/**
* PearBrowser — Bare Worklet Backend
*
* The P2P engine that powers PearBrowser. Runs inside a Bare worklet
* on the phone. Manages Hyperswarm connections, app store catalog,
* site publishing, and the HTTP proxy for WebView content.
*/
const Hyperswarm = require('hyperswarm')
const Corestore = require('corestore')
const Hyperdrive = require('hyperdrive')
const b4a = require('b4a')
const z32 = require('z32')
const fs = require('bare-fs')
const crypto = require('bare-crypto')
// Normalize a Hyperdrive key from either 64-char hex or
// 52-char z-base-32 (Vinjari-style) into lowercase hex.
// Unknown formats pass through so existing errors still fire.
function normalizeDriveKey (raw) {
if (!raw) return raw
if (/^[0-9a-f]{64}$/i.test(raw)) return raw.toLowerCase()
if (/^[13-9a-km-uw-z]{52}$/i.test(raw)) {
try { return Buffer.from(z32.decode(raw.toLowerCase())).toString('hex') }
catch { return raw }
}
return raw
}
const { WorkletRPC } = require('./rpc.js')
const { HyperProxy } = require('./hyper-proxy.js')
const { TabRuntime } = require('./tab-runtime.js')
const { RelayClient } = require('./relay-client.js')
const { CatalogManager } = require('./catalog-manager.js')
const communitySubmit = require('./community-submit.cjs')
const { AppManager } = require('./app-manager.js')
const { SiteManager } = require('./site-manager.js')
const { PearBridge, PEAR_SWARM_V1_SHIM, PEAR_SYNC_SHIM, PEAR_ANONGPT_SHIM } = require('./pear-bridge.js')
const { HttpBridge } = require('./http-bridge.js')
const { AskBrowserService } = require('./ai/ask-browser-service.cjs')
const { ContentShield } = require('./content-shield.cjs')
const { ShieldListSync } = require('./shield-list-sync.cjs')
const { PluginDriveLoader } = require('./plugin-drive-loader.cjs')
const { PluginCatalog } = require('./plugin-catalog.cjs')
const { PearPluginRegistry } = require('./pear-plugins.cjs')
const { SessionBridge } = require('./session-bridge.cjs')
const {
normalizePrivacySettings,
mergeSettingsWithPrivacyDefaults,
isHistoryEnabled,
isSearchIndexEnabled,
DEFAULT_PRIVACY
} = require('./privacy-policy.cjs')
const { AnongptBuyer } = require('./anongpt-buyer.js')
const { UserData } = require('./user-data.js')
const { Identity, validateMnemonic } = require('./identity.js')
const { Profile } = require('./profile.js')
const { Contacts } = require('./contacts.js')
const { Names } = require('./names.cjs')
const { resolveName } = require('./resolve-name.cjs')
const { NameRegistry } = require('./name-registry-store.cjs')
const { NostrEventStore } = require('./nostr-events-store.cjs')
const { createSearchHandler } = require('./search-handler.js')
const { PersonalIndex } = require('./personal-index.cjs')
const { SwarmBridge } = require('./swarm-bridge.js')
const { SwarmGrants } = require('./swarm-grants.js')
const C = require('./constants.js')
const { IPC } = BareKit
const storagePath = Bare.argv[0] || './pearbrowser-storage'
const ENV = (typeof Bare !== 'undefined' && Bare.env) ||
(typeof process !== 'undefined' && process.env) || {}
const ENABLE_DEV_CATALOGUE = ENV.PEARBROWSER_DEV_CATALOGUE === '1'
const ENABLE_RELAY_INDEX_ROOMS = ENV.PEARBROWSER_RELAY_INDEX_ROOMS === '1'
// Browser origin isolation is a security boundary, not an experiment: without
// it, one drive can same-origin read another drive's injected page/API tokens.
// Keep an explicit emergency rollback while shipping the safe posture by
// default.
const ENABLE_PER_DRIVE_ORIGINS = ENV.PEARBROWSER_PER_DRIVE_ORIGINS !== '0'
// Swallow benign races that would otherwise crash Bare with an
// unhandled rejection — most commonly `Corestore is closed` from
// a swarm connection that arrives mid-shutdown, or from inside
// p2p-hiverelay's connection handler after a drive is deleted.
Bare.on('uncaughtException', (err) => {
console.error('[uncaughtException]', err && err.stack || err)
})
// Bare doesn't fire unhandledRejection by default; wrap with a
// process-level listener where available.
try {
if (typeof process !== 'undefined' && process.on) {
process.on('unhandledRejection', (err) => {
console.error('[unhandledRejection]', err && err.stack || err)
})
}
} catch {}
// --- Storage Limits ---
const {
DEFAULT_STORAGE_LIMIT: STORAGE_LIMIT,
DEFAULT_EVICT_THRESHOLD: EVICT_THRESHOLD,
shouldCleanupStorage,
cleanupBrowseStorage
} = require('./storage-quota.cjs')
const STORAGE_CHECK_INTERVAL = 5 * 60 * 1000 // Check every 5 minutes
// --- State ---
let swarm = null
let store = null
let proxy = null
let tabRuntime = null
let catalogManager = null
let personalIndex = null // Lighthouse Phase-0 local self-search (search-core)
let appManager = null
let siteManager = null
let pearBridge = null
let relayClient = null
let hiveRelay = null // p2p-hiverelay HiveRelayClient — always-on pinning
let userData = null
let identity = null
let profile = null
let contacts = null
let identityBindingPublisher = null // Lighthouse Phase 2 — root→search-key binding publish/resolve
let nostrBindingStore = null // NOSTR2 Phase 1 — local cross-curve binding state (npub + epoch)
let nostrEventStore = null // NOSTR Phase 2 — the user's own NIP-01 event log; null until first publish
let queryPlanner = null // Lighthouse Phase 2 — federated query orchestration (local-first + peer fan-out)
let names = null // naming Phase N1 — local petname store (names.cjs); null until ready
let nameRegistry = null // naming Phase N5 — multi-writer Autobase name registry; null until first use
let federatedNameResolver = null // naming Phase N5 — resolve contacts' names across their registries
let federatedNostrFeed = null // NOSTR Phase 3 — surface contacts' attested events in the feed
let swarmBridge = null
let swarmGrants = null
let aiService = null
let contentShield = null // Content Shield — request filter + cosmetic hiding (BROWSER_PARITY_PLAN.md)
let pearPlugins = null // Pear Plugins registry (Phase 3)
let shieldListSync = null // P2P filter-list subscriptions (Phase 2 gate)
let pluginDriveLoader = null // Plugin installs from drives (Phase 3 gate)
let pluginCatalog = null // Plugin discovery: builtin seed + subscribed catalogue drives
let sessionBridge = null // Clearnet session bridge facade (Phase 4)
let privacySettings = { ...DEFAULT_PRIVACY }
/** Map<requestId, { resolve, reject, timer }> for login() ceremonies. */
const pendingLogins = new Map()
/** Map<requestId, { resolve, reject, timer }> for swarm.join() consent ceremonies. */
const pendingSwarmConsents = new Map()
const SWARM_CONSENT_TIMEOUT_MS = 2 * 60 * 1000 // 2 minutes
let peerCount = 0
let browseDrives = new Map() // keyHex → Hyperdrive (for ad-hoc browsing)
let storageTimer = null // handle for the periodic storage-quota check
// Resolves when boot() finishes setting up all managers. Handlers that
// need managers await this so RPC calls issued during the 1–2s boot
// window queue instead of crashing with `null.X`.
let bootResolve, bootReject
const bootReady = new Promise((res, rej) => { bootResolve = res; bootReject = rej })
async function whenReady () { return bootReady }
// --- RPC ---
const rpc = new WorkletRPC(IPC)
const askBrowserService = new AskBrowserService({
getAiService: () => aiService,
loadContext: async (page) => ({
context: page,
source: { kind: page?.text || page?.selection ? 'active-page' : 'metadata' }
}),
emit: (payload) => rpc.event(C.EVT_ASK_BROWSER_STREAM, payload)
})
// Browser commands
rpc.handle(C.CMD_NAVIGATE, async (data = {}) => {
await whenReady()
let navUrl = typeof data.url === 'string' ? data.url : ''
if (!navUrl) throw new Error('url required')
// Phase 4 clearnet / loopback / hyper routing via SessionBridge when ready.
if (sessionBridge) {
const resolved = sessionBridge.resolveNavigation(navUrl)
if (resolved.kind === 'clearnet') {
return {
localUrl: resolved.localUrl,
key: null,
path: '/',
apiToken: null,
contextToken: null,
kind: 'clearnet',
url: resolved.url,
mode: resolved.mode,
upgraded: !!resolved.upgraded,
stripped: resolved.stripped || [],
shieldActive: !!resolved.shieldActive
}
}
if (resolved.kind === 'loopback') {
const proxyDriveKey = driveKeyFromProxyUrl(resolved.url)
const contextToken = proxyDriveKey && proxy?.pageContextToken
? proxy.pageContextToken(proxyDriveKey)
: tabRuntime?.contextTokenForUrl?.(resolved.url) || null
return {
localUrl: resolved.localUrl,
key: proxyDriveKey || null,
path: new URL(resolved.url).pathname || '/',
apiToken: null,
contextToken,
kind: 'loopback',
url: resolved.url
}
}
if (resolved.kind === 'hyper') navUrl = resolved.url
}
const parsed = new URL(navUrl)
// The run-in-tab wrapper is served by tab-runtime on a loopback http(s) port
// (whitelisted in pear.json links) — load it directly, not via /hyper/<key>.
if ((parsed.protocol === 'http:' || parsed.protocol === 'https:') &&
(parsed.hostname === '127.0.0.1' || parsed.hostname === 'localhost')) {
const proxyDriveKey = driveKeyFromProxyUrl(navUrl)
const contextToken = proxyDriveKey && proxy?.pageContextToken
? proxy.pageContextToken(proxyDriveKey)
: tabRuntime?.contextTokenForUrl?.(navUrl) || null
return { localUrl: navUrl, key: proxyDriveKey || null, path: parsed.pathname || '/', apiToken: null, contextToken, kind: 'loopback' }
}
// Non-loopback http(s) without session bridge: refuse rather than map host → drive key
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
throw new Error('Clearnet navigation requires session bridge (still booting?)')
}
const key = normalizeDriveKey(parsed.hostname)
const path = parsed.pathname || '/'
const validKey = /^[0-9a-f]{64}$/i.test(key)
// Start loading the drive in the background — don't wait for sync.
// The proxy will handle waiting for content when WebView requests it.
// This makes NAVIGATE instant while the drive syncs behind the scenes.
ensureBrowseDrive(key).catch((err) => {
console.error('Failed to load browse drive:', err.message)
})
const pathAndSearch = `${path}${parsed.search || ''}`
const localUrl = validKey && proxy?.localUrlForDrive
? await proxy.localUrlForDrive(key, 'hyper', pathAndSearch)
: `http://127.0.0.1:${proxy.port}/hyper/${key}${pathAndSearch}`
const apiToken = validKey && proxy?.issueApiToken
? proxy.issueApiToken(key, { origin: localUrl })
: null
const contextToken = validKey && proxy?.pageContextToken
? proxy.pageContextToken(key)
: null
return {
localUrl,
key,
path,
apiToken,
contextToken,
kind: 'hyper',
url: navUrl
}
})
rpc.handle(C.CMD_GET_STATUS, async () => {
let storageSize = 0
try {
storageSize = await getStorageSize(storagePath)
} catch {}
return {
dhtConnected: swarm !== null,
peerCount,
browseDrives: browseDrives?.size || 0,
installedApps: appManager?.installed?.size || 0,
publishedSites: siteManager?.sites?.size || 0,
proxyPort: proxy?.port || 0,
hiveRelays: hiveRelay?.getRelays ? hiveRelay.getRelays().length : 0,
storageUsed: storageSize,
storageLimit: STORAGE_LIMIT,
storagePercent: Math.round((storageSize / STORAGE_LIMIT) * 100)
}
})
rpc.handle(C.CMD_GET_DRIVE_INFO, async (data = {}) => {
await whenReady()
const keyHex = normalizeDriveKey(data.keyHex || driveKeyFromUrl(data.url))
if (!/^[0-9a-f]{64}$/i.test(keyHex)) {
throw new Error('Invalid drive key format')
}
const drive = await getDriveForProxy(keyHex)
registerHiveRelayDrive(keyHex, drive)
await updateDriveBestEffort(drive)
const peers = getDrivePeerSnapshot(drive)
const bytes = getDriveByteSnapshot(drive)
return {
keyHex,
discoveryKey: drive?.discoveryKey ? b4a.toString(drive.discoveryKey, 'hex') : null,
version: Number.isFinite(drive?.version) ? drive.version : null,
writable: !!drive?.writable,
...peers,
...bytes,
relay: getRelayPinSnapshot(keyHex, drive),
updatedAt: Date.now()
}
})
rpc.handle(C.CMD_RELEASE_ORIGIN, async (data = {}) => {
await whenReady()
const keyHex = normalizeDriveKey(data.keyHex || driveKeyFromProxyUrl(data.url) || driveKeyFromUrl(data.url))
if (!/^[0-9a-f]{64}$/i.test(keyHex)) {
return { ok: false, released: false, reason: 'invalid-drive-key' }
}
const released = proxy?.releaseDriveOrigin ? await proxy.releaseDriveOrigin(keyHex) : false
return { ok: true, released, keyHex: keyHex.toLowerCase() }
})
// Ask Browser is browser-chrome-owned. Hyperdrive pages use the separately
// token/manifest-gated /api/ai/* surface and cannot invoke these RPC commands.
rpc.handle(C.CMD_ASK_BROWSER_CAPABILITIES, async () => {
await whenReady()
return askBrowserService.capabilities()
})
rpc.handle(C.CMD_ASK_BROWSER_START, async (data = {}) => {
await whenReady()
return askBrowserService.start(data)
})
rpc.handle(C.CMD_ASK_BROWSER_CANCEL, async (data = {}) => {
return { ok: await askBrowserService.cancel(data) }
})
// Content Shield live status for the Settings card + urlbar chip.
// Toggle / allowlist / strict / lists persist via user-data settings.
rpc.handle(C.CMD_SHIELD_STATUS, async (data = {}) => {
await whenReady()
if (!contentShield) {
return {
enabled: false, blocked: 0, allowed: 0, blockRules: 0, exceptionRules: 0,
cosmeticRules: 0, scriptletRules: 0, lists: [], listDetails: [],
allowlist: [], strict: [], plugins: {}, topRules: []
}
}
const stats = contentShield.stats()
const driveKey = typeof data.driveKey === 'string' ? data.driveKey.trim().toLowerCase() : ''
if (driveKey && /^[0-9a-f]{64}$/.test(driveKey)) {
stats.driveKey = driveKey
stats.driveAllowlisted = contentShield.isAllowlisted(driveKey)
stats.driveStrict = contentShield.isStrict(driveKey)
}
stats.subscriptions = shieldListSync ? shieldListSync.subscriptions() : []
return stats
})
rpc.handle(C.CMD_SHIELD_LOAD_LIST, async (data = {}) => {
await whenReady()
if (!contentShield) throw new Error('Content Shield not available')
const name = typeof data.name === 'string' ? data.name.trim() : ''
if (!name || name === 'builtin' || name.startsWith('plugin:')) {
throw new Error('invalid list name')
}
const result = contentShield.addList(name, data.text == null ? '' : String(data.text))
await persistShieldState()
return result
})
rpc.handle(C.CMD_SHIELD_REMOVE_LIST, async (data = {}) => {
await whenReady()
if (!contentShield) throw new Error('Content Shield not available')
const name = typeof data.name === 'string' ? data.name.trim() : ''
if (!name || name === 'builtin') throw new Error('invalid list name')
const removed = contentShield.removeList(name)
await persistShieldState()
return { removed, name }
})
rpc.handle(C.CMD_SHIELD_SET_ALLOW, async (data = {}) => {
await whenReady()
if (!contentShield) throw new Error('Content Shield not available')
const driveKey = typeof data.driveKey === 'string' ? data.driveKey.trim().toLowerCase() : ''
if (!/^[0-9a-f]{64}$/.test(driveKey)) throw new Error('driveKey must be 64-char hex')
if (data.allow) contentShield.allowlistDrive(driveKey)
else contentShield.removeAllowlistDrive(driveKey)
await persistShieldState()
return {
driveKey,
allowlisted: contentShield.isAllowlisted(driveKey),
allowlist: contentShield.allowlist()
}
})
rpc.handle(C.CMD_SHIELD_SET_STRICT, async (data = {}) => {
await whenReady()
if (!contentShield) throw new Error('Content Shield not available')
const driveKey = typeof data.driveKey === 'string' ? data.driveKey.trim().toLowerCase() : ''
if (!/^[0-9a-f]{64}$/.test(driveKey)) throw new Error('driveKey must be 64-char hex')
contentShield.setStrictDrive(driveKey, !!data.strict)
await persistShieldState()
return {
driveKey,
strict: contentShield.isStrict(driveKey),
strictDrives: contentShield.strictDrives()
}
})
rpc.handle(C.CMD_PLUGIN_LIST, async () => {
await whenReady()
return { plugins: pearPlugins ? pearPlugins.list() : [] }
})
rpc.handle(C.CMD_PLUGIN_SET_ENABLED, async (data = {}) => {
await whenReady()
if (!pearPlugins) throw new Error('Plugin registry not available')
const result = pearPlugins.setEnabled(data.id, data.enabled !== false)
await persistShieldState()
return result
})
rpc.handle(C.CMD_PLUGIN_REGISTER, async (data = {}) => {
await whenReady()
if (!pearPlugins) throw new Error('Plugin registry not available')
const result = pearPlugins.register({
id: data.id,
manifest: data.manifest,
contribution: data.contribution,
enabled: data.enabled !== false
})
if (result && result.ok) rememberPluginPayload(data)
await persistShieldState()
return result
})
async function persistShieldState () {
if (!contentShield || !userData) return
try {
const state = contentShield.exportListState()
// Sync enable flags from registry into durable state.
if (pearPlugins) {
for (const p of pearPlugins.list()) {
state.plugins[p.id] = p.enabled
if (persistShieldState._pluginPayloads[p.id]) {
persistShieldState._pluginPayloads[p.id].enabled = p.enabled
}
}
}
await userData.setSettings({
contentShieldState: state,
contentShieldPlugins: { ...persistShieldState._pluginPayloads },
contentShieldAllow: state.allowlist,
contentShieldStrict: state.strict
})
} catch (err) {
console.warn('[content-shield] persist failed:', err && err.message)
}
}
// Remember last-known plugin register payloads so restart can rehydrate.
persistShieldState._pluginPayloads = {}
function rememberPluginPayload (data) {
if (!data || !data.id) return
const id = String(data.id).trim().toLowerCase()
persistShieldState._pluginPayloads[id] = {
id,
manifest: data.manifest,
contribution: data.contribution,
enabled: data.enabled !== false
}
}
// --- P2P distribution: filter-list drives + plugin drives ---
//
// Rule text and plugin payloads are durable through persistShieldState();
// these commands own the drive-sourced lifecycle (subscribe/refresh/install/
// update) so lists and plugins keep working offline after first sync and
// hot-swap when their drives update.
rpc.handle(C.CMD_SHIELD_SUBSCRIBE_LIST, async (data = {}) => {
await whenReady()
if (!shieldListSync) throw new Error('Shield list sync not available')
const result = await shieldListSync.subscribe(data.driveKey)
await persistShieldState()
return { ...result, subscriptions: shieldListSync.subscriptions() }
})
rpc.handle(C.CMD_SHIELD_UNSUBSCRIBE_LIST, async (data = {}) => {
await whenReady()
if (!shieldListSync) throw new Error('Shield list sync not available')
const result = await shieldListSync.unsubscribe(data.driveKey)
await persistShieldState()
return { ...result, subscriptions: shieldListSync.subscriptions() }
})
rpc.handle(C.CMD_SHIELD_REFRESH_LISTS, async (data = {}) => {
await whenReady()
if (!shieldListSync) throw new Error('Shield list sync not available')
const outcomes = data.driveKey
? [{ driveKey: String(data.driveKey).toLowerCase(), ok: true, ...(await shieldListSync.refresh(data.driveKey, { force: !!data.force })) }]
: await shieldListSync.refreshAll({ force: !!data.force })
await persistShieldState()
return { outcomes, subscriptions: shieldListSync.subscriptions() }
})
rpc.handle(C.CMD_PLUGIN_INSTALL_DRIVE, async (data = {}) => {
await whenReady()
if (!pluginDriveLoader) throw new Error('Plugin drive loader not available')
const result = await pluginDriveLoader.installFromDrive(data.driveKey, {
grantedCapabilities: data.granted,
reviewedFingerprint: data.reviewedFingerprint
})
await persistShieldState()
return result
})
rpc.handle(C.CMD_PLUGIN_UPDATE_DRIVE, async (data = {}) => {
await whenReady()
if (!pluginDriveLoader) throw new Error('Plugin drive loader not available')
const result = await pluginDriveLoader.updateFromDrive(data.driveKey, {
grantedCapabilities: data.granted,
reviewedFingerprint: data.reviewedFingerprint
})
await persistShieldState()
return result
})
rpc.handle(C.CMD_PLUGIN_UNINSTALL, async (data = {}) => {
await whenReady()
if (!pluginDriveLoader) throw new Error('Plugin drive loader not available')
const result = await pluginDriveLoader.uninstall(data.driveKey)
if (result.driveKey) delete persistShieldState._pluginPayloads[result.driveKey]
await persistShieldState()
return result
})
// Plugin discovery. The catalogue is metadata-only: installing still runs
// through the drive loader's grant + escalation path, and `kind: "app"`
// entries (anonGPT) simply open as hyper:// apps.
rpc.handle(C.CMD_PLUGIN_CATALOG, async () => {
await whenReady()
if (!pluginCatalog) return { entries: [], sources: [] }
const installed = new Set(pearPlugins ? pearPlugins.list().map(p => p.id) : [])
return {
entries: pluginCatalog.entries().map(entry => ({
...entry,
installed: !!(entry.driveKey && installed.has(entry.driveKey))
})),
sources: pluginCatalog.sources()
}
})
rpc.handle(C.CMD_PLUGIN_CATALOG_LOAD_DRIVE, async (data = {}) => {
await whenReady()
if (!pluginCatalog) throw new Error('Plugin catalogue not available')
const result = await pluginCatalog.loadFromDrive(data.driveKey)
await persistPluginCatalog()
return { ...result, sources: pluginCatalog.sources() }
})
rpc.handle(C.CMD_PLUGIN_CATALOG_REMOVE_SOURCE, async (data = {}) => {
await whenReady()
if (!pluginCatalog) throw new Error('Plugin catalogue not available')
const removed = pluginCatalog.removeSource(data.driveKey)
await persistPluginCatalog()
return { removed, sources: pluginCatalog.sources() }
})
function persistPluginCatalog () {
if (!pluginCatalog || !userData) return Promise.resolve()
return userData.setSettings({ contentShieldPluginCatalog: pluginCatalog.exportState() }).catch(() => {})
}
function persistPluginInstallRecords () {
if (!pluginDriveLoader || !userData) return Promise.resolve()
const records = {}
for (const record of pluginDriveLoader.installs()) {
records[record.driveKey] = {
granted: record.granted,
version: record.version,
installedAt: record.installedAt,
escalated: record.escalated
}
}
return userData.setSettings({ contentShieldPluginInstalls: records }).catch(() => {})
}
rpc.handle(C.CMD_GET_IDENTITY, async () => {
return {
publicKey: swarm ? swarm.keyPair.publicKey.toString('hex') : null,
// Identity metadata for the Settings "My Identity" panel
hasBackupPhrase: !!identity,
mnemonicWordCount: identity ? identity.getMnemonic().split(' ').length : 0,
}
})
// App Store commands
rpc.handle(C.CMD_LOAD_CATALOG, async (data) => {
await whenReady()
return await catalogManager.loadCatalog(normalizeDriveKey(data.keyHex))
})
// Phase 1 ticket 1 — Hyperbee-backed catalog (pre-positioning; relay
// publishes Hyperdrive catalogs today. Once a Hyperbee catalog exists,
// ExploreScreen can switch by sending `hyperbee://<key>` URLs.)
rpc.handle(C.CMD_LOAD_CATALOG_BEE, async (data) => {
await whenReady()
return await catalogManager.loadCatalogBee(normalizeDriveKey(data.keyHex))
})
// Phase 2 — Autobase collaborative catalog ("Autobee"). EXPERIMENTAL and
// OFF BY DEFAULT: the flag is enforced here (server-side) so a stale or
// tampered renderer can't enable the experiment. Toggle via the
// `experimentalAutobeeCatalogs` user-data setting. See AUTOBEE-RESEARCH.md.
rpc.handle(C.CMD_LOAD_CATALOG_AUTOBEE, async (data) => {
await whenReady()
await requireAutobee()
return await catalogManager.loadCatalogAutobee(normalizeDriveKey(data.keyHex))
})
// schema-sheets catalogue (the 4th source). Takes a z32 room LINK (key, or
// key+encryptionKey) — NOT a normalized hex drive key. Pins best-effort so the
// catalogue survives writers going offline. Coexists with the other formats.
rpc.handle(C.CMD_SHEETS_LOAD, async (data) => {
await whenReady()
const link = String((data && data.link) || '').trim()
if (!link) throw new Error('sheets catalogue link required')
const result = await catalogManager.loadCatalogSheets(link)
try {
const { decodeSheetsLink } = require('./sheets-catalog')
const { keyHex } = decodeSheetsLink(link)
const entry = catalogManager.catalogs.get(`sheets:${keyHex}`)
const dk = entry && entry.sheets && entry.sheets.discoveryKey()
if (dk) await pinDriveBestEffort(keyHex, dk)
} catch (err) { console.error('[sheets] pin best-effort failed:', err && err.message) }
return result
})
// Phase 5 — load a catalogue from a relay's index room (hiveindex://). Takes a
// z32 indexRoom LINK (from the relay's capability doc / catalog.json). The relay
// hosts its own index room, so no client-side pin is needed (unlike a
// user-shared sheets catalogue). Coexists with the other sources.
rpc.handle(C.CMD_LOAD_CATALOG_INDEX, async (data) => {
await whenReady()
const link = String((data && data.link) || '').trim()
if (!link) throw new Error('index-room link required')
return await catalogManager.loadCatalogIndexRoom(link)
})
// Re-query a loaded sheets catalogue with a JMESPath filter (powers search).
// The query is validated against a whitelist in SheetsCatalog.listApps; the
// length cap here is an early-reject defense-in-depth (Risk #2 / #9).
rpc.handle(C.CMD_SHEETS_LIST, async (data) => {
await whenReady()
const link = String((data && data.link) || '').trim()
if (!link) throw new Error('sheets catalogue link required')
const query = data && data.query != null ? String(data.query) : undefined
if (query && query.length > 512) throw new Error('Search query too long')
return await catalogManager.querySheetsCatalog(link, query)
})
rpc.handle(C.CMD_SHEETS_LIST_SCHEMAS, async (data) => {
await whenReady()
const link = String((data && data.link) || '').trim()
if (!link) throw new Error('sheets catalogue link required')
return { schemas: await catalogManager.listSheetsSchemas(link) }
})
rpc.handle(C.CMD_INSTALL_APP, async (data) => {
await whenReady()
const result = await appManager.install(data, (progress) => {
rpc.event(C.EVT_INSTALL_PROGRESS, { appId: data.id, progress })
})
persistState()
return result
})
rpc.handle(C.CMD_UNINSTALL_APP, async (data) => {
const result = await appManager.uninstall(data.id)
persistState()
return result
})
rpc.handle(C.CMD_LAUNCH_APP, async (data) => {
const app = appManager.installed.get(data.id)
if (!app) throw new Error('App not installed: ' + data.id)
// Ensure the app drive is loaded in the proxy
await appManager.getDrive(app.driveKey)
const driveKey = normalizeDriveKey(app.driveKey)
const localUrl = proxy?.localUrlForDrive
? await proxy.localUrlForDrive(driveKey, 'app', '/index.html')
: `http://127.0.0.1:${proxy.port}/app/${driveKey}/index.html`
return {
localUrl,
appId: data.id,
name: app.name,
driveKey,
apiToken: proxy?.issueApiToken ? proxy.issueApiToken(driveKey, { origin: localUrl }) : null,
contextToken: proxy?.pageContextToken ? proxy.pageContextToken(driveKey) : null
}
})
rpc.handle(C.CMD_LIST_INSTALLED, () => {
if (!appManager) return []
return appManager.listInstalled()
})
rpc.handle(C.CMD_CHECK_UPDATES, async () => {
const allApps = catalogManager.getAllApps()
return await appManager.checkUpdates(allApps)
})
// Aggregated catalog-of-catalogs view: every app across every loaded
// catalog, de-duplicated, plus the list of loaded catalogs for facets.
rpc.handle(C.CMD_GET_CATALOG_APPS, () => {
if (!catalogManager) return { apps: [], catalogs: [] }
return {
apps: catalogManager.getAggregatedApps(),
catalogs: catalogManager.listCatalogs(),
}
})
rpc.handle(C.CMD_UNLOAD_CATALOG, async (data) => {
if (!catalogManager) return false
return await catalogManager.unloadCatalog(normalizeDriveKey(data.keyHex))
})
// --- Lighthouse P2P search (Phase 0 — local self-search) ---
// Query / feed the personal index. Querying is fully local (no network);
// indexing is best-effort and never throws to the caller.
// Local-first + optional background federate (Lighthouse Phase 2). handleSearch
// (search-handler.js) returns local hop-0 results synchronously and, when
// data.federated is set and a planner exists, pushes EVT_SEARCH_FEDERATED later
// with the enriched trusted-peer set (queryId-correlated, stale-suppressed). The
// reply shape is the old { results, stats } plus additive { phase, federating,
// queryId } — so existing renderers keep working unchanged.
const handleSearch = createSearchHandler({
getPersonalIndex: () => personalIndex,
getQueryPlanner: () => queryPlanner,
emit: (payload) => rpc.event(C.EVT_SEARCH_FEDERATED, payload),
})
rpc.handle(C.CMD_SEARCH, async (data) => {
await whenReady()
return handleSearch(data)
})
rpc.handle(C.CMD_SEARCH_FEDERATED, async (data = {}) => {
await whenReady()
return handleSearch({ ...data, federated: true })
})
// Lighthouse Phase 2 — publish/refresh our IdentityBinding on demand (e.g. a
// Settings "rotate search key" action), and resolve a contact's current search
// pubkey from the DHT (verified against their Contacts-held root).
rpc.handle(C.CMD_IDENTITY_BINDING_PUBLISH, async (data) => {
await whenReady()
if (!identityBindingPublisher) throw new Error('binding publisher unavailable')
return await identityBindingPublisher.publish({ rotate: !!(data && data.rotate) })
})
rpc.handle(C.CMD_IDENTITY_BINDING_RESOLVE, async (data) => {
await whenReady()
if (!identityBindingPublisher) throw new Error('binding publisher unavailable')
if (!data || !data.contactPubkey || !data.dhtPubkey) throw new Error('contactPubkey + dhtPubkey required')
const resolved = await identityBindingPublisher.resolve({
contactPubkey: String(data.contactPubkey), dhtPubkey: String(data.dhtPubkey),
})
return resolved || { searchPubkey: null, indexKey: null }
})
rpc.handle(C.CMD_SEARCH_INDEX, async (data) => {
await whenReady()
// Privacy-first: local page indexing is opt-in (searchIndexEnabled).
if (!personalIndex || !data || !data.driveKey) return { ok: false, indexed: false }
try {
const settings = userData ? await userData.getSettings() : {}
if (!isSearchIndexEnabled(settings)) {
return { ok: true, indexed: false, reason: 'search-index-disabled' }
}
const docId = await personalIndex.indexDoc({
driveKey: normalizeDriveKey(data.driveKey),
path: data.path || '/',
title: data.title || '',
body: data.text || data.body || '',
publishedAt: Number.isFinite(data.publishedAt) ? data.publishedAt : 0,
})
return { ok: !!docId, docId, indexed: !!docId }
} catch (err) {
console.error('[search] index failed')
return { ok: false, indexed: false }
}
})
// --- Catalog authoring (your own publishable catalog) ---
// Each mutation re-pins the catalog drive to the HiveRelay backbone so the
// new content is durable and discoverable even when this device is offline.
rpc.handle(C.CMD_MYCATALOG_GET, async (data) => {
await whenReady()
return await catalogManager.getMyCatalog(normalizeDriveKey(data.keyHex))
})
rpc.handle(C.CMD_MYCATALOG_CREATE, async (data) => {
await whenReady()
const result = await catalogManager.createMyCatalog(data && data.name)
await pinDriveBestEffort(result.keyHex, catalogManager.myCatalogDiscoveryKey(result.keyHex))
return result
})
rpc.handle(C.CMD_MYCATALOG_ADD_APP, async (data) => {
await whenReady()
const keyHex = normalizeDriveKey(data.keyHex)
const result = await catalogManager.addAppToCatalog(keyHex, data.app)
await pinDriveBestEffort(keyHex, catalogManager.myCatalogDiscoveryKey(keyHex))
return result
})
rpc.handle(C.CMD_MYCATALOG_REMOVE_APP, async (data) => {
await whenReady()
const keyHex = normalizeDriveKey(data.keyHex)
const result = await catalogManager.removeAppFromCatalog(keyHex, data.id)
await pinDriveBestEffort(keyHex, catalogManager.myCatalogDiscoveryKey(keyHex))
return result
})
rpc.handle(C.CMD_MYCATALOG_RENAME, async (data) => {
await whenReady()
const keyHex = normalizeDriveKey(data.keyHex)
const result = await catalogManager.renameMyCatalog(keyHex, data.name)
await pinDriveBestEffort(keyHex, catalogManager.myCatalogDiscoveryKey(keyHex))
return result
})
rpc.handle(C.CMD_MYCATALOG_UPDATE_APP, async (data) => {
await whenReady()
const keyHex = normalizeDriveKey(data.keyHex)
const result = await catalogManager.updateAppInCatalog(keyHex, data.id, data.app)
await pinDriveBestEffort(keyHex, catalogManager.myCatalogDiscoveryKey(keyHex))
return result
})
// ─── Community app submission + moderation (2026-06-22) ──────────────────────
// Anyone can submit their app to the COMMUNITY catalogue; the relay queues a pin
// request in `review` mode; an operator-gated in-app moderator panel reviews the
// queue and approves/rejects via the relay management API. Pure projection +
// request shaping live in backend/community-submit.cjs (unit-tested). See the
// catalogue-unified memory for the two-list (curated f5fb7500 / community
// 5d961fdc) architecture.
// Operator's relay management endpoint + API key (set via the moderator panel,
// persisted in userdata settings). Empty until configured.
const moderationPendingByKey = new Map()
const moderationReviewCache = new Map()
let moderationPendingMode = null
let moderationPendingLoadedAt = 0
const MODERATION_REVIEW_CACHE_MS = 5 * 60 * 1000
const MAX_REVIEW_INDEX_BYTES = communitySubmit.MAX_INDEX_BYTES
const MAX_REVIEW_MANIFEST_BYTES = 256 * 1024
const MAX_RELAY_MANAGE_RESPONSE_BYTES = 2 * 1024 * 1024
async function getModSettings () {
try {
const s = (await requireUserData().getSettings()) || {}
return {
baseUrl: s.relayManageUrl || '',
apiKey: s.relayManageKey || '',
audit: Array.isArray(s.communityReviewAudit) ? s.communityReviewAudit.slice(0, 50) : []
}
} catch { return { baseUrl: '', apiKey: '', audit: [] } }
}
function withReviewTimeout (promise, ms, label) {
let timer
const guard = new Promise((resolve, reject) => {
timer = setTimeout(() => reject(new Error(label + ' timed out')), ms)
})
return Promise.race([promise, guard]).finally(() => clearTimeout(timer))
}
function boundedReviewText (value, max = 1000) {
return String(value == null ? '' : value).trim().slice(0, max)
}
async function readBoundedReviewFile (drive, path, maxBytes, timeoutMs, label) {
const entry = await withReviewTimeout(drive.entry(path, { wait: true }), timeoutMs, label + ' metadata fetch')
const blob = entry && entry.value && entry.value.blob
if (!blob) return { buffer: null, byteLength: 0, tooLarge: false }
const byteLength = Number(blob.byteLength) || 0
if (byteLength > maxBytes) return { buffer: null, byteLength, tooLarge: true }
const buffer = await withReviewTimeout(drive.get(path, { wait: true }), timeoutMs, label + ' fetch')
const actualByteLength = buffer ? buffer.length : 0
return {
buffer: actualByteLength > maxBytes ? null : buffer,
byteLength: actualByteLength,
tooLarge: actualByteLength > maxBytes
}
}
async function appendCommunityReviewAudit (entry) {
const store = requireUserData()
const settings = (await store.getSettings()) || {}
const previous = Array.isArray(settings.communityReviewAudit) ? settings.communityReviewAudit : []
const record = {
appKey: boundedReviewText(entry.appKey, 64).toLowerCase(),
publisherPubkey: boundedReviewText(entry.publisherPubkey, 64).toLowerCase(),
action: entry.action === 'approve' ? 'approve' : 'reject',
reason: boundedReviewText(entry.reason, 500),
note: boundedReviewText(entry.note, 1000),
reviewStatus: boundedReviewText(entry.reviewStatus, 40),
summary: entry.summary && typeof entry.summary === 'object'
? {
pass: Number(entry.summary.pass) || 0,
warning: Number(entry.summary.warning) || 0,
block: Number(entry.summary.block) || 0
}
: null,
decidedAt: Date.now()
}
await store.setSettings({ communityReviewAudit: [record, ...previous].slice(0, 100) })
return record
}
async function appendCommunityReviewAuditSafely (entry) {
try {
return { audit: await appendCommunityReviewAudit(entry), auditWarning: '' }
} catch (err) {
const auditWarning = 'Relay decision succeeded, but the local audit record could not be saved: ' + boundedReviewText(err && err.message, 300)
console.error('[mod] ' + auditWarning)
return { audit: null, auditWarning }
}
}
async function reviewPendingSubmission (appKey, pending = {}, opts = {}) {
const normalizedKey = normalizeDriveKey(String(appKey || ''))
if (!/^[0-9a-f]{64}$/i.test(normalizedKey)) throw new Error('Due diligence needs a valid 64-hex appKey.')
const cached = moderationReviewCache.get(normalizedKey)
if (!opts.force && cached && Date.now() - cached.checkedAt < MODERATION_REVIEW_CACHE_MS) return cached
let receiptDriveVersion = 0
let targetDriveVersion = 0
let targetDriveKey = ''
let indexBuf = null
let indexBytes = 0
let manifestBuf = null
let manifest = null
let manifestError = ''
let receiptFetchError = ''
let targetFetchError = ''
try {
const receiptDrive = await withReviewTimeout(ensureBrowseDrive(normalizedKey), 12000, 'Receipt drive open')
try { await withReviewTimeout(receiptDrive.update({ wait: true }), 10000, 'Receipt drive update') } catch (_) {}
receiptDriveVersion = Number(receiptDrive.version) || 0
try {
const manifestFile = await readBoundedReviewFile(receiptDrive, '/manifest.json', MAX_REVIEW_MANIFEST_BYTES, 8000, 'Receipt manifest')
manifestBuf = manifestFile.buffer
if (manifestFile.tooLarge) {
manifestBuf = null
manifestError = `/manifest.json exceeds the ${MAX_REVIEW_MANIFEST_BYTES}-byte review cap.`
}
} catch (err) {
if (!/timed out/i.test(err && err.message)) manifestError = err && err.message ? err.message : String(err)
}
} catch (err) { receiptFetchError = err && err.message ? err.message : String(err) }
if (manifestBuf) {
if (manifestBuf.length > MAX_REVIEW_MANIFEST_BYTES) {
manifestError = `/manifest.json exceeds the ${MAX_REVIEW_MANIFEST_BYTES}-byte review cap.`
} else {
try { manifest = safeJSONParse(Buffer.from(manifestBuf).toString('utf8')) } catch (err) {
manifestError = err && err.message ? err.message : String(err)
}
}
}
if (manifest && manifest.submissionKind === 'hyper') {
const target = communitySubmit.deriveKeyAndLink(manifest.link || manifest.driveKey, normalizeDriveKey)
if (target.error) {
targetFetchError = target.error
} else {
targetDriveKey = target.driveKey
try {
const targetDrive = await withReviewTimeout(ensureBrowseDrive(targetDriveKey), 12000, 'Target drive open')
try { await withReviewTimeout(targetDrive.update({ wait: true }), 10000, 'Target drive update') } catch (_) {}
targetDriveVersion = Number(targetDrive.version) || 0
const indexFile = await readBoundedReviewFile(targetDrive, '/index.html', MAX_REVIEW_INDEX_BYTES, 12000, 'Target index.html')
indexBuf = indexFile.buffer
indexBytes = indexFile.byteLength
if (indexFile.tooLarge) indexBytes = Math.max(indexBytes, MAX_REVIEW_INDEX_BYTES + 1)
} catch (err) { targetFetchError = err && err.message ? err.message : String(err) }
}
}
const duplicates = catalogManager
? catalogManager.getAggregatedApps().filter((app) => {
if (manifest && manifest.submissionKind === 'pear-v3') {
const submitted = communitySubmit.normalizePearInstallLink(manifest.nativeDelivery && manifest.nativeDelivery.installLink)
const existing = communitySubmit.normalizePearInstallLink(app && app.nativeDelivery && app.nativeDelivery.installLink)
return !submitted.error && !existing.error && submitted.installLink === existing.installLink
}
const driveKey = normalizeDriveKey(String((app && app.driveKey) || ''))
const linkKey = driveKeyFromUrl(app && app.link)
return !!targetDriveKey && (driveKey === targetDriveKey || linkKey === targetDriveKey)
})
: []
const report = communitySubmit.buildReviewReport({
appKey: normalizedKey,
pending,
manifest,
manifestError,
indexText: indexBuf ? Buffer.from(indexBuf).toString('utf8') : '',
indexBytes,
receiptDriveVersion,
targetDriveVersion,
targetDriveKey,
duplicates,
receiptFetchError,
targetFetchError,
normalizeKey: normalizeDriveKey
}, Date.now())
moderationReviewCache.set(normalizedKey, report)
return report
}
// Minimal JSON HTTP(S) round-trip for the relay management API. bare-http1 has
// no TLS, so https:// relays go through bare-https (wraps bare-tls). Returns
// { status, json, text }.
function relayManageHttp (spec, timeoutMs = 15000) {
return new Promise((resolve, reject) => {
let settled = false
let timer
const fail = (err) => {
if (settled) return
settled = true
clearTimeout(timer)
reject(err)
}
let u
try { u = new URL(spec.url) } catch { return fail(new Error('Bad relay URL: ' + spec.url)) }
const isHttps = u.protocol === 'https:'
let lib
try { lib = isHttps ? require('bare-https') : require('bare-http1') } catch (e) {
return fail(new Error('HTTP client unavailable: ' + (e && e.message)))
}
const req = lib.request({
method: spec.method || 'GET',
hostname: u.hostname,
port: u.port ? parseInt(u.port) : (isHttps ? 443 : 80),
path: u.pathname + u.search,
headers: spec.headers || {}
}, (res) => {
const chunks = []
let bytes = 0
res.on('data', (c) => {
if (settled) return
bytes += c.length
if (bytes > MAX_RELAY_MANAGE_RESPONSE_BYTES) {
try { req.destroy() } catch {}
fail(new Error('Relay management response exceeded the 2 MB safety limit.'))
return
}
chunks.push(c)
})
res.on('end', () => {
if (settled) return
settled = true
clearTimeout(timer)
const text = Buffer.concat(chunks).toString('utf8')
let json = null
try { json = text ? JSON.parse(text) : null } catch {}
resolve({ status: res.statusCode, json, text })
})
res.on('error', fail)
})
timer = setTimeout(() => { try { req.destroy() } catch {} ; fail(new Error('Relay request timed out')) }, timeoutMs)
req.on('error', fail)
if (spec.body) req.write(spec.body)
req.end()
})
}
// Promote an approved app into the published community Hyperbee (5d961fdc) so
// desktop community-list readers see it. Deliberately NOT done from the worklet:
// 1. The community bee's writer secret lives in the source tree
// (03-sites/pearbrowser-publishers/community-catalog), which a
// released/staged app does not ship, and the worklet has no source-tree
// path primitive (no __dirname / Pear.config.dir in use here).
// 2. The relay pin-queue carries only { appKey, publisherPubkey } — no app
// name/description — so an approve alone can't synthesise a rich entry.
// The authoritative moderation action (pin approve/reject) runs relay-side and
// is fully wired. To surface an approved app in everyone's Community list, the
// operator republishes the community bee from its source via the proven secret-
// holding path: node scripts/publish-catalog-bee.js \
// ../../../03-sites/pearbrowser-publishers/community-catalog-source/catalog.json \
// --storage ../../../03-sites/pearbrowser-publishers/community-catalog
// (A future enhancement could mirror approved apps into an app-OWNED bee via the
// CMD_MYCATALOG_* machinery + auto-load its key, sidestepping the external
// secret — see communitySubmit.communityBeeEntry() for the entry shape.)
async function promoteToCommunityCatalogue (manifest) {
return { ok: false, deferred: true, reason: 'Approved + pinned on relays. Republish the community bee (scripts/publish-catalog-bee.js) to list it for everyone.' }
}
rpc.handle(C.CMD_SUBMIT_APP, async (data = {}) => {
await whenReady()
if (!hiveRelay || typeof hiveRelay.publish !== 'function') {
throw new Error('Relay client is still starting — try submitting again in a few seconds.')
}
const submittedBy = (swarm && swarm.keyPair) ? swarm.keyPair.publicKey.toString('hex') : null
const built = communitySubmit.buildSubmissionManifest(data, {
submittedBy, now: Date.now(), normalizeKey: normalizeDriveKey
})
if (built.error) throw new Error(built.error)
const { manifest, id, kind, driveKey, installLink, receiptId } = built
// Publish exactly one bounded receipt drive and put that receipt in the relay
// review queue. The receipt references either separately seeded Hyper content
// or a Pear v3 production identity. It never asks HiveRelay to pin native
// release bytes, and it avoids the old double-queue where moderators saw the
// target drive but could not retrieve the separately published form metadata.
let manifestKey = null
let acceptances = 0
try {
const mdrive = await hiveRelay.publish(
[{ path: '/manifest.json', content: JSON.stringify(manifest, null, 2) }],
{ appId: receiptId, seed: true, replicas: 3, ttlDays: 365, timeout: 15000 }
)
if (mdrive && mdrive.key) {
manifestKey = normalizeDriveKey(Buffer.isBuffer(mdrive.key) ? mdrive.key.toString('hex') : String(mdrive.key))
}
acceptances = Number(mdrive && mdrive.replicas && mdrive.replicas.accepted) || 0
} catch (err) {
console.error('[submit] publish manifest failed:', err && err.message)
throw new Error('The catalogue receipt could not be published: ' + boundedReviewText(err && err.message, 300))
}
if (!manifestKey) throw new Error('The catalogue receipt publisher returned no valid drive key.')
const receiptWarning = acceptances > 0
? ''
: 'No relay acknowledged the review receipt yet; keep this browser online and retry if it does not appear in the operator queue.'
console.log(`[submit] queued "${manifest.name}" (${id}) kind=${kind} receipt=${manifestKey.slice(0, 8)} acceptances=${acceptances}`)
return {
ok: true,
id,
driveKey,
installLink,
kind,
manifestKey,
receiptPublished: true,
receiptWarning,
acceptances,
status: acceptances > 0 ? 'pending-review' : 'awaiting-relay',
manifest
}
})
rpc.handle(C.CMD_MOD_PENDING, async () => {
await whenReady()
const { baseUrl, apiKey, audit } = await getModSettings()
const spec = communitySubmit.manageRequest('pending', { baseUrl, apiKey })
if (spec.error) throw new Error(spec.error)
const res = await relayManageHttp(spec)
if (res.status === 401) throw new Error('Relay rejected the moderator API key (401). Check it in the Moderator panel.')
if (res.status >= 400 || !res.json) throw new Error('Relay pending request failed (HTTP ' + res.status + ').')
const requests = Array.isArray(res.json.requests) ? res.json.requests : []
const toHex = (v) => typeof v === 'string' ? v : (v ? Buffer.from(v.data || v).toString('hex') : '')
const pending = requests.map((r) => ({
appKey: normalizeDriveKey(toHex(r.appKey)),
publisherPubkey: toHex(r.publisherPubkey),
discoveredAt: r.discoveredAt || null,
ttlSeconds: r.ttlSeconds || null,
currentRelays: r.currentRelays || 0,
replicationFactor: r.replicationFactor || null,
source: boundedReviewText(r.source, 80),
contentType: boundedReviewText(r.contentType || r.type, 80),
appId: boundedReviewText(r.appId || r.id, 80),
name: boundedReviewText(r.name, 160),
author: boundedReviewText(r.author, 160),
version: boundedReviewText(r.version, 40),
categories: Array.isArray(r.categories) ? r.categories.map((value) => boundedReviewText(value, 80)).filter(Boolean).slice(0, 12) : [],
privacyTier: boundedReviewText(r.privacyTier, 40),
storageClass: boundedReviewText(r.storageClass, 40),
availabilityClass: boundedReviewText(r.availabilityClass, 40),
blind: r.blind === true
}))
moderationPendingMode = boundedReviewText(res.json.mode, 40) || null
moderationPendingLoadedAt = Date.now()
moderationPendingByKey.clear()
moderationReviewCache.clear()
for (const request of pending) if (request.appKey) moderationPendingByKey.set(request.appKey, request)
return { ok: true, mode: moderationPendingMode, count: pending.length, pending, audit }
})
rpc.handle(C.CMD_MOD_REVIEW, async (data = {}) => {
await whenReady()
const appKey = normalizeDriveKey(String(data.appKey || data.driveKey || ''))
const pending = moderationPendingByKey.get(appKey)
if (!pending) throw new Error('This app is not in the loaded relay queue. Refresh pending submissions before reviewing it.')
return await reviewPendingSubmission(appKey, pending, { force: data.force === true })
})
rpc.handle(C.CMD_MOD_APPROVE, async (data = {}) => {
await whenReady()
const appKey = normalizeDriveKey(String(data.appKey || data.driveKey || ''))
if (moderationPendingMode !== 'review') throw new Error('Relay decisions require a freshly loaded queue from a relay in review mode.')
if (Date.now() - moderationPendingLoadedAt > MODERATION_REVIEW_CACHE_MS) throw new Error('The relay queue is stale. Refresh pending submissions before approving.')
const pending = moderationPendingByKey.get(appKey)
if (!pending) throw new Error('This app is not in the loaded relay queue. Refresh pending submissions before approving it.')
const cachedReview = moderationReviewCache.get(appKey)
const reviewedReceiptDriveVersion = Number(data.reviewedReceiptDriveVersion)
const reviewedTargetDriveVersion = Number(data.reviewedTargetDriveVersion || 0)
if (!communitySubmit.reviewEvidenceMatches(data, cachedReview)) {
throw new Error('The due-diligence evidence does not match this approval. Re-run the review before approving.')
}
const review = await reviewPendingSubmission(appKey, pending, { force: true })
if (review.evidence.receiptDriveVersion !== reviewedReceiptDriveVersion ||
review.evidence.targetDriveVersion !== reviewedTargetDriveVersion) {
throw new Error('The receipt or target content changed after review. Inspect the refreshed evidence before approving.')
}
if (!review.approvalAllowed) {
const blockers = review.checks.filter((check) => check.status === 'block').map((check) => check.label).join(', ')
throw new Error('Due diligence blocked approval: ' + (blockers || 'review evidence failed') + '.')
}
if (review.requiresAcknowledgement && data.acknowledged !== true) {
throw new Error('A reviewer must complete the human release review and acknowledge the due-diligence warnings before approval.')
}
const note = boundedReviewText(data.note, 1000)
if (!note) throw new Error('Record a reviewer note describing the due diligence performed before approval.')
const { baseUrl, apiKey } = await getModSettings()
const spec = communitySubmit.manageRequest('approve', { baseUrl, apiKey, appKey })
if (spec.error) throw new Error(spec.error)
const res = await relayManageHttp(spec)
if (res.status === 401) throw new Error('Relay rejected the moderator API key (401).')
if (res.status >= 400) throw new Error('Relay approve failed (HTTP ' + res.status + ').')
// Report the separate catalogue-publication gate; this currently performs no
// write because the secret-holding publisher is intentionally out-of-process.
let promoted = null
if (review.manifest) {
try { promoted = await promoteToCommunityCatalogue(review.manifest) } catch (err) {
console.error('[mod] community-bee write failed:', err && err.message)
promoted = { ok: false, error: err && err.message }
}
}
const { audit, auditWarning } = await appendCommunityReviewAuditSafely({
appKey,
publisherPubkey: review.publisherPubkey,
action: 'approve',
note,
reviewStatus: review.status,
summary: review.summary
})
moderationPendingByKey.delete(appKey)
moderationReviewCache.delete(appKey)
return { ok: true, appKey, promoted, review, audit, auditWarning }
})
rpc.handle(C.CMD_MOD_REJECT, async (data = {}) => {
await whenReady()
const appKey = normalizeDriveKey(String(data.appKey || data.driveKey || ''))
const reason = boundedReviewText(data.reason, 500)
if (!reason) throw new Error('A rejection reason is required for the moderation audit trail.')
if (moderationPendingMode !== 'review') throw new Error('Relay decisions require a freshly loaded queue from a relay in review mode.')
if (Date.now() - moderationPendingLoadedAt > MODERATION_REVIEW_CACHE_MS) throw new Error('The relay queue is stale. Refresh pending submissions before rejecting.')
const pending = moderationPendingByKey.get(appKey)
if (!pending) throw new Error('This app is not in the loaded relay queue. Refresh pending submissions before rejecting it.')
const { baseUrl, apiKey } = await getModSettings()
const spec = communitySubmit.manageRequest('reject', { baseUrl, apiKey, appKey })
if (spec.error) throw new Error(spec.error)
const res = await relayManageHttp(spec)
if (res.status === 401) throw new Error('Relay rejected the moderator API key (401).')
if (res.status >= 400) throw new Error('Relay reject failed (HTTP ' + res.status + ').')
const { audit, auditWarning } = await appendCommunityReviewAuditSafely({
appKey,
publisherPubkey: pending.publisherPubkey,
action: 'reject',
reason,
note: data.note,
reviewStatus: moderationReviewCache.get(appKey)?.status || 'not-run',
summary: moderationReviewCache.get(appKey)?.summary || null
})
moderationPendingByKey.delete(appKey)
moderationReviewCache.delete(appKey)
return { ok: true, appKey, audit, auditWarning }
})
// Collaborative (Autobee) catalog authoring — Rollout Phase 3. All gated by
// the experimentalAutobeeCatalogs flag (server-side, fails closed). NOTE:
// these deliberately do NOT pin on HiveRelay — the doc's "Do Not Do Yet"
// defers Autobee relay durability until all its cores are understood. Owned
// catalogs are served over the swarm (server:true) while the app runs.
rpc.handle(C.CMD_AUTOBEE_CREATE, async (data) => {
await whenReady()
await requireAutobee()
return await catalogManager.createAutobeeCatalog(data && data.name)
})
rpc.handle(C.CMD_AUTOBEE_GET, async (data) => {
await whenReady()
await requireAutobee()
return await catalogManager.getAutobeeCatalog(normalizeDriveKey(data.keyHex))
})
rpc.handle(C.CMD_AUTOBEE_ADD_APP, async (data) => {
await whenReady()
await requireAutobee()
return await catalogManager.autobeeAddApp(normalizeDriveKey(data.keyHex), data.app)
})
rpc.handle(C.CMD_AUTOBEE_REMOVE_APP, async (data) => {
await whenReady()
await requireAutobee()
return await catalogManager.autobeeRemoveApp(normalizeDriveKey(data.keyHex), data.id)
})
rpc.handle(C.CMD_AUTOBEE_RENAME, async (data) => {
await whenReady()
await requireAutobee()
return await catalogManager.autobeeRename(normalizeDriveKey(data.keyHex), data.name)
})
rpc.handle(C.CMD_AUTOBEE_ADD_WRITER, async (data) => {
await whenReady()
await requireAutobee()
// data.writerKey is a 64-hex Autobase writer key, not a drive key.
return await catalogManager.autobeeAddWriter(normalizeDriveKey(data.keyHex), data.writerKey)
})
// Site Builder commands
const SITE_TEMPLATES = {
blank: { id: 'blank', name: 'Blank', blocks: [], theme: null },
simple: {
id: 'simple',
name: 'Simple page',
blocks: [
{ type: 'heading', level: 1, text: 'New site' },
{ type: 'text', text: 'Write something worth sharing.' }
],
theme: null
},
links: {
id: 'links',
name: 'Link page',
blocks: [
{ type: 'heading', level: 1, text: 'Links' },
{ type: 'link', href: 'https://', text: 'Add a link' },
{ type: 'divider' },
{ type: 'text', text: 'A small page of useful places.' }
],
theme: null
}
}
function cloneSiteTemplate (template) {
return JSON.parse(JSON.stringify(template))
}
rpc.handle(C.CMD_CREATE_SITE, async (data) => {
await whenReady()
console.log('[create-site] start:', data?.name)
const t0 = Date.now()
const result = await siteManager.createSite(data.name)
console.log('[create-site] done in', Date.now() - t0, 'ms:', result?.siteId)
persistState()
return result
})
rpc.handle(C.CMD_UPDATE_SITE, async (data) => {
await whenReady()
// Allow rename independently of blocks/files.
if (typeof data.name === 'string' && data.name.trim()) {
const site = siteManager.sites.get(data.siteId)
if (site) site.name = data.name.trim().slice(0, 100).replace(/[<>"'`]/g, '')
}
let result = { siteId: data.siteId }
if (data.blocks) {
result = await siteManager.buildFromBlocks(data.siteId, data.blocks, data.theme)
} else if (data.files) {
result = await siteManager.updateSite(data.siteId, data.files)
}
persistState()
return result
})
rpc.handle(C.CMD_PUBLISH_SITE, async (data) => {
await whenReady()
const result = await siteManager.publishSite(data.siteId)
persistState()
// Pin to the HiveRelay network (p2p-hiverelay) — Protomux-based
// Ed25519-signed seed requests broadcast to all connected relays.
// Failure is non-fatal; drive stays P2P-seeded locally regardless.
let pin = { ok: false, acceptances: 0, connectedRelays: 0, replicatedPeers: 0, replicationTimedOut: false }
try {
const keyHex = result?.keyHex
const site = siteManager.sites.get(data.siteId)
if (keyHex && hiveRelay && typeof hiveRelay.seed === 'function') {
registerHiveRelayDrive(keyHex, site?.drive)
const connectedRelays = hiveRelay.getRelays ? hiveRelay.getRelays().length : 0
console.log(`[publish] pinning ${keyHex.slice(0, 8)} to ${connectedRelays} HiveRelay(s)`)
// Pass `discoveryKey` explicitly so relays always announce the exact
// Hypercore/HiveRelay topic this drive uses. This avoids depending on
// SDK-side default derivation if the relay stack evolves again.
const acceptances = await hiveRelay.seed(keyHex, {
replicas: 3,
timeout: 10000,
discoveryKey: site?.drive?.discoveryKey
})
const acceptCount = Array.isArray(acceptances) ? acceptances.length : 0
pin = { ok: acceptCount > 0, acceptances: acceptCount, connectedRelays, replicatedPeers: 0, replicationTimedOut: false }
// Canonical waitForDurable() API from current p2p-hiverelay clients.
// Returns once at least `minPeers` relays have caught up, or throws on
// timeout. The drive.core.peers polling path below remains as a safety
// net for older SDKs encountered in the wild.
if (acceptCount > 0 && typeof hiveRelay.waitForDurable === 'function') {
try {
console.log(`[publish] waitForDurable() available — using canonical API`)
const status = await hiveRelay.waitForDurable(keyHex, { timeoutMs: 30000, minPeers: 1 })
pin.replicatedPeers = status?.activePeers ?? 0
pin.durable = !!status?.durable
if (!status?.durable) pin.replicationTimedOut = true
console.log(`[publish] durable=${status?.durable} peers=${pin.replicatedPeers}`)
} catch (err) {
console.error('[publish] waitForDurable failed:', err && err.message)
pin.replicationTimedOut = true
}
} else if (site?.drive?.core && acceptCount > 0) {
// Legacy fallback: poll drive.core.peers for remote peers that
// reach remoteLength >= localLength. Only fires against older SDKs
// that don't expose waitForDurable().
const core = site.drive.core
const startLen = core.length
const REPL_TIMEOUT = 30000
console.log(`[publish] waiting up to ${REPL_TIMEOUT / 1000}s for replication (length=${startLen})`)
const t0 = Date.now()
await new Promise((resolve) => {
const check = () => {
const peers = core.peers || []
const synced = peers.filter((p) => {
const rl = p.remoteLength ?? p.length ?? 0
return rl >= startLen
})
if (synced.length >= 1) {
pin.replicatedPeers = synced.length
console.log(`[publish] replicated to ${synced.length} peer(s) in ${Date.now() - t0}ms`)
clearInterval(timer)
resolve()
} else if (Date.now() - t0 > REPL_TIMEOUT) {
pin.replicationTimedOut = true
pin.replicatedPeers = peers.length
console.log(`[publish] replication timeout after ${REPL_TIMEOUT}ms — ${peers.length} peer(s) connected but not yet synced`)
clearInterval(timer)
resolve()
}
}
const timer = setInterval(check, 500)
check()
})
}
}
} catch (err) {
console.error('[publish] HiveRelay pin failed:', err && err.message)
}
const payload = { ...result, pin }
rpc.event(C.EVT_SITE_PUBLISHED, payload)
return payload
})
rpc.handle(C.CMD_UNPUBLISH_SITE, async (data) => {
await whenReady()
const result = await siteManager.unpublishSite(data.siteId)
persistState()
return result
})
rpc.handle(C.CMD_LIST_SITES, () => {
if (!siteManager) return []
return siteManager.listSites()
})
rpc.handle(C.CMD_LOAD_TEMPLATE, async ({ id } = {}) => {
await whenReady()
const key = String(id || 'blank').trim().toLowerCase()
const template = SITE_TEMPLATES[key] || SITE_TEMPLATES.blank
return {
template: cloneSiteTemplate(template),
templates: Object.values(SITE_TEMPLATES).map(({ blocks, theme, ...meta }) => meta)
}
})
rpc.handle(C.CMD_GET_SITE_BLOCKS, async (data) => {
if (!siteManager) return { blocks: [], theme: null }
return await siteManager.getSiteBlocks(data.siteId)
})
// Resolve a published app/site icon for display: fetch the declared iconRef (a
// path inside the drive) or a well-known icon path, inline as a data URI. This
// wires the desktop icon-fetch for the sheets/index-room/seed catalogue path
// (previously only the legacy catalog.json path inlined icons — see PBACS
// §"Icon resolution"). Best-effort + cached; null → the UI shows a letter glyph.
rpc.handle(C.CMD_GET_APP_ICON, async ({ driveKey, iconRef } = {}) => {
return { iconData: await resolveAppIcon(driveKey, iconRef) }
})
// Upload/set the icon for one of YOUR sites — writes /icon.<ext> into the site's
// drive (peers replicate it; no re-publish needed for an already-seeded drive).
rpc.handle(C.CMD_SET_SITE_ICON, async ({ siteId, dataUrl } = {}) => {
if (!siteManager) throw new Error('site manager not available')
const res = await siteManager.setIcon(siteId, dataUrl)
if (res && res.keyHex) for (const k of [..._iconCache.keys()]) if (k.startsWith(res.keyHex + '|')) _iconCache.delete(k)
return res
})
rpc.handle(C.CMD_RESET_APP, async () => {
// Safely tear down: (1) unseed every pinned site so the publisher
// keypair still matches, (2) shutdown managers, (3) wipe storage,
// (4) exit — the user relaunches and starts fresh.
const report = { unseeded: [], failed: [] }
try {
if (siteManager && hiveRelay && typeof hiveRelay.unseed === 'function') {
for (const [, site] of siteManager.sites) {
if (site.published && site.keyHex) {
try {
await hiveRelay.unseed(site.keyHex)
report.unseeded.push(site.keyHex)
} catch (err) {
report.failed.push({ keyHex: site.keyHex, error: err && err.message })
}
}
}
}
} catch (err) {
console.error('[reset] unseed loop failed:', err && err.message)
}
try { await shutdown() } catch {}
try {
fs.rmSync(storagePath, { recursive: true, force: true })
} catch (err) {
console.error('[reset] wipe failed:', err && err.message)
}
// Give the reply a tick to reach the UI before we exit.
setTimeout(() => {
try { Bare.exit?.() } catch {}
}, 200)
return report
})
rpc.handle(C.CMD_LEGACY_APP_MIGRATION, async (data) => {
const legacyMigrationId = String(data?.legacyMigrationId || '').trim().toLowerCase()
if (!/^[13-9a-km-uw-z]{52}$/.test(legacyMigrationId)) {
throw new Error('legacy migration id must be a 52-character z-base-32 identifier')
}
return {
action: 'legacy-migration-required',
legacyMigrationId,
message: 'This legacy Pear app cannot be launched by PearBrowser v3. Install a verified native v3 package when its publisher provides one.'
}
})
// Runs only the in-process demo router headlessly in a browser tab. Remote
// application workers require a signed native v3 package and are never fetched
// or executed from a browser catalog.
rpc.handle(C.CMD_RUN_APP_IN_TAB, async (data) => {
const link = String(data?.link || 'demo').trim()
if (!tabRuntime) throw new Error('tab runtime is not available')
if (link !== 'demo') {
return {
action: 'legacy-migration-required',
link,
message: 'This legacy app needs a verified v3 package before it can run in a PearBrowser tab.'
}
}
const res = tabRuntime.open(link)
console.log('[tab-runtime] run-in-tab')
return res
})
rpc.handle(C.CMD_DELETE_SITE, async (data) => {
await whenReady()
// If published, ask HiveRelay to unseed first. Ed25519-signed with
// our client keypair — only the original publisher (us) can unseed.
let unseed = { ok: false, relays: 0 }
try {
const site = siteManager?.sites?.get?.(data.siteId)
const keyHex = site?.keyHex
if (keyHex && site?.published && hiveRelay && typeof hiveRelay.unseed === 'function') {
console.log(`[delete] unseeding ${keyHex.slice(0, 8)} from HiveRelay`)
const res = await hiveRelay.unseed(keyHex)
unseed = { ok: (res?.relays ?? 0) > 0, relays: res?.relays ?? 0 }
}
} catch (err) {
console.error('[delete] unseed failed:', err && err.message)
}
const result = await siteManager.deleteSite(data.siteId)
persistState()
return { ...(typeof result === 'object' ? result : { ok: !!result }), unseed }
})
// Pear Bridge — WebView apps call P2P APIs via this relay
rpc.handle(C.CMD_BRIDGE, async (data) => {
const { method, args } = data
if (!pearBridge) throw new Error('Bridge not initialized')
switch (method) {
case 'sync.create':
return await pearBridge.createSyncGroup(args.appId)
case 'sync.join':
return await pearBridge.joinSyncGroup(args.appId, args.inviteKey)
case 'sync.append':
return await pearBridge.append(args.appId, args.op)
case 'sync.get':
return await pearBridge.get(args.appId, args.key)
case 'sync.list':
return await pearBridge.list(args.appId, args.prefix, args.opts)
case 'sync.status':
return pearBridge.getSyncStatus(args.appId)
case 'identity.getPublicKey':
return { publicKey: swarm ? swarm.keyPair.publicKey.toString('hex') : null }
case 'navigate':
// RN handles this directly
return { ok: true }
default:
throw new Error('Unknown bridge method: ' + method)
}
})
// System
rpc.handle(C.CMD_STOP, async () => {
await shutdown()
return { ok: true }
})
rpc.handle(C.CMD_CLEAR_CACHE, async () => {
let cleared = 0
// Clear proxy cache
if (proxy) {
const cacheStats = proxy.getCacheStats?.()
proxy.clearCache?.()
cleared += cacheStats?.size || 0
}
// Clear browse drives cache
for (const [key, { drive }] of browseDrives) {
try { await drive.clear?.() } catch {}
}
return { cleared, message: 'Cache cleared successfully' }
})
// --- Relay configuration (Phase 0 ticket 2) ---
// Replaces the previously hardcoded relay list in boot().
// Settings UI writes through these handlers; state is persisted in
// pearbrowser-state.json so the config survives restarts.
rpc.handle(C.CMD_GET_RELAYS, async () => {
if (!relayClient) return { relays: [], enabled: false, configured: false }
return { ...relayClient.getConfig(), configured: true }
})
rpc.handle(C.CMD_SET_RELAYS, async ({ relays } = {}) => {
if (!relayClient) throw new Error('Relay client not initialised')
if (!Array.isArray(relays)) throw new Error('relays must be an array of URLs')
const ok = relayClient.setRelays(relays)
if (!ok) throw new Error('No valid http(s) relay URLs provided')
persistState()
return { ok: true, relays: relayClient.relays }
})
rpc.handle(C.CMD_SET_RELAY_ENABLED, async ({ enabled } = {}) => {
if (!relayClient) throw new Error('Relay client not initialised')
if (typeof enabled !== 'boolean') throw new Error('enabled must be boolean')
relayClient.setEnabled(enabled)
persistState()
return { ok: true, enabled: relayClient.enabled }
})
rpc.handle(C.CMD_CHECK_RELAY_CAPABILITY, async ({ url } = {}) => {
if (!relayClient) return { ok: false, error: 'Relay client not initialised' }
const clean = typeof url === 'string' ? url.trim().replace(/\/+$/, '') : ''
const configured = new Set(relayClient.relays.map((relayUrl) => relayUrl.replace(/\/+$/, '')))
if (!configured.has(clean)) return { ok: false, error: 'relay is not configured' }
return relayClient.checkCapability(clean)
})
// --- User data (Phase 1 ticket 2) ---
// Hyperbee-backed bookmarks, history, settings, session, tabs.
// Replaces AsyncStorage usage in the RN layer.
function requireUserData () {
if (!userData) throw new Error('User data not available — worklet still booting')
return userData
}
// Experimental-feature gate for Autobee collaborative catalogs (Phase 2).
// Off unless the user has set `experimentalAutobeeCatalogs` in settings;
// failures (no user data yet, etc.) fail closed.
async function isAutobeeEnabled () {
try {
const s = await requireUserData().getSettings()
return !!(s && s.experimentalAutobeeCatalogs)
} catch { return false }
}
async function requireAutobee () {
if (!(await isAutobeeEnabled())) {
throw new Error('Collaborative (Autobee) catalogs are experimental — enable them in Settings first.')
}
}
// --- Multi-device browser-state sync (encrypted) — Rollout Phase 4 ---------
// EXPERIMENTAL, off by default. One encrypted Autobase per user holding their
// own bookmark edits, replicated across their paired devices. The encryption
// key + bootstrap key + writer key form the pairing invite. The instance is
// owned here and lazily (re)opened from persisted settings (syncKey/syncEncKey).
let browserSync = null
async function requireSync () {
let on = false
try { on = !!(await requireUserData().getSettings()).experimentalDeviceSync } catch {}
if (!on) throw new Error('Device sync is experimental — enable it in Settings first.')
}
// Reopen the user's existing sync base from persisted settings, if paired.
// Returns the live instance or null (not paired yet).
async function ensureBrowserSync () {
const s = await requireUserData().getSettings()
const key = typeof s.syncKey === 'string' && /^[0-9a-f]{64}$/i.test(s.syncKey) ? s.syncKey : null
const enc = typeof s.syncEncKey === 'string' && /^[0-9a-f]{64}$/i.test(s.syncEncKey) ? s.syncEncKey : null
if (!key || !enc) return null
if (browserSync && browserSync.key === key) return browserSync
if (browserSync) { try { await browserSync.close() } catch {} browserSync = null }
const { BrowserStateSync } = require('./browser-state-sync.cjs')
browserSync = await new BrowserStateSync(store, { bootstrap: key, encryptionKey: enc }).ready()
if (browserSync.discoveryKey) swarm.join(browserSync.discoveryKey, { server: true, client: true })
return browserSync
}
rpc.handle(C.CMD_SYNC_STATUS, async () => {
await whenReady(); await requireSync()
const sy = await ensureBrowserSync()
if (!sy) return { enabled: true, paired: false }
const st = await sy.state()
const s = await requireUserData().getSettings()
return { enabled: true, paired: true, key: sy.key, encKey: s.syncEncKey, writerKey: sy.localKey, writable: sy.writable, count: st.count, bookmarks: st.bookmarks }
})
// Enable + create a fresh encrypted sync base (this device becomes the first
// writer). Returns the pairing invite: { key, encKey, writerKey }.
rpc.handle(C.CMD_SYNC_CREATE, async () => {
await whenReady(); await requireSync()
const existing = await ensureBrowserSync()
if (existing) {
const st = await existing.state()
const s = await requireUserData().getSettings()
return { key: existing.key, encKey: s.syncEncKey, writerKey: existing.localKey, writable: existing.writable, ...st }
}
const { BrowserStateSync } = require('./browser-state-sync.cjs')
const encKey = require('crypto').randomBytes(32).toString('hex')
// Mint the autobase key, then reopen by key (mirrors ensureNameRegistry). The
// store is namespaced inside BrowserStateSync by a FIXED substore, so mint and
// reopen-by-key share that substore (reopen stays writable) and mint.close()
// frees only the sync substore — never the shared root Corestore.
const mint = await new BrowserStateSync(store, { bootstrap: null, encryptionKey: encKey }).ready()
const key = mint.key
await mint.close()
await requireUserData().setSettings({ syncKey: key, syncEncKey: encKey })
browserSync = await new BrowserStateSync(store, { bootstrap: key, encryptionKey: encKey }).ready()
if (browserSync.discoveryKey) swarm.join(browserSync.discoveryKey, { server: true, client: true })
const st = await browserSync.state()
return { key, encKey, writerKey: browserSync.localKey, writable: browserSync.writable, ...st }
})
// Pair THIS device using an invite from another device: { key, encKey }. The
// device is read-only until the inviting device adds its writerKey.
rpc.handle(C.CMD_SYNC_JOIN, async (data) => {
await whenReady(); await requireSync()
const key = normalizeDriveKey(String((data && data.key) || ''))
const encKey = String((data && data.encKey) || '').trim().toLowerCase()
if (!/^[0-9a-f]{64}$/i.test(key) || !/^[0-9a-f]{64}$/i.test(encKey)) throw new Error('Invalid pairing invite (need a 64-hex key and encryption key).')
if (browserSync) { try { await browserSync.close() } catch {} browserSync = null }
await requireUserData().setSettings({ syncKey: key, syncEncKey: encKey })
const { BrowserStateSync } = require('./browser-state-sync.cjs')
browserSync = await new BrowserStateSync(store, { bootstrap: key, encryptionKey: encKey }).ready()
if (browserSync.discoveryKey) swarm.join(browserSync.discoveryKey, { server: true, client: true })
const st = await browserSync.state()
return { key, encKey, writerKey: browserSync.localKey, writable: browserSync.writable, ...st }
})
// Add another device as a writer (paste the writerKey it showed you).
rpc.handle(C.CMD_SYNC_ADD_WRITER, async (data) => {
await whenReady(); await requireSync()
const sy = await ensureBrowserSync()
if (!sy) throw new Error('Sync is not set up on this device yet.')
if (!sy.writable) throw new Error('Only a writer can add another writer.')
const writerKey = String((data && data.writerKey) || '').trim().toLowerCase()
if (!/^[0-9a-f]{64}$/i.test(writerKey)) throw new Error('Invalid writer key (need 64-hex).')
await sy.addWriter(writerKey)
return { ok: true, writerKey }
})
rpc.handle(C.CMD_SYNC_GET_BOOKMARKS, async () => {
await whenReady(); await requireSync()
const sy = await ensureBrowserSync()
if (!sy) return { paired: false, bookmarks: [] }
return { paired: true, writable: sy.writable, ...(await sy.state()) }
})
rpc.handle(C.CMD_SYNC_ADD_BOOKMARK, async (data) => {
await whenReady(); await requireSync()
const sy = await ensureBrowserSync()
if (!sy) throw new Error('Sync is not set up on this device yet.')
if (!sy.writable) throw new Error('This device is read-only — ask another device to add it as a writer.')
await sy.addBookmark({ url: String((data && data.url) || ''), title: String((data && data.title) || ''), addedAt: Date.now() })
return await sy.state()
})
rpc.handle(C.CMD_SYNC_REMOVE_BOOKMARK, async (data) => {
await whenReady(); await requireSync()
const sy = await ensureBrowserSync()
if (!sy) throw new Error('Sync is not set up on this device yet.')
if (!sy.writable) throw new Error('This device is read-only.')
await sy.removeBookmark(String((data && data.url) || ''))
return await sy.state()
})
// Push this device's LOCAL bookmarks into the synced set (one-way import).
rpc.handle(C.CMD_SYNC_PUSH_LOCAL, async () => {
await whenReady(); await requireSync()
const sy = await ensureBrowserSync()
if (!sy) throw new Error('Sync is not set up on this device yet.')
if (!sy.writable) throw new Error('This device is read-only.')
const local = await requireUserData().listBookmarks().catch(() => [])
let pushed = 0
for (const b of (Array.isArray(local) ? local : [])) {
if (!b || !b.url) continue
await sy.addBookmark({ url: String(b.url), title: String(b.title || ''), addedAt: Number.isFinite(b.addedAt) ? b.addedAt : Date.now() })
pushed++
}
return { pushed, ...(await sy.state()) }
})
rpc.handle(C.CMD_USERDATA_LIST_BOOKMARKS, async () => {
if (!userData) return { bookmarks: [] }
return { bookmarks: await userData.listBookmarks() }
})
rpc.handle(C.CMD_USERDATA_ADD_BOOKMARK, async ({ url, title } = {}) => {
await whenReady()
const result = await requireUserData().addBookmark({ url, title })
return { bookmark: result }
})
rpc.handle(C.CMD_USERDATA_REMOVE_BOOKMARK, async ({ url } = {}) => {
const removed = await requireUserData().removeBookmark(url)
return { removed }
})
rpc.handle(C.CMD_USERDATA_LIST_HISTORY, async ({ limit } = {}) => {
if (!userData) return { history: [], historyEnabled: false }
const settings = await userData.getSettings()
const enabled = isHistoryEnabled(settings)
// When history is off, never surface stored visits (and clear any leftovers).
if (!enabled) {
try { await userData.clearHistory() } catch {}
return { history: [], historyEnabled: false }
}
return { history: await userData.listHistory({ limit }), historyEnabled: true }
})
rpc.handle(C.CMD_USERDATA_ADD_HISTORY, async ({ url, title } = {}) => {
await whenReady()
// Privacy-first: visit history is opt-in. Default OFF — refuse silently.
const settings = userData ? await userData.getSettings() : {}
if (!isHistoryEnabled(settings)) {
return { ok: true, recorded: false, reason: 'history-disabled' }
}
await requireUserData().addHistory({ url, title })
return { ok: true, recorded: true }
})
rpc.handle(C.CMD_USERDATA_CLEAR_HISTORY, async () => {
const cleared = await requireUserData().clearHistory()
return { cleared }
})
rpc.handle(C.CMD_USERDATA_GET_SETTINGS, async () => {
const stored = userData ? await requireUserData().getSettings() : {}
return { settings: mergeSettingsWithPrivacyDefaults(stored) }
})
rpc.handle(C.CMD_USERDATA_SET_SETTINGS, async ({ updates } = {}) => {
// Telemetry can never be turned on.
if (updates && typeof updates === 'object') {
updates = { ...updates, telemetryEnabled: false }
}
const settings = await requireUserData().setSettings(updates || {})
// Apply the Content Shield toggle live — default ON; only explicit false disables.
if (contentShield) contentShield.setEnabled(settings.contentShield !== false)
// Allowlist / strict arrays can also be written as settings for simpler UI paths.
if (contentShield && updates && typeof updates === 'object') {
if (Array.isArray(updates.contentShieldAllow)) {
for (const key of contentShield.allowlist()) contentShield.removeAllowlistDrive(key)
for (const key of updates.contentShieldAllow) contentShield.allowlistDrive(key)
}
if (Array.isArray(updates.contentShieldStrict)) {
for (const key of contentShield.strictDrives()) contentShield.setStrictDrive(key, false)
for (const key of updates.contentShieldStrict) contentShield.setStrictDrive(key, true)
}
}
// Turning history off wipes any previously stored visits.
if (updates && updates.historyEnabled === false && userData) {
try { await userData.clearHistory() } catch {}
}
// Privacy ladder + clearnet mode — apply live to proxy + session bridge.
applyPrivacyFromSettings(settings)
return { settings: mergeSettingsWithPrivacyDefaults(settings) }
})
function applyPrivacyFromSettings (settings) {
const merged = mergeSettingsWithPrivacyDefaults(settings || {})
privacySettings = normalizePrivacySettings(merged)
if (proxy && typeof proxy.setPrivacySettings === 'function') {
proxy.setPrivacySettings(privacySettings)
}
if (contentShield && typeof merged.contentShield === 'boolean') {
contentShield.setEnabled(merged.contentShield !== false)
}
}
rpc.handle(C.CMD_PRIVACY_STATUS, async () => {
await whenReady()
const stored = userData ? await userData.getSettings() : {}
const merged = mergeSettingsWithPrivacyDefaults(stored)
return {
privacy: {
...privacySettings,
historyEnabled: merged.historyEnabled === true,
searchIndexEnabled: merged.searchIndexEnabled === true,
telemetryEnabled: false,
contentShield: merged.contentShield !== false
},
dataCollection: {
telemetry: false,
history: merged.historyEnabled === true,
searchIndex: merged.searchIndexEnabled === true,
remoteAnalytics: false,
note: 'PearBrowser does not ship telemetry. History and local search indexing are opt-in only.'
},
session: sessionBridge ? sessionBridge.status() : { nativeBridge: false, hasNativeHook: false }
}
})
rpc.handle(C.CMD_USERDATA_GET_SESSION, async () => {
return { session: await requireUserData().getSession() }
})
rpc.handle(C.CMD_USERDATA_SAVE_SESSION, async ({ state } = {}) => {
await requireUserData().saveSession(state || {})
return { ok: true }
})
rpc.handle(C.CMD_USERDATA_IMPORT, async ({ dump } = {}) => {
const imported = await requireUserData().importDump(dump || {})
return { imported }
})
// --- Identity (Phase 1 ticket 3) ---
function requireIdentity () {
if (!identity) throw new Error('Identity not available — worklet still booting')
return identity
}
rpc.handle(C.CMD_IDENTITY_EXPORT_PHRASE, async () => {
return { mnemonic: requireIdentity().getMnemonic() }
})
rpc.handle(C.CMD_IDENTITY_IMPORT_PHRASE, async ({ mnemonic } = {}) => {
if (typeof mnemonic !== 'string') throw new Error('mnemonic must be a string')
if (!validateMnemonic(mnemonic)) throw new Error('Invalid seed phrase — check each word and try again')
await requireIdentity().restoreFromMnemonic(mnemonic)
// Caller MUST restart the worklet for the new identity to take effect
return { ok: true, restartRequired: true }
})
rpc.handle(C.CMD_IDENTITY_ROTATE, async () => {
await requireIdentity().rotate()
return { ok: true, restartRequired: true }
})
rpc.handle(C.CMD_IDENTITY_VALIDATE_PHRASE, async ({ mnemonic } = {}) => {
return { valid: validateMnemonic(mnemonic || '') }
})
// --- Device linking (blind-pairing) ----------------------------------------
// Port of hyper-identity's DeviceLinker, hardened: minting an invite is an
// explicit user action on the SOURCE device, and the root seed is only revealed
// to a device that pairs with THAT single-use invite (see backend/device-linker.js).
let _deviceLinker = null
function getDeviceLinker () {
if (!swarm) throw new Error('swarm not ready — cannot link devices yet')
if (!_deviceLinker) {
const { DeviceLinker } = require('./device-linker.js')
// autoAccept: the user explicitly initiated linking on THIS device and will
// only hand the single-use invite to their own new device (e.g. via QR).
_deviceLinker = new DeviceLinker(swarm, { identity: requireIdentity(), autoAccept: true, log: (m) => console.log(m) })
}
return _deviceLinker
}
rpc.handle(C.CMD_DEVICE_LINK_CREATE_INVITE, async () => {
const { invite, discoveryKey, done } = await getDeviceLinker().createInvite()
done.then((r) => console.log('[device-link] linked ' + ((r && r.device && r.device.device) || 'device')))
.catch((e) => console.warn('[device-link] failed:', e && e.message))
return { invite, discoveryKey }
})
rpc.handle(C.CMD_DEVICE_LINK_JOIN, async ({ invite, device } = {}) => {
if (typeof invite !== 'string' || !invite) throw new Error('invite required')
if (!swarm) throw new Error('swarm not ready — cannot link devices yet')
const { DeviceLinker } = require('./device-linker.js')
const linker = new DeviceLinker(swarm, { identity: requireIdentity() })
await linker.joinWithInvite(invite, { device: device || 'this device' })
// identity.json was rewritten — every store must re-open under the linked seed
return { ok: true, restartRequired: true }
})
rpc.handle(C.CMD_IDENTITY_SIGN, async ({ payload, driveKey } = {}) => {
if (payload === undefined || payload === null) throw new Error('payload required')
const id = requireIdentity()
// If driveKey is provided, sign with the per-app sub-key (Phase A).
// Otherwise fall back to the root (kept for backward compat — the
// "browser" context on Settings screens uses this).
if (typeof driveKey === 'string' && driveKey.length > 0) {
return id.signForApp(driveKey, payload)
}
return id.sign(typeof payload === 'string' ? payload : Buffer.from(payload))
})
// Phase P0 — verify an ed25519 detached signature. With a driveKey, verifies
// a per-app signForApp() signature (reconstructs the domain-separator tag);
// otherwise a plain verify against the provided publicKey. Backend consumers
// (naming/payments/nostr verify-and-drop) call identity.verify() directly;
// this command exposes the same primitive to the renderer.
rpc.handle(C.CMD_IDENTITY_VERIFY, async ({ payload, signature, publicKey, driveKey, namespace } = {}) => {
if (payload === undefined || payload === null) throw new Error('payload required')
const id = requireIdentity()
const ok = (typeof driveKey === 'string' && driveKey.length > 0)
? id.verifyForApp(driveKey, payload, namespace || '', { signature, publicKey })
: id.verify(payload, signature, publicKey)
return { ok: !!ok, algorithm: 'ed25519' }
})
// --- Profile (Identity Plan Phase B) ---
function requireProfile () {
if (!profile) throw new Error('Profile not available — worklet still booting')
return profile
}
rpc.handle(C.CMD_PROFILE_GET, async () => {
return { profile: await requireProfile().getAll() }
})
rpc.handle(C.CMD_PROFILE_UPDATE, async ({ updates } = {}) => {
return { profile: await requireProfile().update(updates || {}) }
})
rpc.handle(C.CMD_PROFILE_CLEAR, async () => {
await requireProfile().clear()
return { ok: true }
})
// --- Login ceremony (Identity Plan Phase C) ---
//
// Flow:
// 1. Page calls window.pear.login(opts)
// 2. http-bridge POST /api/login calls ctx.requestLogin(args)
// → openLoginCeremony() below
// 3. We return the EXISTING grant if one is valid.
// Otherwise we fire EVT_LOGIN_REQUEST to the RN/Native shell and
// park the pending promise in `pendingLogins`.
// 4. User decides in a native consent sheet → shell calls
// CMD_LOGIN_RESOLVE with { requestId, approved, scopes }
// 5. We record the grant in profile.bee, produce the signed
// attestation, resolve the pending promise → http-bridge returns
// the attestation to the page.
const LOGIN_TIMEOUT_MS = 2 * 60 * 1000
const LOGIN_DEFAULT_TTL = 7 * 24 * 60 * 60 * 1000
async function openLoginCeremony ({ driveKeyHex, scopes = [], appName = null, reason = null }) {
if (!identity) throw new Error('Identity not available')
if (!profile) throw new Error('Profile not available')
// Reuse an existing valid grant covering ALL requested scopes.
const existing = await profile.getGrant(driveKeyHex)
if (existing && scopes.every((s) => existing.scopes.includes(s))) {
return buildAttestation(driveKeyHex, existing)
}
// Fresh consent — park, ask UI, wait.
const requestId = crypto.randomBytes(16).toString('hex')
const payload = {
requestId,
driveKey: driveKeyHex,
scopes,
appName,
reason,
currentGrant: existing || null,
}
return await new Promise((resolve, reject) => {
const timer = setTimeout(() => {
if (pendingLogins.has(requestId)) {
pendingLogins.delete(requestId)
reject(new Error('Login request timed out (user did not respond in 2 minutes)'))
}
}, LOGIN_TIMEOUT_MS)
pendingLogins.set(requestId, {
resolve, reject, timer,
driveKeyHex,
requestedScopes: scopes,
requestedAppName: appName,
})
rpc.event(C.EVT_LOGIN_REQUEST, payload)
})
}
function buildAttestation (driveKeyHex, grant) {
// The attestation is:
// pear.login.v1:<driveKey>:<appPubkey>:<scopes-joined>:<expiresAt>
// signed with the per-app sub-key. Apps + third parties can verify
// by recomputing the app sub-pubkey from the user's root pubkey
// (but since root never leaves the device, they verify the
// attestation directly with appPubkey which is embedded).
const keypair = identity.getAppKeypair(driveKeyHex)
const appPubkey = keypair.publicKey.toString('hex')
const payload = `pear.login.v1:${driveKeyHex}:${appPubkey}:${grant.scopes.join(',')}:${grant.expiresAt}`
const signed = identity.signForApp(driveKeyHex, payload, 'login')
return {
appPubkey,
scopes: grant.scopes,
expiresAt: grant.expiresAt,
grantedAt: grant.grantedAt,
loginProof: signed.signature,
tag: signed.tag,
}
}
rpc.handle(C.CMD_LOGIN_RESOLVE, async ({ requestId, approved, scopes, ttlMs } = {}) => {
const pending = pendingLogins.get(requestId)
if (!pending) throw new Error('No pending login with that id (timed out?)')
pendingLogins.delete(requestId)
clearTimeout(pending.timer)
if (!approved) {
pending.reject(new Error('User declined'))
return { ok: true, approved: false }
}
// UI decides which scopes to grant (can narrow from what was requested).
// Fall back to whatever the page asked for if the UI doesn't echo it.
const finalScopes = Array.isArray(scopes) && scopes.length > 0
? scopes.map(String)
: pending.requestedScopes
const expiresAt = Date.now() + (typeof ttlMs === 'number' ? ttlMs : LOGIN_DEFAULT_TTL)
const grant = await profile.setGrant(pending.driveKeyHex, {
scopes: finalScopes,
appName: pending.requestedAppName,
expiresAt,
})
const attestation = buildAttestation(pending.driveKeyHex, grant)
pending.resolve(attestation)
return { ok: true, approved: true, driveKey: pending.driveKeyHex, scopes: finalScopes }
})
rpc.handle(C.CMD_LOGIN_LIST_GRANTS, async () => {
return { grants: await requireProfile().listGrants() }
})
rpc.handle(C.CMD_LOGIN_REVOKE_GRANT, async ({ driveKeyHex } = {}) => {
if (typeof driveKeyHex !== 'string') throw new Error('driveKeyHex required')
await requireProfile().revokeGrant(driveKeyHex)
return { ok: true }
})
rpc.handle(C.CMD_LOGIN_REVOKE_ALL, async () => {
const n = await requireProfile().revokeAllGrants()
return { ok: true, revoked: n }
})
// --- Contacts (Identity Plan Phase D) ---
function requireContacts () {
if (!contacts) throw new Error('Contacts not available — worklet still booting')
return contacts
}
rpc.handle(C.CMD_CONTACTS_LIST, async ({ limit } = {}) => {
return { contacts: await requireContacts().list({ limit }) }
})
rpc.handle(C.CMD_CONTACTS_LOOKUP, async ({ pubkey } = {}) => {
return { contact: await requireContacts().lookup(pubkey) }
})
rpc.handle(C.CMD_CONTACTS_ADD, async (input = {}) => {
return { contact: await requireContacts().add(input) }
})
// Build our own shareable contact invite: pk + display name + a root signature
// over `pear.contact:<pk>:<name>` (what add() verifies) + our lighthouse-binding
// DHT key (bk), so whoever adds us can resolve our binding for federated search.
rpc.handle(C.CMD_CONTACTS_MY_INVITE, async () => {
await whenReady()
const id = requireIdentity()
const rootHex = b4a.toString(id.getSigningKeypair().publicKey, 'hex')
let displayName = ''
try { displayName = String((await requireProfile().get('displayName')) || '').trim().slice(0, 128) } catch {}
const bindingKey = b4a.toString(id.getAppKeypair('lighthouse-binding').publicKey, 'hex')
// sign name AND binding key, so neither can be swapped in transit (add() binds both)
const sig = id.sign(`pear.contact:${rootHex}:${displayName}:${bindingKey}`).signature
const url = `p2p-contact://invite?pk=${rootHex}&name=${encodeURIComponent(displayName)}&sig=${sig}&bk=${bindingKey}`
return { url, pubkey: rootHex, displayName, bindingKey }
})
// Add a contact from a pasted invite URL. parseInviteURL extracts pk/name/sig/bk;
// add() fail-closed verifies the signature (rejecting a tampered invite).
rpc.handle(C.CMD_CONTACTS_ADD_INVITE, async ({ url } = {}) => {
await whenReady()
const parsed = Contacts.parseInviteURL(String(url || ''))
if (!parsed) throw new Error('not a valid contact invite URL')
return { contact: await requireContacts().add(parsed) }
})
rpc.handle(C.CMD_CONTACTS_UPDATE, async ({ pubkey, updates } = {}) => {
return { contact: await requireContacts().update(pubkey, updates || {}) }
})
// --- Naming (Phase N1) — petnames + curated bootstrap resolver ---
//
// Local Hyperbee petname store (Tier 0) + the pure tiered resolver
// (names.cjs / resolve-name.cjs, both Node-tested). Gated behind the
// experimentalNaming user-data flag, modelled on requireAutobee: disabled ⇒
// CMD_NAME_RESOLVE answers null and the URL bar behaves EXACTLY as before, so
// the flag-off path has no boot/navigation impact. Mutations fail closed.
function requireNames () {
if (!names) throw new Error('Names not available — worklet still booting')
return names
}
async function isNamingEnabled () {
try {
const s = await requireUserData().getSettings()
return !!(s && s.experimentalNaming)
} catch { return false }
}
async function requireNaming () {
if (!(await isNamingEnabled())) {
throw new Error('Naming (petnames) is experimental — enable it in Settings first.')
}
}
// The owner identity for the user's name claims: their stable ROOT ed25519 key.
// ownerSign signs the registry's domain-tagged canonical bytes (NOT page-supplied
// bytes — the canon is built backend-side in name-registry-ops), so a claim can
// never be coerced into signing attacker-chosen content. Mirrors the publisher's
// _rootSign / _rootPubkeyHex (identity-binding-publisher.js).
function nameRegSigner () {
const id = requireIdentity()
return {
owner: b4a.toString(id.getSigningKeypair().publicKey, 'hex'),
ownerSign: (msg) => id.sign(msg).signature,
}
}
// Open the user's OWN name registry, minting it on first claim. Mirrors
// ensureBrowserSync: mint under a throwaway namespace, persist the key + a fresh
// encryption key, then always reopen by bootstrap (so the per-user Autobase has a
// stable identity on the shared Corestore). Returns the live registry or null.
// Serialize ensureNameRegistry so two concurrent first-time claims can't both
// enter the create branch (which would double-mint + race setSettings, orphaning
// one base). The second caller awaits the first, then sees the persisted key and
// reopens the same registry. Mirrors PersonalIndex._serialize.
let _nameRegChain = Promise.resolve()
function ensureNameRegistry (opts = {}) {
const run = _nameRegChain.then(() => _ensureNameRegistryImpl(opts), () => _ensureNameRegistryImpl(opts))
_nameRegChain = run.then(() => {}, () => {}) // the lock never rejects
return run
}
async function _ensureNameRegistryImpl ({ create = false } = {}) {
const s = await requireUserData().getSettings()
const key = (typeof s.nameRegKey === 'string' && /^[0-9a-f]{64}$/i.test(s.nameRegKey)) ? s.nameRegKey : null
if (key) {
if (nameRegistry && nameRegistry.key === key) return nameRegistry
if (nameRegistry) { try { await nameRegistry.close() } catch {} nameRegistry = null }
nameRegistry = await new NameRegistry(store, { bootstrap: key, encryptionKey: null }).ready()
if (nameRegistry.discoveryKey && swarm) swarm.join(nameRegistry.discoveryKey, { server: true, client: true })
return nameRegistry
}
if (!create) return null
// Create the user's registry ONCE and KEEP it (no close-then-reopen on the
// shared store). UNENCRYPTED so trusted contacts can replicate + resolve the
// user's PUBLIC name claims — integrity is the per-claim owner signature, not
// secrecy. Anyone the user shares the key with (their contacts) can resolve it.
const reg = await new NameRegistry(store, { bootstrap: null, encryptionKey: null }).ready()
await requireUserData().setSettings({ nameRegKey: reg.key })
nameRegistry = reg
if (nameRegistry.discoveryKey && swarm) swarm.join(nameRegistry.discoveryKey, { server: true, client: true })
// Advertise the registry key on our self-certifying binding so contacts can
// discover + replicate it (best-effort; rides the lighthouse-binding rail).
if (identityBindingPublisher) identityBindingPublisher.publish().catch(() => {})
return nameRegistry
}
// Read-only, cached, replicating views of CONTACTS' registries (federation). Each
// gets its OWN substore (keyed by bootstrap) so they never collide with the user's
// own registry or each other on the shared store. Keyed by the CONTACT root (one
// registry per contact): when a contact rotates their advertised key we tear down
// the stale base + leave its swarm topic, and the map is capped + LRU-evicted, so
// a verified contact churning their key can't leak unbounded substores/topics.
const MAX_CONTACT_REGISTRIES = 128
const _contactRegistries = new Map() // contactRoot hex -> { keyHex, reg }
async function _closeContactReg (entry) {
try { if (entry.reg.discoveryKey && swarm) swarm.leave(entry.reg.discoveryKey) } catch {}
try { await entry.reg.close() } catch {}
}
async function openContactRegistry (keyHex, contactRoot) {
if (typeof keyHex !== 'string' || !/^[0-9a-f]{64}$/i.test(keyHex)) return null
const rootKey = (typeof contactRoot === 'string' && contactRoot) ? contactRoot.toLowerCase() : keyHex
const cached = _contactRegistries.get(rootKey)
if (cached) {
if (cached.keyHex === keyHex) return cached.reg
_contactRegistries.delete(rootKey) // contact rotated their key — drop the stale base
await _closeContactReg(cached)
}
while (_contactRegistries.size >= MAX_CONTACT_REGISTRIES) { // bound + LRU-evict (insertion order)
const oldestKey = _contactRegistries.keys().next().value
const oldest = _contactRegistries.get(oldestKey)
_contactRegistries.delete(oldestKey)
await _closeContactReg(oldest)
}
const reg = await new NameRegistry(store, { bootstrap: keyHex, encryptionKey: null, storeNamespace: 'eab-name-registry-c-' + keyHex }).ready()
if (reg.discoveryKey && swarm) swarm.join(reg.discoveryKey, { server: false, client: true })
_contactRegistries.set(rootKey, { keyHex, reg })
return reg
}
// Read-only, cached, replicating views of CONTACTS' Nostr event stores (Phase 3).
// Same discipline as openContactRegistry: own substore per store, keyed by contact
// root, close/leave on key-rotate, cap + LRU-evict.
const _contactEventStores = new Map() // contactRoot hex -> { keyHex, store }
async function _closeContactEventStore (entry) {
try { if (entry.store.discoveryKey && swarm) swarm.leave(entry.store.discoveryKey) } catch {}
try { await entry.store.close() } catch {}
}
async function openContactEventStore (keyHex, contactRoot) {
if (typeof keyHex !== 'string' || !/^[0-9a-f]{64}$/i.test(keyHex)) return null
const rootKey = (typeof contactRoot === 'string' && contactRoot) ? contactRoot.toLowerCase() : keyHex
const cached = _contactEventStores.get(rootKey)
if (cached) {
if (cached.keyHex === keyHex) return cached.store
_contactEventStores.delete(rootKey)
await _closeContactEventStore(cached)
}
while (_contactEventStores.size >= MAX_CONTACT_REGISTRIES) {
const oldestKey = _contactEventStores.keys().next().value
const oldest = _contactEventStores.get(oldestKey)
_contactEventStores.delete(oldestKey)
await _closeContactEventStore(oldest)
}
const es = await new NostrEventStore(store, { bootstrap: keyHex, encryptionKey: null, storeNamespace: 'eab-nostr-events-c-' + keyHex }).ready()
if (es.discoveryKey && swarm) swarm.join(es.discoveryKey, { server: false, client: true })
_contactEventStores.set(rootKey, { keyHex, store: es })
return es
}
// Resolve a typed word against the local petname store (Tier 0, wins) + the
// curated NAME_ALIASES floor (Tier 3). Never throws for the disabled/unknown
// case — returns { resolved: null } so the UI falls through to plain URL
// handling. Tiers 1/2 (contacts, followed rooms) slot in at N3/N4.
rpc.handle(C.CMD_NAME_RESOLVE, async ({ name } = {}) => {
await whenReady()
if (!(await isNamingEnabled())) return { resolved: null, enabled: false }
const petnames = names ? await names.petnameMap() : {}
// Tier 2a — the user's OWN registry (reopened if it exists; never minted by a
// read). Best-effort: a registry failure must not break petname/curated.
let registry = {}
try { const reg = await ensureNameRegistry({ create: false }); if (reg) registry = await reg.activeMap() } catch {}
// Resolve highest tiers first WITHOUT the curated floor: petname (0) + own
// registry (2a). If those miss, try federation (2b) before falling to curated.
let resolved = resolveName(name, { petnames, registry, aliases: false })
if (!resolved && federatedNameResolver) {
// Tier 2b — trusted contacts' registries (cross-user federation).
let fed = null
try { fed = await federatedNameResolver.resolve(name) } catch {}
if (fed) resolved = { name: fed.name, key: fed.key || null, link: fed.link || null, target: fed.target || fed.link || fed.key || null, label: fed.name, provenance: 'contact', source: fed.source, candidates: fed.candidates }
}
// Tier 3 — curated bootstrap floor (lowest authority).
if (!resolved) resolved = resolveName(name, { aliases: true })
return { resolved, enabled: !!names }
})
rpc.handle(C.CMD_NAME_PETNAME_LIST, async ({ limit } = {}) => {
await whenReady()
if (!names || !(await isNamingEnabled())) return { petnames: [] }
return { petnames: await requireNames().list({ limit }) }
})
rpc.handle(C.CMD_NAME_PETNAME_SET, async ({ name, key, link, label } = {}) => {
await whenReady()
await requireNaming()
return { petname: await requireNames().setPetname({ name, key, link, label }) }
})
rpc.handle(C.CMD_NAME_PETNAME_REMOVE, async ({ name } = {}) => {
await whenReady()
await requireNaming()
await requireNames().removePetname(name)
return { ok: true }
})
// --- Name registry (Phase N5) — owner-signed multi-writer claims --------------
// Claims auto-create the user's own registry; the owner is their root identity,
// signed backend-side (the page supplies only name+target, never signable bytes).
function requireNameRegistryCreated () {
if (!nameRegistry) throw new Error('No name registry yet — claim a name to create one.')
return nameRegistry
}
rpc.handle(C.CMD_NAMEREG_STATUS, async () => {
await whenReady()
if (!(await isNamingEnabled())) return { enabled: false, created: false }
const reg = await ensureNameRegistry({ create: false })
if (!reg) return { enabled: true, created: false }
const { owner } = nameRegSigner()
return { enabled: true, created: true, key: reg.key, owner, writable: reg.writable, writerKey: reg.localKey }
})
rpc.handle(C.CMD_NAMEREG_LIST, async () => {
await whenReady()
if (!(await isNamingEnabled())) return { names: [] }
const reg = await ensureNameRegistry({ create: false })
return { names: reg ? await reg.list() : [] }
})
rpc.handle(C.CMD_NAMEREG_RESOLVE, async ({ name } = {}) => {
await whenReady()
if (!(await isNamingEnabled())) return { resolved: null }
const reg = await ensureNameRegistry({ create: false })
return { resolved: reg ? await reg.resolve(name) : null }
})
rpc.handle(C.CMD_NAMEREG_CLAIM, async ({ name, target } = {}) => {
await whenReady(); await requireNaming()
// Pre-validate BEFORE minting, so garbage input never creates a registry or
// appends a dead op. A name that normalizes to empty (all-invisible) or exceeds
// MAX_NAME would be silently dropped by the reducer → a confusing {ok, null}.
const { normalize } = require('./name-normalize.cjs')
const { MAX_NAME, TARGET_ERROR, normalizeTarget } = require('./name-registry-ops.cjs')
if (typeof name !== 'string' || !name.trim()) throw new Error('name required')
if (name.length > MAX_NAME || !normalize(name)) throw new Error('invalid name (too long, or empty after normalization)')
const cleanTarget = normalizeTarget(target)
if (!cleanTarget) throw new Error(TARGET_ERROR)
const reg = await ensureNameRegistry({ create: true })
const { owner, ownerSign } = nameRegSigner()
await reg.claim({ name, target: cleanTarget.target, owner }, ownerSign)
const resolved = await reg.resolve(name)
// A null resolve after a well-formed claim means the reducer rejected it —
// homograph-blocked or already held by someone else. Report it honestly.
if (!resolved) throw new Error('Name unavailable — already held or blocked as a look-alike of an existing name.')
return { ok: true, resolved }
})
rpc.handle(C.CMD_NAMEREG_ROTATE, async ({ name, target } = {}) => {
await whenReady(); await requireNaming()
const { TARGET_ERROR, normalizeTarget } = require('./name-registry-ops.cjs')
const cleanTarget = normalizeTarget(target)
if (!cleanTarget) throw new Error(TARGET_ERROR)
const reg = requireNameRegistryCreated()
const cur = await reg.resolve(name)
if (!cur) throw new Error('You don\'t hold that name (claim it first).')
const { owner, ownerSign } = nameRegSigner()
if (cur.owner !== owner) throw new Error('You don\'t own that name.')
await reg.rotate({ name, target: cleanTarget.target, owner, version: cur.version + 1 }, ownerSign)
return { ok: true, resolved: await reg.resolve(name) }
})
rpc.handle(C.CMD_NAMEREG_RELEASE, async ({ name } = {}) => {
await whenReady(); await requireNaming()
const reg = requireNameRegistryCreated()
const { owner, ownerSign } = nameRegSigner()
await reg.release({ name, owner }, ownerSign)
return { ok: true }
})
rpc.handle(C.CMD_NAMEREG_REVOKE, async ({ name } = {}) => {
await whenReady(); await requireNaming()
const reg = requireNameRegistryCreated()
const { owner, ownerSign } = nameRegSigner()
await reg.revoke({ name, owner }, ownerSign)
return { ok: true }
})
// --- swarm.v1 — swarm consent ceremony + grants management ---
//
// When http-bridge → swarm-bridge sees a Tier C topic-join request,
// it calls requestConsent(args) which routes through openSwarmConsent
// below. Mirrors the login ceremony shape: park a pending promise,
// fire EVT_SWARM_REQUEST to the UI, the UI calls CMD_SWARM_RESOLVE
// once the user decides.
async function openSwarmConsent ({ driveKeyHex, appName, reason, topicHex, protocol }) {
return await new Promise((resolve, reject) => {
const requestId = require('crypto').randomBytes(16).toString('hex')
const timer = setTimeout(() => {
if (pendingSwarmConsents.has(requestId)) {
pendingSwarmConsents.delete(requestId)
reject(new Error('Swarm consent timed out (no response within 2 minutes)'))
}
}, SWARM_CONSENT_TIMEOUT_MS)
pendingSwarmConsents.set(requestId, { resolve, reject, timer })
rpc.event(C.EVT_SWARM_REQUEST, {
requestId,
driveKey: driveKeyHex,
topicHex,
protocol,
appName,
reason,
})
})
}
rpc.handle(C.CMD_SWARM_RESOLVE, async ({ requestId, approved } = {}) => {
const pending = pendingSwarmConsents.get(requestId)
if (!pending) throw new Error('No pending swarm consent with that id (timed out?)')
pendingSwarmConsents.delete(requestId)
clearTimeout(pending.timer)
pending.resolve(!!approved)
return { ok: true, approved: !!approved }
})
rpc.handle(C.CMD_SWARM_LIST_GRANTS, async ({ driveKey } = {}) => {
if (!swarmGrants) return { grants: [] }
const grants = driveKey
? await swarmGrants.listForApp(driveKey)
: await swarmGrants.list()
return { grants }
})
rpc.handle(C.CMD_SWARM_REVOKE_GRANT, async ({ driveKey, topicHex } = {}) => {
if (!swarmGrants) throw new Error('SwarmGrants not available')
const result = await swarmGrants.remove(driveKey, topicHex)
return { ok: true, ...result }
})
rpc.handle(C.CMD_SWARM_REVOKE_ALL_FOR_APP, async ({ driveKey } = {}) => {
if (!swarmGrants) throw new Error('SwarmGrants not available')
const n = await swarmGrants.removeAllForApp(driveKey)
return { ok: true, revoked: n }
})
rpc.handle(C.CMD_CONTACTS_REMOVE, async ({ pubkey } = {}) => {
await requireContacts().remove(pubkey)
return { ok: true }
})
// --- Nostr identity (Phase 1) — npub + cross-curve binding (NOSTR0-2) -------
// GET_IDENTITY/BIND/REVOKE take NO page-controlled payload: the signed canonical
// bytes are built backend-side (identity.makeNostrBinding/makeNostrRevocation),
// so a page can trigger a bind/revoke but can never get the root key to sign
// attacker-chosen bytes (threat #10). The nsec never leaves the worklet.
function requireNostrIdentity () {
if (!nostrBindingStore) throw new Error('Nostr identity not available — worklet still booting')
return nostrBindingStore
}
rpc.handle(C.CMD_NOSTR_GET_IDENTITY, async () => {
await whenReady()
return requireNostrIdentity().getState()
})
rpc.handle(C.CMD_NOSTR_BIND, async () => {
await whenReady()
const state = await requireNostrIdentity().bind()
// re-advertise our (now-linked) nostr-bind so contacts can author-verify our notes
if (identityBindingPublisher) identityBindingPublisher.publish().catch(() => {})
return state
})
rpc.handle(C.CMD_NOSTR_REVOKE, async () => {
await whenReady()
const state = await requireNostrIdentity().revoke()
// Re-advertise after unlinking so contacts stop trusting the last published
// nostrBind instead of waiting for an unrelated future binding refresh.
if (identityBindingPublisher) identityBindingPublisher.publish().catch(() => {})
return state
})
// --- Nostr event store (Phase 2) — author + read your own NIP-01 events --------
// The user's OWN event log: UNENCRYPTED + create-and-keep (public, signature-
// integrity, contact-replicable in a later phase), serialized so two concurrent
// first publishes can't double-mint. Mirrors ensureNameRegistry.
let _nostrEvChain = Promise.resolve()
function ensureNostrEventStore (opts = {}) {
const run = _nostrEvChain.then(() => _ensureNostrEventStoreImpl(opts), () => _ensureNostrEventStoreImpl(opts))
_nostrEvChain = run.then(() => {}, () => {})
return run
}
async function _ensureNostrEventStoreImpl ({ create = false } = {}) {
const s = await requireUserData().getSettings()
const key = (typeof s.nostrEventKey === 'string' && /^[0-9a-f]{64}$/i.test(s.nostrEventKey)) ? s.nostrEventKey : null
if (key) {
if (nostrEventStore && nostrEventStore.key === key) return nostrEventStore
if (nostrEventStore) { try { await nostrEventStore.close() } catch {} nostrEventStore = null }
nostrEventStore = await new NostrEventStore(store, { bootstrap: key, encryptionKey: null }).ready()
if (nostrEventStore.discoveryKey && swarm) swarm.join(nostrEventStore.discoveryKey, { server: true, client: true })
return nostrEventStore
}
if (!create) return null
const es = await new NostrEventStore(store, { bootstrap: null, encryptionKey: null }).ready()
await requireUserData().setSettings({ nostrEventKey: es.key })
nostrEventStore = es
if (nostrEventStore.discoveryKey && swarm) swarm.join(nostrEventStore.discoveryKey, { server: true, client: true })
// advertise the event-store key on our binding so contacts can replicate our feed
if (identityBindingPublisher) identityBindingPublisher.publish().catch(() => {})
return nostrEventStore
}
// Sign a NIP-01 event with the user's Nostr key (trusted chrome only — the page-
// facing signer with a kind-whitelist is a later phase) and store it. created_at
// is the event's claimed wall-clock (part of the signed content + NIP-01); the
// store's reducer never orders by it.
rpc.handle(C.CMD_NOSTR_PUBLISH, async ({ kind, content, tags, created_at } = {}) => {
await whenReady()
const id = requireIdentity()
const { MAX_CONTENT, MAX_TAGS, MAX_EVENT_BYTES } = require('./nostr-events-ops.cjs')
if (!Number.isInteger(kind) || kind < 0) throw new Error('kind must be a non-negative integer')
if (typeof content !== 'string') throw new Error('content must be a string')
if (content.length > MAX_CONTENT) throw new Error('content is too large')
const safeTags = Array.isArray(tags)
? tags.filter((t) => Array.isArray(t) && t.every((x) => typeof x === 'string')).slice(0, MAX_TAGS)
: []
const nowSec = Math.floor(Date.now() / 1000)
const ts = Number.isInteger(created_at) && created_at > 0 ? created_at : nowSec
// reject a future-dated note (anti-spam: keeps your own + contacts' feeds honest
// once events become contact-replicable; the reducer itself stays clock-free)
if (ts > nowSec + 900) throw new Error('created_at is too far in the future')
try {
const draft = { id: '0'.repeat(64), pubkey: id.getNostrPublicKey(), created_at: ts, kind, tags: safeTags, content, sig: '0'.repeat(128) }
if (JSON.stringify(draft).length > MAX_EVENT_BYTES) throw new Error('event is too large')
} catch (err) {
if (err && err.message === 'event is too large') throw err
throw new Error('event could not be serialized')
}
const ev = id.nostrSignEvent({ kind, created_at: ts, tags: safeTags, content })
const { verifyEvent } = require('./nostr-events-apply.cjs')
if (!verifyEvent(ev).ok) throw new Error('event failed NIP-01 validation (content/tags too large?)')
const es = await ensureNostrEventStore({ create: true })
await es.addEvent(ev)
return { ok: true, event: ev }
})
// Query your own feed; with { federated:true }, also merge TRUSTED contacts'
// attested events (each tagged `_via` with the source contact). Best-effort: a
// corrupt store / a slow contact degrades to fewer events, never an RPC reject.
const MAX_FEED_EVENTS = 500
rpc.handle(C.CMD_NOSTR_QUERY, async ({ filter, federated } = {}) => {
await whenReady()
const { queryEvents } = require('./nostr-query.cjs')
let own = []
try { const es = await ensureNostrEventStore({ create: false }); if (es) own = (await es.listEvents()).map((ev) => ({ ...ev, _via: null })) } catch {}
let contactEvents = []
let hidden = null
if (federated && federatedNostrFeed) {
try {
if (typeof federatedNostrFeed.eventsWithDiagnostics === 'function') {
const res = await federatedNostrFeed.eventsWithDiagnostics()
contactEvents = Array.isArray(res?.events) ? res.events : []
hidden = res?.hidden || null
} else {
contactEvents = await federatedNostrFeed.events()
}
} catch {}
}
const byId = new Map()
for (const ev of own) byId.set(ev.id, ev) // own wins on id collision
for (const ev of contactEvents) if (!byId.has(ev.id)) byId.set(ev.id, ev)
const cap = Math.min((filter && Number.isInteger(filter.limit) && filter.limit > 0) ? filter.limit : MAX_FEED_EVENTS, MAX_FEED_EVENTS)
return { events: queryEvents([...byId.values()], { ...(filter || {}), limit: cap }), hidden }
})
// Also enrich the existing CMD_GET_IDENTITY response with mnemonic hint
// (without exposing the phrase itself) so the UI can show identity status.
// --- Drive Management ---
const MAX_BROWSE_DRIVES = 20
const driveInfoUpdates = new WeakMap()
function safeJSONParse (str) {
const obj = JSON.parse(str)
if (obj && typeof obj === 'object') {
delete obj.__proto__
delete obj.constructor
}
return obj
}
async function ensureBrowseDrive (keyHex) {
// Validate drive key format
if (!/^[0-9a-f]{64}$/i.test(keyHex)) {
throw new Error('Invalid drive key format')
}
if (browseDrives.has(keyHex)) {
const entry = browseDrives.get(keyHex)
entry.lastAccess = Date.now()
// Re-sync on navigation so a RE-PUBLISHED drive (advanced to a new version)
// is picked up on reload. Without this, a cached session stays pinned to the
// version it first saw: the page's index.html still loads but its newer
// sub-resources (js/app.js …) read a stale file index and 404. Best-effort +
// debounced; if the version actually moves we drop the proxy's per-file cache
// for this drive so stale bytes can't mask the new release.
reSyncBrowseDrive(entry.drive, keyHex)
return entry.drive
}
// Evict the least-recently-used drive if at capacity. We track
// lastAccess on every hit, so evict by that — not by Map insertion order
// (keys().next()), which would drop a frequently-used drive just because
// it was opened first. Matches the LRU policy in cleanupOldData().
if (browseDrives.size >= MAX_BROWSE_DRIVES) {
let oldest = null
let oldestAccess = Infinity
for (const [key, entry] of browseDrives) {
const access = entry.lastAccess || 0
if (access < oldestAccess) { oldestAccess = access; oldest = key }
}
const oldEntry = browseDrives.get(oldest)
browseDrives.delete(oldest)
unregisterHiveRelayDrive(oldest, oldEntry.drive)
try { await swarm.leave(oldEntry.drive.discoveryKey) } catch (err) {
console.error('Failed to leave swarm:', err.message)
}
try { await oldEntry.drive.close() } catch (err) {
console.error('Failed to close drive:', err.message)
}
}
const drive = new Hyperdrive(store, Buffer.from(keyHex, 'hex'))
await drive.ready()
// Tell Hypercore that peer discovery is in progress so {wait:true} reads BLOCK
// until a peer is actually found, instead of returning empty / timing out on a
// cold drive. Without this, the proxy's drive.update (8s) + drive.get (15s) wait
// out their timeouts with nobody to ask → multi-minute "Loading" stall / bogus
// directory-listing fallback. verify-pin is fast for exactly this reason.
const donePeers = drive.findingPeers()
const discovery = swarm.join(drive.discoveryKey, { server: false, client: true })
discovery.flushed().then(donePeers, donePeers)
// Prefetch the entrypoint so the block the proxy will request is already in
// flight — overlaps DHT discovery with the block fetch (best-effort).
drive.get('/index.html', { wait: true }).catch(() => {})
browseDrives.set(keyHex, {
drive,
lastAccess: Date.now()
})
registerHiveRelayDrive(keyHex, drive)
return drive
}
async function getDriveForProxy (keyHex) {
// Check browse drives
if (browseDrives.has(keyHex)) {
const entry = browseDrives.get(keyHex)
entry.lastAccess = Date.now()
return entry.drive
}
// Check app drives
if (appManager && appManager.activeDrives.has(keyHex)) {
return appManager.activeDrives.get(keyHex)
}
// Check site drives
if (siteManager) {
for (const [, site] of siteManager.sites) {
if (site.keyHex === keyHex) return site.drive
}
}
// Load on demand
return await ensureBrowseDrive(keyHex)
}
// --- App/site icon resolution (CMD_GET_APP_ICON) ---------------------------
const ICON_WELL_KNOWN = ['/icon.svg', '/icon.png', '/icon.jpg', '/icon.jpeg', '/icon.webp', '/favicon.svg', '/favicon.png', '/favicon.ico', '/logo.svg', '/logo.png']
const ICON_MIME = { svg: 'image/svg+xml', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', ico: 'image/x-icon', gif: 'image/gif' }
const MAX_ICON_BYTES = 512 * 1024
const ICON_TTL_MS = 10 * 60 * 1000 // cache a FOUND icon a while
const ICON_NULL_TTL_MS = 45 * 1000 // retry a MISSING icon soon (drive may still be replicating)
const _iconCache = new Map() // 'driveKey|iconRef' -> { iconData, ts }
function _iconMime (path) {
const ext = String(path).split('.').pop().toLowerCase()
return ICON_MIME[ext] || 'image/png'
}
// Open the drive by key and return the first available icon as a data: URI,
// trying the declared iconRef first, then well-known paths. Fail-soft to null.
async function resolveAppIcon (driveKey, iconRef) {
if (typeof driveKey !== 'string' || !/^[0-9a-f]{64}$/i.test(driveKey)) return null
const cacheKey = driveKey + '|' + (typeof iconRef === 'string' ? iconRef : '')
const hit = _iconCache.get(cacheKey)
if (hit && (Date.now() - hit.ts) < (hit.iconData ? ICON_TTL_MS : ICON_NULL_TTL_MS)) return hit.iconData
let iconData = null
try {
const drive = await getDriveForProxy(driveKey)
if (drive) {
const candidates = []
if (typeof iconRef === 'string' && iconRef && iconRef.length <= 512) {
candidates.push(iconRef.startsWith('/') ? iconRef : '/' + iconRef)
}
for (const p of ICON_WELL_KNOWN) if (!candidates.includes(p)) candidates.push(p)
for (const p of candidates) {
const buf = await drive.get(p).catch(() => null)
if (buf && buf.length && buf.length <= MAX_ICON_BYTES) {
iconData = 'data:' + _iconMime(p) + ';base64,' + Buffer.from(buf).toString('base64')
break
}
}
}
} catch (_) { iconData = null }
_iconCache.set(cacheKey, { iconData, ts: Date.now() })
return iconData
}
function driveKeyFromUrl (url) {
if (typeof url !== 'string' || !url.trim()) return ''
try {
const parsed = new URL(url)
return parsed.hostname || ''
} catch {
return ''
}
}
function driveKeyFromProxyUrl (url) {
if (typeof url !== 'string' || !url.trim()) return ''
try {
const parsed = new URL(url)
if (parsed.protocol !== 'http:' || (parsed.hostname !== '127.0.0.1' && parsed.hostname !== 'localhost')) return ''
const match = parsed.pathname.match(/^\/(?:hyper|app)\/([0-9a-f]{64})(?:\/|$)/i)
return match ? match[1].toLowerCase() : ''
} catch {
return ''
}
}
function registerHiveRelayDrive (keyHex, drive) {
if (!keyHex || !drive || !hiveRelay?.drives || typeof hiveRelay.drives.set !== 'function') return
const existing = hiveRelay.drives.get(keyHex)
if (!existing || existing === drive || existing.closed || existing.closing) hiveRelay.drives.set(keyHex, drive)
}
function unregisterHiveRelayDrive (keyHex, drive) {
if (!keyHex || !hiveRelay?.drives || typeof hiveRelay.drives.get !== 'function') return
if (!drive || hiveRelay.drives.get(keyHex) === drive) hiveRelay.drives.delete(keyHex)
}
async function updateDriveBestEffort (drive, timeoutMs = 2500) {
if (!drive || typeof drive.update !== 'function') return
let pending = driveInfoUpdates.get(drive)
if (!pending) {
pending = drive.update({ wait: true })
.catch(() => {})
.finally(() => driveInfoUpdates.delete(drive))
driveInfoUpdates.set(drive, pending)
}
try {
await Promise.race([
pending,
new Promise((resolve) => setTimeout(resolve, timeoutMs))
])
} catch {}
}
// Re-advance a cached browse drive to the latest published version and, if the
// version actually moved (the author re-published), invalidate the proxy's
// per-file cache for that drive so a reload serves the new release instead of
// stale bytes. Fire-and-forget so navigation stays instant; the proxy's own
// fetch path also self-heals on a miss (see hyper-proxy _fetchP2P).
function reSyncBrowseDrive (drive, keyHex) {
if (!drive || typeof drive.update !== 'function') return
const before = drive.version
updateDriveBestEffort(drive)
.then(() => {
if (drive.version !== before && proxy && typeof proxy.invalidateCache === 'function') {
proxy.invalidateCache(keyHex)
}
})
.catch(() => {})
}
function getDrivePeerSnapshot (drive) {
const metadataPeers = peerCollectionSize(drive?.core?.peers)
const blobPeers = peerCollectionSize(drive?.blobs?.core?.peers)
return {
peerCount: Math.max(metadataPeers, blobPeers),
metadataPeerCount: metadataPeers,
blobPeerCount: blobPeers
}
}
function peerCollectionSize (peers) {
if (!peers) return 0
if (Number.isFinite(peers.length)) return peers.length
if (Number.isFinite(peers.size)) return peers.size
return 0
}
function getDriveByteSnapshot (drive) {
const metadataBytes = Number.isFinite(drive?.core?.byteLength) ? drive.core.byteLength : 0
const blobBytes = Number.isFinite(drive?.blobs?.core?.byteLength) ? drive.blobs.core.byteLength : 0
return {
byteLength: metadataBytes + blobBytes,
metadataByteLength: metadataBytes,
blobByteLength: blobBytes
}
}
function getRelayPinSnapshot (keyHex, drive) {
const relays = hiveRelay?.getRelays ? hiveRelay.getRelays() : []
let seedStatus = null
let durableStatus = null
const advertisedRelayPubkeys = new Set()
try {
seedStatus = hiveRelay?.getSeedStatus ? hiveRelay.getSeedStatus(keyHex) : null
} catch {}
try {
registerHiveRelayDrive(keyHex, drive)
durableStatus = hiveRelay?.getDurableStatus ? hiveRelay.getDurableStatus(keyHex) : null
} catch {}
try {
const rows = hiveRelay?.getAvailableApps ? hiveRelay.getAvailableApps() : []
for (const row of rows || []) {
const rowKey = String(row?.appKey || row?.driveKey || row?.keyHex || row?.key || '').toLowerCase()
if (rowKey !== keyHex) continue
const relayPubkey = row?.source?.relayPubkey || row?.relayPubkey || row?.relay || null
if (relayPubkey) advertisedRelayPubkeys.add(String(relayPubkey))
if (Array.isArray(row?.relays)) {
for (const relay of row.relays) advertisedRelayPubkeys.add(String(relay))
}
}
} catch {}
const seedRelays = Array.isArray(seedStatus?.relays) ? seedStatus.relays : []
const seedRelayPubkeys = seedRelays.map((relay) => relay?.pubkey).filter(Boolean).map(String)
const pinRelayPubkeys = Array.from(new Set([...Array.from(advertisedRelayPubkeys), ...seedRelayPubkeys]))
return {
available: !!hiveRelay,
connectedRelays: relays.length,
connectedRelayPubkeys: relays.map((r) => r.pubkey).filter(Boolean),
seedAcceptances: seedStatus?.acceptances || 0,
seedRelays,
advertisedRelays: advertisedRelayPubkeys.size,
advertisedRelayPubkeys: Array.from(advertisedRelayPubkeys),
pinRelayPubkeys,
durable: !!durableStatus?.durable,
activePeers: durableStatus?.activePeers || 0,
driveOpen: durableStatus?.driveOpen !== false,
byteLengthLocal: durableStatus?.byteLengthLocal || 0,
byteLengthRemoteMax: durableStatus?.byteLengthRemoteMax || 0,
hybridFetchEnabled: relayClient?.enabled !== false,
gatewayRelays: Array.isArray(relayClient?.relays) ? [...relayClient.relays] : []
}
}
// Persist app/site state to disk
function persistState () {
try {
const state = {
installedApps: appManager ? appManager.export() : {},
sites: siteManager ? siteManager.export() : {},
relayConfig: relayClient ? {
relays: relayClient.relays,
enabled: relayClient.enabled,
} : undefined,
savedAt: Date.now()
}
fs.writeFileSync(storagePath + '/pearbrowser-state.json', JSON.stringify(state))
} catch (err) {
console.warn('[persistState] write failed:', err && err.message)
}
}
// --- Boot ---
async function boot () {
console.log('Boot starting, storagePath:', storagePath)
rpc.event(C.EVT_BOOT_PROGRESS, { stage: 'identity-load', message: 'Loading identity...' })
// Phase 1 ticket 3 — load or generate the user's root identity
identity = new Identity(storagePath)
await identity.ready()
console.log('Identity ready')
rpc.event(C.EVT_BOOT_PROGRESS, { stage: 'corestore-start', message: 'Initializing storage...' })
// Derive the Corestore from the user's identity seed so rotating the
// identity gives a clean store. The seed is 32 bytes — exactly what
// Corestore's primaryKey expects. `unsafe: true` acknowledges that we
// know what we're doing (corestore >= 7.x guards the primaryKey path
// because a wrong value destroys existing hypercore data).
// Corestore manages its own primaryKey (auto-generated + persisted
// on first open, reused after). Decoupled from identity so identity
// regeneration / partial writes never cause primaryKey mismatches
// that require a destructive recovery. Identity exists independently
// for publisher signing (HiveRelay) and BIP-39 backup.
// Clear stale rocksdb LOCK from a previous process that didn't shut
// down cleanly (e.g. SIGKILL, power loss, mid-boot crash). If the
// lock is truly live, opening it below will still fail and we re-
// throw. This avoids the common "instant crash on second run after
// a crash" trap users hit.
try {
const lockPath = storagePath + '/db/LOCK'
if (fs.existsSync(lockPath)) {
try { fs.unlinkSync(lockPath); console.log('[boot] cleared stale db/LOCK') } catch {}
}
} catch {}
store = new Corestore(storagePath)
console.log('Corestore created, waiting for ready...')
rpc.event(C.EVT_BOOT_PROGRESS, { stage: 'corestore-ready', message: 'Storage ready' })
await store.ready()
console.log('Corestore ready')
console.log('Creating Hyperswarm...')
rpc.event(C.EVT_BOOT_PROGRESS, { stage: 'hyperswarm-start', message: 'Starting P2P network...' })
swarm = new Hyperswarm()
console.log('Hyperswarm created')
rpc.event(C.EVT_BOOT_PROGRESS, { stage: 'hyperswarm-ready', message: 'P2P network ready' })
swarm.on('connection', (conn) => {
// Guard against race: incoming connection can land while a drive
// is closing (e.g. during CMD_DELETE_SITE) and store.replicate()
// will throw "Corestore is closed", crashing the whole Bare
// process via the unhandled rejection path. Swallow cleanly.
if (!store || store.closed || store.closing) {
try { conn.destroy?.() } catch {}
return
}
try {
store.replicate(conn)
} catch (err) {
console.error('[swarm] replicate failed:', err && err.message)
try { conn.destroy?.() } catch {}
return
}
peerCount++
rpc.event(C.EVT_PEER_COUNT, { peerCount })
conn.on('close', () => {
peerCount = Math.max(0, peerCount - 1)
rpc.event(C.EVT_PEER_COUNT, { peerCount })
})
conn.on('error', () => {
peerCount = Math.max(0, peerCount - 1)
rpc.event(C.EVT_PEER_COUNT, { peerCount })
})
})
// HiveRelay client — shares our swarm + store. Handles auto-discovery
// of relay nodes (5 live across 2 regions), signed seed requests, and
// circuit-relay fallbacks for NAT-blocked peers. Non-fatal if init fails.
//
// 0.8.5 note: client SDK split out into its own ESM package
// (`p2p-hiverelay-client`) since 0.5.x. Old `p2p-hiverelay/client`
// subpath no longer exists. Use dynamic import() because we're in a
// CommonJS module and the new package is `"type": "module"`.
try {
const { HiveRelayClient } = await import('p2p-hiverelay-client')
hiveRelay = new HiveRelayClient({ swarm, store })
hiveRelay.on('relay-connected', ({ pubkey }) => {
console.log('[hiverelay] connected:', pubkey.slice(0, 12) + '…')
})
await hiveRelay.start()
console.log('[hiverelay] client started')
} catch (err) {
console.error('[hiverelay] init failed:', err && err.message)
hiveRelay = null
}
// Initialize managers
rpc.event(C.EVT_BOOT_PROGRESS, { stage: 'managers-start', message: 'Loading app manager...' })
catalogManager = new CatalogManager(store, swarm)
appManager = new AppManager(store, swarm)
siteManager = new SiteManager(store, swarm)
pearBridge = new PearBridge(store, swarm)
// Phase 1 ticket 2 — Hyperbee-backed user data (bookmarks, history, etc.)
userData = new UserData(store, swarm)
try {
await userData.ready()
console.log('UserData ready')
} catch (err) {
console.error('UserData init failed:', err && err.message)
userData = null
}
// Lighthouse Phase 0 — local self-search index over the user's own browsed/
// bookmarked content (docs/P2P-SEARCH-RESEARCH.md). Each posting is signed by
// the per-user 'search' subkey (forward-compat with the networked phases);
// queried fully locally. PersonalIndex is imported in the static dependency
// block so Pear stages it for the Bare main process.
try {
// Sign each posting with the BOUND (rotatable) search key via the binding
// publisher, so a trusted peer who resolves our search key can verify our
// postings (Lighthouse Phase 2). The publisher is created just below; this
// hook runs only when a page is indexed (post-boot), so it's set by then.
// Fallback to the seed-derived 'search' subkey if the publisher isn't up —
// those postings stay self-only (hop-0 isn't peer-verified) until re-indexed.
const sign = (canonDoc) => {
const payload = JSON.stringify(canonDoc)
if (identityBindingPublisher) {
try { return identityBindingPublisher.signDocSync(payload) } catch (_) { /* fall through */ }
}
const r = identity.signForApp('search', payload, 'lighthouse-doc-v2')
return { sig: r.signature, pubkey: r.publicKey }
}
personalIndex = await new PersonalIndex(store, { sign }).ready()
console.log('PersonalIndex ready')
} catch (err) {
console.error('PersonalIndex init failed:', err && err.message)
personalIndex = null
}
// Identity Plan Phase B + D — profile attributes + contacts Hyperbees.
profile = new Profile(store)
try { await profile.ready(); console.log('Profile ready') }
catch (err) { console.error('Profile init failed:', err && err.message); profile = null }
contacts = new Contacts(store, {
// Verify invite signatures against the contact's own root key. Fails
// closed: if identity is unavailable, a signed invite is refused.
verify: (payload, sigHex, pubHex) => {
if (!identity) throw new Error('identity unavailable for contact verify')
return identity.verify(payload, sigHex, pubHex)
}
})
try { await contacts.ready(); console.log('Contacts ready') }
catch (err) { console.error('Contacts init failed:', err && err.message); contacts = null }
// NOSTR2 Phase 1 — local cross-curve binding state (npub + attested epoch),
// persisted in the PersonalIndex meta namespace. Initialize this before the
// Lighthouse binding publisher so boot-time advertisements include any
// previously linked Nostr binding.
if (personalIndex && identity) {
try {
const { NostrBindingStore } = require('./nostr-binding-store.cjs')
nostrBindingStore = await new NostrBindingStore({ identity, personalIndex }).ready()
console.log('NostrBindingStore ready')
} catch (err) {
console.error('NostrBindingStore init failed:', err && err.message)
nostrBindingStore = null
}
}
// Lighthouse Phase 2 — publish our self-certifying IdentityBinding (root →
// rotatable search subkey) to the DHT + personal index, so trusted peers can
// resolve and verify our search postings. Best-effort; never blocks boot.
if (personalIndex && identity) {
try {
const { IdentityBindingPublisher } = require('./identity-binding-publisher.js')
identityBindingPublisher = await new IdentityBindingPublisher({
ib: require('./identity-binding.cjs'),
identity, personalIndex, contacts, dht: swarm && swarm.dht,
// advertise our name-registry key (if created) so contacts can resolve our names
getNameRegKey: async () => { try { return (await requireUserData().getSettings()).nameRegKey || null } catch { return null } },
// Nostr Phase 3: advertise our event-store key plus current nostr-bind
// and revocations so contacts can classify linked/revoked/stale authors.
getNostrEventKey: async () => { try { return (await requireUserData().getSettings()).nostrEventKey || null } catch { return null } },
getNostrBind: async () => { try { const st = nostrBindingStore ? await nostrBindingStore.getState() : null; return st ? st.binding : null } catch { return null } },
getNostrRevocations: async () => { try { return nostrBindingStore ? await nostrBindingStore.getRevocations() : [] } catch { return [] } },
}).ready()
identityBindingPublisher.publish().catch((e) => console.error('[binding] publish failed:', e && e.message))
console.log('IdentityBindingPublisher ready')
} catch (err) {
console.error('IdentityBindingPublisher init failed:', err && err.message)
identityBindingPublisher = null
}
}
// N5 federation — resolve a typed name across TRUSTED contacts' registries.
// Needs contacts (the trust frontier) + the binding publisher (to find each
// contact's advertised registry key) + openContactRegistry (to replicate it).
if (contacts && identityBindingPublisher) {
try {
const { FederatedNameResolver } = require('./federated-name-resolver.cjs')
federatedNameResolver = new FederatedNameResolver({
listContacts: () => requireContacts().list({ limit: 200 }),
resolveBinding: (args) => identityBindingPublisher.resolve(args),
openRegistry: (keyHex, contactRoot) => openContactRegistry(keyHex, contactRoot),
})
console.log('FederatedNameResolver ready')
} catch (err) {
console.error('FederatedNameResolver init failed:', err && err.message)
federatedNameResolver = null
}
// NOSTR Phase 3 — surface TRUSTED contacts' attested events in the feed.
try {
const { FederatedNostrFeed } = require('./federated-nostr-feed.cjs')
federatedNostrFeed = new FederatedNostrFeed({
listContacts: () => requireContacts().list({ limit: 200 }),
resolveBinding: (args) => identityBindingPublisher.resolve(args),
openEventStore: (keyHex, contactRoot) => openContactEventStore(keyHex, contactRoot),
})
console.log('FederatedNostrFeed ready')
} catch (err) {
console.error('FederatedNostrFeed init failed:', err && err.message)
federatedNostrFeed = null
}
}
// Retry Nostr binding setup if the early pre-publisher init failed.
if (personalIndex && identity && !nostrBindingStore) {
try {
const { NostrBindingStore } = require('./nostr-binding-store.cjs')
nostrBindingStore = await new NostrBindingStore({ identity, personalIndex }).ready()
console.log('NostrBindingStore ready')
} catch (err) {
console.error('NostrBindingStore init failed:', err && err.message)
nostrBindingStore = null
}
}
// Lighthouse Phase 2 — federated query planner (local-first + trusted-peer
// fan-out). Degrades to local-only search if it can't construct, matching the
// existing graceful-degradation pattern.
if (personalIndex && identity) {
try {
const { QueryPlanner, SearchFanoutBudget } = require('./query-planner.js')
queryPlanner = new QueryPlanner({
personalIndex, contacts, identity, swarm, store,
budget: new SearchFanoutBudget(), bindingPublisher: identityBindingPublisher,
})
console.log('QueryPlanner ready')
} catch (err) {
console.error('QueryPlanner init failed:', err && err.message)
queryPlanner = null
}
}
// Naming Phase N1 — local petname store (crypto-free Hyperbee). Always
// inited; the experimentalNaming flag only gates whether the resolver/
// mutations are reachable, not whether the store opens.
names = new Names(store)
try { await names.ready(); console.log('Names ready') }
catch (err) { console.error('Names init failed:', err && err.message); names = null }
// swarm.v1 — direct Hyperswarm access for hyper:// pages.
// SwarmGrants persists Tier C topic-join grants across launches.
// SwarmBridge multiplexes peer events into per-channel SSE streams.
swarmGrants = new SwarmGrants(store, swarm)
try { await swarmGrants.ready(); console.log('SwarmGrants ready') }
catch (err) { console.error('SwarmGrants init failed:', err && err.message); swarmGrants = null }
swarmBridge = new SwarmBridge(swarm, {
identity,
swarmGrants,
requestConsent: (args) => openSwarmConsent(args),
})
rpc.event(C.EVT_BOOT_PROGRESS, { stage: 'managers-ready', message: 'Managers loaded' })
// Restore persisted app/site state from disk
const stateFile = storagePath + '/pearbrowser-state.json'
let persistedState = {}
try {
const raw = fs.readFileSync(stateFile, 'utf-8')
persistedState = safeJSONParse(raw) || {}
if (persistedState.installedApps) appManager.import(persistedState.installedApps)
if (persistedState.sites) await siteManager.import(persistedState.sites)
} catch (err) {
// No saved state yet — first run
if (err.code !== 'ENOENT') {
console.error('Failed to load state:', err.message)
}
}
// Initialize relay client for hybrid fast-path.
// Config is user-controllable via CMD_SET_RELAYS / CMD_SET_RELAY_ENABLED
// and persisted in pearbrowser-state.json alongside apps and sites.
// Phase 0 ticket 2 completed.
const DEFAULT_RELAYS = [
'https://relay-us.p2phiverelay.xyz',
'https://relay-sg.p2phiverelay.xyz'
]
const savedRelayConfig = persistedState.relayConfig || {}
relayClient = new RelayClient({
relays: Array.isArray(savedRelayConfig.relays) && savedRelayConfig.relays.length > 0
? savedRelayConfig.relays
: DEFAULT_RELAYS,
enabled: savedRelayConfig.enabled !== false,
timeout: 5000
})
// iroh adoption — bootstrap the relay directory + index rooms from
// DHT-resolvable relay records (resolve each seed's pubkey over swarm.dht,
// signature-verified by hyperdht). Best-effort + fire-and-forget so it never
// blocks boot; the static DEFAULT_RELAYS already give a working fallback, and
// a seed with no pubkey simply no-ops. Completes the Phase-5 bootstrap loop:
// a baked-in relay pubkey resolves its current gateway + index room with no
// hardcoded address and no running sidecar.
;(async () => {
try {
if (!swarm || !swarm.dht || !Array.isArray(C.BOOTSTRAP_RELAYS) || !C.BOOTSTRAP_RELAYS.length) return
const { indexRooms } = await relayClient.bootstrapFromDht(swarm.dht, C.BOOTSTRAP_RELAYS)
for (const r of indexRooms) {
if (!r || !r.indexRoom || !catalogManager) continue
if (!ENABLE_RELAY_INDEX_ROOMS) continue
try { await catalogManager.loadCatalogIndexRoom(r.indexRoom) } catch (e) {
console.error('[iroh] index-room load failed:', e && e.message)
}
}
} catch (err) {
console.error('[iroh] relay bootstrap failed:', err && err.message)
}
})()
// Start HTTP proxy with hybrid fetching (relay + P2P)
proxy = new HyperProxy(getDriveForProxy, (path, err) => {
rpc.event(C.EVT_ERROR, { type: 'proxy-error', path, message: err })
}, relayClient, {
perDriveOrigins: ENABLE_PER_DRIVE_ORIGINS
})
// swarm.v1 — page-side shim that exposes window.pear.swarm.v1 to every
// text/html response served by the proxy. Pages get it for free; no
// <script src> required from the page author. See docs/SWARM-V1.md.
proxy.setPearSwarmShim(PEAR_SWARM_V1_SHIM)
proxy.setPearSyncShim(PEAR_SYNC_SHIM)
// Content Shield — enabled by default, persisted `contentShield: false`
// turns it off. Phase 2 durable lists / allowlist / strict and Phase 3
// plugin kill-switches rehydrate from user-data (offline after first save).
contentShield = new ContentShield()
pearPlugins = new PearPluginRegistry({ shield: contentShield })
try {
const shieldSettings = userData ? await userData.getSettings() : null
if (shieldSettings && shieldSettings.contentShield === false) contentShield.setEnabled(false)
if (shieldSettings && shieldSettings.contentShieldState) {
contentShield.importListState(shieldSettings.contentShieldState)
}
// Legacy array keys (settings UI may write these directly).
if (shieldSettings && Array.isArray(shieldSettings.contentShieldAllow)) {
for (const key of shieldSettings.contentShieldAllow) contentShield.allowlistDrive(key)
}
if (shieldSettings && Array.isArray(shieldSettings.contentShieldStrict)) {
for (const key of shieldSettings.contentShieldStrict) contentShield.setStrictDrive(key, true)
}
if (shieldSettings && shieldSettings.contentShieldPlugins && typeof shieldSettings.contentShieldPlugins === 'object') {
persistShieldState._pluginPayloads = { ...shieldSettings.contentShieldPlugins }
for (const payload of Object.values(shieldSettings.contentShieldPlugins)) {
try { pearPlugins.register(payload) } catch {}
}
}
// P2P distribution (Phase 2/3 gates). Rule text and plugin payloads were
// already rehydrated above, so both work fully offline; these two own
// the drive-sourced metadata and the hot-swap lifecycle.
const sha256Hex = (buf) => crypto.createHash('sha256').update(buf).digest('hex')
const refreshDistributionDrive = async (keyHex) => {
const drive = await getDriveForProxy(keyHex)
if (!drive) return
const before = drive.version
await updateDriveBestEffort(drive, 8000)
if (drive.version !== before && proxy && typeof proxy.invalidateCache === 'function') {
proxy.invalidateCache(keyHex)
}
}
shieldListSync = new ShieldListSync({
shield: contentShield,
fetchDriveFile: (keyHex, path) => proxy._hybridFetch(keyHex, path),
refreshDrive: refreshDistributionDrive,
sha256Hex,
persistMeta: async (meta) => {
if (userData) await userData.setSettings({ contentShieldListSync: meta })
}
})
if (shieldSettings && shieldSettings.contentShieldListSync) {
shieldListSync.restore(shieldSettings.contentShieldListSync)
}
pluginDriveLoader = new PluginDriveLoader({
registry: pearPlugins,
fetchDriveFile: (keyHex, path) => proxy._hybridFetch(keyHex, path),
refreshDrive: refreshDistributionDrive,
sha256Hex,
persistInstall: async (id, payload) => {
if (payload === null) {
delete persistShieldState._pluginPayloads[id]
} else if (payload.__recordPatch) {
const existing = persistShieldState._pluginPayloads[id]
if (existing) existing.enabled = false // escalation guard disabled it
} else {
persistShieldState._pluginPayloads[id] = {
id,
manifest: payload.manifest,
contribution: payload.contribution,
enabled: payload.enabled !== false
}
}
await persistPluginInstallRecords()
}
})
if (shieldSettings && shieldSettings.contentShieldPluginInstalls) {
pluginDriveLoader.restore(shieldSettings.contentShieldPluginInstalls)
}
pluginCatalog = new PluginCatalog({
fetchDriveFile: (keyHex, path) => proxy._hybridFetch(keyHex, path),
refreshDrive: refreshDistributionDrive
})
if (shieldSettings && shieldSettings.contentShieldPluginCatalog) {
pluginCatalog.restore(shieldSettings.contentShieldPluginCatalog)
}
} catch {}
proxy.setContentShield(contentShield)
// Background list refresh: first sweep shortly after boot (drives need the
// swarm), then periodic hot-swap checks. Failures never disturb browsing.
if (shieldListSync) {
const firstSweep = setTimeout(() => {
shieldListSync.refreshAll()
.then((outcomes) => {
if (outcomes.some(o => o.ok && o.changed)) return persistShieldState()
})
.catch(() => {})
}, 30 * 1000)
if (typeof firstSweep?.unref === 'function') firstSweep.unref()
shieldListSync.startAutoRefresh(30 * 60 * 1000)
}
// Session bridge + privacy ladder (Phases 4–5). Clearnet navigations route
// through the browser-owned /clearnet/* proxy by default so Content Shield
// sees every request. Direct mode (real https load) is a settings opt-in.
try {
const bootSettings = userData ? await userData.getSettings() : null
applyPrivacyFromSettings(bootSettings || {})
} catch {
privacySettings = { ...DEFAULT_PRIVACY }
if (proxy) proxy.setPrivacySettings(privacySettings)
}
sessionBridge = new SessionBridge({
getShield: () => contentShield,
getPrivacy: () => privacySettings,
getProxyPort: () => (proxy && proxy.port) || 0
})
// Try attaching a native webRequest hook if pear-electron ever injects one.
try { sessionBridge.attachNativeSession() } catch {}
// anonGPT — page-side shim that exposes window.pear.anongpt.infer
// ONLY for the anonGPT drive AND only when that drive's manifest.json
// declares the required privacy claims. Gating lives in HyperProxy's
// _shouldInjectAnongptShim(). See backend/anongpt-buyer.js +
// anongpt/docs/spec/02-pearbrowser-dev-bridge.md.
//
// The buyer needs ServiceRegistry + ServiceProtocol (ESM) which the
// root index.js loaded statically and stashed on globalThis via
// pear-adapter.cjs. We can't import them here because backend/* is
// CJS and Bare/Pear's import() has no referrer from this context.
const anongptBuyer = new AnongptBuyer({
swarm,
identity,
services: globalThis._pearbrowserEsmModules || null
})
proxy.setAnongptShim(PEAR_ANONGPT_SHIM)
proxy.setAnongptDriveKey(C.ANONGPT_DRIVE_KEY)
aiService = globalThis._pearbrowserEsmModules?.aiService || null
// Mount direct HTTP bridge (WebView → localhost → Bare, bypasses RN relay)
const httpBridge = new HttpBridge(pearBridge, swarm, getDriveForProxy, {
validateToken: (token) => proxy ? proxy.validateApiToken(token) : null,
identity,
profile,
contacts,
swarmBridge,
anongptBuyer,
anongptDriveKey: C.ANONGPT_DRIVE_KEY,
aiService,
// Login ceremony plumbing — http-bridge calls requestLogin() when a
// page invokes pear.login(). We fire EVT_LOGIN_REQUEST up to the
// UI, which calls CMD_LOGIN_RESOLVE after the user decides. See
// the ceremony handler below.
requestLogin: (args) => openLoginCeremony(args),
})
proxy.setHttpBridge(httpBridge)
console.log('Starting HTTP proxy...')
rpc.event(C.EVT_BOOT_PROGRESS, { stage: 'proxy-start', message: 'Starting HTTP proxy...' })
const port = await proxy.start()
console.log('HTTP proxy started on port:', port)
rpc.event(C.EVT_BOOT_PROGRESS, { stage: 'proxy-ready', message: 'HTTP proxy ready on port ' + port })
// Tab runtime: the "run in a tab" path. Serves the headless-app wrapper +
// bridges each tab's WebSocket to a pear-request worker pipe. Best-effort —
// a failure here just means the in-tab path is unavailable, not a boot block.
try {
tabRuntime = new TabRuntime()
await tabRuntime.start()
} catch (err) {
console.error('[tab-runtime] failed to start:', err && err.message)
tabRuntime = null
}
// Start storage monitoring. Keep the handle so shutdown() can clear it —
// otherwise the timer keeps firing checkStorageQuota() against a closed
// store/swarm after teardown.
storageTimer = setInterval(() => checkStorageQuota(), STORAGE_CHECK_INTERVAL)
// Seed + load the schema-sheets demo catalogue only when explicitly requested.
// The release Apps path uses the curated Hyperbee/offline seed; this local
// demo room is useful for schema-sheets development but must not be able to
// destabilize a normal browser launch.
if (ENABLE_DEV_CATALOGUE) {
ensureDevCatalogue().catch((err) => console.error('[dev-catalogue]', err && err.message))
}
// Notify React Native
console.log('Sending READY event')
rpc.event(C.EVT_READY, { port })
bootResolve()
}
// DEV catalogue seed: create + seed a schema-sheets catalogue room locally on
// boot so the Apps store is populated end-to-end from the real sheets read path.
// The same instance is both the writer and the loaded view (registered directly,
// not reopened). Swap for loadCatalogSheets(relayZ32) when the relay ships one.
async function ensureDevCatalogue () {
const { SheetsCatalog } = require('./sheets-catalog')
const { SEED_APPS } = require('./catalogue-seed')
const sc = new SheetsCatalog(store, swarm)
await sc.open(null)
await sc.join('pearbrowser')
await sc.update()
// Reconcile the room to EXACTLY mirror SEED_APPS by CONTENT (not just identity)
// so field edits — newly-added inline icons, changed links — actually take
// effect, and stale/duplicate rows from older boots can't accrete. Earlier
// identity-only add/prune missed content changes (icon-less rows persisted).
// Gated on a content signature so a room that already matches is left untouched
// (no append churn); when it differs we wipe + reseed the whole set.
let existing = await sc.listApps()
const sig = JSON.stringify(SEED_APPS.map((a) => [
a.name || '', a.type || '', a.driveKey || '', a.link || '', a.iconData || '',
a.author || '', a.description || '', a.homepage || '', a.sourceUrl || '',
a.license || '', a.verification || '',
(Array.isArray(a.categories) ? a.categories : []).join(',')
]))
let storedSig = null
try { const s = await requireUserData().getSettings(); storedSig = s && s.devCatalogueSig } catch {}
if (storedSig === sig && existing.length === SEED_APPS.length) {
console.log('[dev-catalogue] up to date (' + existing.length + ' apps)')
} else {
for (const row of existing) { try { await sc.deleteApp(row.id) } catch {} }
if (existing.length) { await sc.update(); await new Promise((r) => setTimeout(r, 200)) }
const base = Date.now()
let added = 0
for (let i = 0; i < SEED_APPS.length; i++) {
const ts = base - (SEED_APPS.length - i) * 1000
try { await sc.addApp({ ...SEED_APPS[i], publishedAt: ts }, ts); added++ } catch (e) {
console.error('[dev-catalogue] seed', SEED_APPS[i].name, e && e.message)
}
}
await sc.update()
await new Promise((r) => setTimeout(r, 300))
try { await requireUserData().setSettings({ devCatalogueSig: sig }) } catch {}
console.log('[dev-catalogue] reseeded ' + added + '/' + SEED_APPS.length + ' apps (content changed)')
}
const link = await catalogManager.registerSheetsCatalog(sc, 'PearBrowser Apps')
console.log('[dev-catalogue] registered ' + (await sc.listApps()).length + ' apps; sheets://' + link)
try { await pinDriveBestEffort(sc.keyHex(), sc.discoveryKey()) } catch {}
}
async function shutdown () {
if (storageTimer) { clearInterval(storageTimer); storageTimer = null }
try { shieldListSync?.stop() } catch {}
try { await askBrowserService.close() } catch {}
if (aiService) { try { await aiService.close() } catch {}; aiService = null }
if (swarmBridge) { try { await swarmBridge.destroy() } catch {} swarmBridge = null }
if (proxy) { try { await proxy.stop() } catch {} proxy = null }
if (pearBridge) { try { await pearBridge.close() } catch {} pearBridge = null }
if (siteManager) { try { await siteManager.close() } catch {} siteManager = null }
if (appManager) { try { await appManager.close() } catch {} appManager = null }
if (catalogManager) { try { await catalogManager.close() } catch {} catalogManager = null }
if (personalIndex) { try { await personalIndex.close() } catch {} personalIndex = null }
if (browserSync) { try { await browserSync.close() } catch {} browserSync = null }
if (nameRegistry) { try { await nameRegistry.close() } catch {} nameRegistry = null }
for (const entry of _contactRegistries.values()) { try { await entry.reg.close() } catch {} }
_contactRegistries.clear()
if (nostrEventStore) { try { await nostrEventStore.close() } catch {} nostrEventStore = null }
for (const entry of _contactEventStores.values()) { try { await entry.store.close() } catch {} }
_contactEventStores.clear()
for (const [keyHex, entry] of browseDrives) {
unregisterHiveRelayDrive(keyHex, entry.drive)
try { await entry.drive.close() } catch {}
}
browseDrives.clear()
if (swarm) { try { await swarm.destroy() } catch {} swarm = null }
if (store) { try { await store.close() } catch {} store = null }
}
// Pin a drive to the HiveRelay backbone, best-effort. Never throws — a
// pin failure just means the drive stays locally P2P-seeded. Mirrors the
// seed() call in CMD_PUBLISH_SITE.
async function pinDriveBestEffort (keyHex, discoveryKey) {
try {
if (!keyHex || !hiveRelay || typeof hiveRelay.seed !== 'function') return
await hiveRelay.seed(keyHex, { replicas: 3, timeout: 10000, discoveryKey: discoveryKey || undefined })
} catch (err) {
console.error('[pin] best-effort pin failed:', err && err.message)
}
}
// --- Storage Management ---
async function checkStorageQuota () {
try {
const stats = await getStorageSize(storagePath)
console.log(`Storage usage: ${formatBytes(stats)} / ${formatBytes(STORAGE_LIMIT)}`)
if (shouldCleanupStorage(stats, { limit: STORAGE_LIMIT, threshold: EVICT_THRESHOLD })) {
console.log('Storage above threshold, running cleanup...')
await cleanupOldData()
}
} catch (err) {
console.error('Storage check failed:', err.message)
}
}
async function getStorageSize (dir) {
const fs = require('bare-fs')
const path = require('bare-path')
let total = 0
async function calcSize (currentPath) {
const entries = await fs.promises.readdir(currentPath)
for (const entry of entries) {
const fullPath = path.join(currentPath, entry)
const stat = await fs.promises.stat(fullPath)
if (stat.isDirectory()) {
await calcSize(fullPath)
} else {
total += stat.size
}
}
}
await calcSize(dir)
return total
}
function formatBytes (bytes) {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
async function cleanupOldData () {
const result = await cleanupBrowseStorage({
browseDrives,
proxy,
onEvict: (key) => console.log(`Evicting old browse drive: ${key.slice(0, 8)}...`),
unregisterDrive: unregisterHiveRelayDrive,
leaveDiscovery: (discoveryKey) => swarm?.leave(discoveryKey)
})
if (result.proxyCacheCleared) {
console.log('Cleared proxy cache')
}
// In future: could also prune old/unused app versions.
}
// --- Lifecycle ---
Bare.on('suspend', () => IPC.unref())
Bare.on('resume', () => IPC.ref())
// When the native host closes the IPC pipe (for example, when its window
// closes), run
// a clean shutdown so Corestore flushes, Hyperswarm closes, and
// rocksdb releases its LOCK. Without this the next launch trips on
// EADDRINUSE or a stale LOCK.
let shuttingDown = false
async function gracefulExit () {
if (shuttingDown) return
shuttingDown = true
try { await shutdown() } catch (err) { console.error('[shutdown]', err && err.message) }
try { Bare.exit?.() } catch {}
}
try { IPC.on('close', gracefulExit) } catch {}
try { IPC.on('end', gracefulExit) } catch {}
try { Bare.on?.('beforeExit', gracefulExit) } catch {}
// --- Start ---
console.log('Starting boot...')
boot().catch((err) => {
console.error('Boot failed:', err)
rpc.event(C.EVT_BOOT_PROGRESS, { stage: 'error', message: err.message, error: err.stack })
rpc.event(C.EVT_ERROR, { type: 'boot-error', message: err.message, stack: err.stack })
bootReject(err)
})