Theseus: built-in extensions can answer paths under a name they ship with

Pithos needs its full-page app at pithos.sia/<user>/<drive>/<folder>
instead of a loopback address. A new site-route capability lets a
built-in add-on declare names in its manifest (siteRoutes) and register
a handler for them; the bns:// handler asks it first for every path but
the root, and a handler that returns nothing hands the request back to
the name's own site. Community add-ons cannot use it, since answering
for a name is impersonating it.
This commit is contained in:
Local Dev 2026-10-04 03:29:30 +02:00
parent 556a22b062
commit bdc8f951c6
2 changed files with 60 additions and 3 deletions

View file

@ -84,6 +84,15 @@ const KNOWN_CAPABILITIES = new Set([
// declare "sidebar-panel" typically follow up with
// api.revealSidebar(panelId) to surface the result.
"context-menu-item",
// site-route: manifest.siteRoutes = ["pithos.sia"] + api.registerSiteRoute
// ({ host, handle }) — the add-on answers requests for paths
// under a BCNR name it ships with (Pithos serves its app at
// pithos.sia/<user>/<drive>/…). handle(request) gets the
// Fetch Request and returns a Response, or null for "not
// mine" (the name's own site then answers). The root page
// always stays the site's. Built-in add-ons only: answering
// for a name is impersonating it.
"site-route",
]);
// Chrome-style match pattern → predicate. "<scheme>://<host>/<path>" where
@ -205,6 +214,17 @@ function validateManifest(raw, folderName) {
// one. Each entry is validated as an id itself and gates vault.derive by
// (own id OR one of these) in makeApi below.
const absorbs = Array.isArray(m.absorbs) ? m.absorbs.map(String).filter(Boolean) : [];
// siteRoutes: BCNR names this add-on answers paths under (see site-route).
const siteRoutes = [];
if (capabilities.includes("site-route")) {
const list = Array.isArray(m.siteRoutes) ? m.siteRoutes : [];
for (const h of list) {
const host = String(h || "").trim().toLowerCase();
if (!/^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(host)) throw new Error(`addon "${id}": siteRoutes entry "${h}" is not a host name`);
siteRoutes.push(host);
}
if (!siteRoutes.length) throw new Error(`addon "${id}": capability "site-route" needs a non-empty "siteRoutes" list`);
}
for (const a of absorbs) {
if (!/^[a-z0-9][a-z0-9._-]{0,63}$/i.test(a)) {
throw new Error(`addon "${id}": absorbs entry "${a}" is not a valid add-on id`);
@ -250,9 +270,9 @@ function validateManifest(raw, folderName) {
let activationNote = "";
if (activation === "on-demand") {
if (capabilities.includes("request-filter")) { activation = "startup"; activationNote = "request-filter add-ons start at launch"; }
else if (!panels.length && !toolbarMenu && !contextMenuItems.length && !pageInject) { activation = "startup"; activationNote = "declares nothing that could start it"; }
else if (!panels.length && !toolbarMenu && !contextMenuItems.length && !pageInject && !siteRoutes.length) { activation = "startup"; activationNote = "declares nothing that could start it"; }
}
return { id, name, version, description, author, icon, main, capabilities, pageInject, toolbarMenu, contextMenuItems, absorbs, category, dock, panels, activation, activationNote };
return { id, name, version, description, author, icon, main, capabilities, pageInject, toolbarMenu, contextMenuItems, absorbs, category, dock, panels, siteRoutes, activation, activationNote };
}
// Loader singleton. `discoverAndActivate(opts)` returns a snapshot the rest
@ -513,6 +533,19 @@ class AddonHost {
return Promise.all([...this._dormant.keys()].map((id) => this.ensureActive(id, reason).catch(() => {})));
}
isDormant(id) { return this._dormant.has(id); }
// The site route answering paths under `host`, starting its add-on if it
// waits for first use; null when no built-in add-on claims the name.
async siteRouteFor(host) {
const h = String(host || "").toLowerCase();
for (const { manifest, dormant } of this._enabledEntries()) {
if (!(manifest.siteRoutes || []).includes(h)) continue;
if (this._isFirstPartyId && !this._isFirstPartyId(manifest.id)) continue;
if (dormant) { try { await this.ensureActive(manifest.id, `${h} opened`); } catch { continue; } }
const route = this._active.get(manifest.id)?.siteRoutes.get(h);
if (route) return route;
}
return null;
}
// Enabled = running or waiting for first use.
isEnabled(id) { return this._active.has(id) || this._dormant.has(id); }
@ -544,7 +577,7 @@ class AddonHost {
throw new Error(`main file must export an activate(api) function`);
}
// Declared panels are registered up front; activate() may still add more.
const active = { manifest, folder, exports: mod, sidebarPanels: this._resolvePanels(manifest, folder), handlers: new Map(), inject: null, tabListeners: [], ready: Promise.resolve() };
const active = { manifest, folder, exports: mod, sidebarPanels: this._resolvePanels(manifest, folder), handlers: new Map(), inject: null, tabListeners: [], siteRoutes: new Map(), ready: Promise.resolve() };
if (manifest.pageInject) {
// Read the inject source once at activation. It's shipped to every
// matching tab's preload verbatim, so a syntax error surfaces in the
@ -625,6 +658,16 @@ class AddonHost {
// a short emoji/glyph.
// `side: "left"` puts the panel in the quick-links strip on the left
// edge instead of the right sidebar (hosts without left panels ignore it).
// See "site-route" above. Registered per activation, so a reload or a
// disabled add-on drops it with the rest of its state.
registerSiteRoute: ({ host, handle } = {}) => {
if (!(manifest.capabilities || []).includes("site-route")) throw new Error(`registerSiteRoute requires the "site-route" capability`);
if (this._isFirstPartyId && !this._isFirstPartyId(manifest.id)) throw new Error("registerSiteRoute is reserved for built-in add-ons");
const h = String(host || "").toLowerCase();
if (!(manifest.siteRoutes || []).includes(h)) throw new Error(`registerSiteRoute: "${h}" is not in the manifest's siteRoutes`);
if (typeof handle !== "function") throw new Error("registerSiteRoute needs a handle(request) function");
active.siteRoutes.set(h, { addonId: manifest.id, handle });
},
registerSidebarPanel: ({ id, title, icon = manifest.icon, page, side }) => {
if (!id || !title || !page) throw new Error(`registerSidebarPanel needs {id, title, page}`);
const abs = path.join(folder, String(page).replace(/^[\\/]/, ""));

14
main.js
View file

@ -1973,6 +1973,20 @@ async function serveBns(request) {
const rawPath = url.pathname || "/";
let reqPath; try { reqPath = decodeURIComponent(rawPath); } catch { reqPath = rawPath; }
// A built-in extension may answer paths under a name it ships with (Pithos
// serves its app at pithos.sia/<user>/<drive>/…). The root page stays the
// name's own site, and whatever the extension does not claim falls through
// to the normal lookup below.
if (rawPath !== "/" && addonHost?.siteRouteFor) {
try {
const route = await addonHost.siteRouteFor(host);
if (route) {
const out = await route.handle(request);
if (out) return out;
}
} catch (e) { console.warn(`[site-route] ${host}${rawPath}: ${e?.message || e}`); }
}
// (bns://collision-choose/ is handled by the will-navigate listener attached
// to each tab — it fires BEFORE the request reaches this protocol handler.)