From 8d96e979fad892b7b9d334d7a1a7a8c639f9c8c3 Mon Sep 17 00:00:00 2001 From: Silent Mode Date: Mon, 28 Sep 2026 01:24:08 +0200 Subject: [PATCH] feat(theseus): a subdomain rule applies wherever the name is served from MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gateway checks a name's host rules before it decides what to serve, so a blocked or redirected subdomain behaves the same whatever record the name carries. Theseus only inherited that for names it proxies through the gateway's /bns/ mount. A name with both s3 and ip — the shape that caused the 2026-08-13 subdomain bug — would have had its blocked subdomain answer anyway, because Theseus talks straight to the IP. It now asks the gateway for the host's verified rule before taking either of the paths it serves itself, and only for those paths, so an ordinary subdomain navigation gains no round trip. Verification stays in one place: the client reads a decision, it does not re-derive one. The spec catches up with what is implemented — it still described v1 and called hosts a future idea. --- main.js | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/main.js b/main.js index ba830e1b..3543f9af 100644 --- a/main.js +++ b/main.js @@ -240,6 +240,27 @@ function dnsRecordsCached(name) { const c = dnsRecordsCache.get(name); return c && Date.now() - c.at < DNS_RECORDS_TTL ? c.value : undefined; } +// The routing half of a name's signed manifest for ONE host, verified by the +// gateway (which already holds that code) and cached per host. Only consulted +// for a subdomain that Theseus serves itself — an `ip` or inline `h` record — +// because anything going through the gateway's /bns/ mount already had the +// rule applied there. KEEP IN STEP with public-gateway.mjs serve(). +const hostActionCache = new Map(); +async function fetchHostAction(host) { + const c = hostActionCache.get(host); + if (c && Date.now() - c.at < DNS_RECORDS_TTL) return c.value; + let value = null; + try { + const r = await fetch(`${GATEWAY}/api/dns/${encodeURIComponent(host)}`, { signal: AbortSignal.timeout(3000), cache: "no-store" }); + if (r.ok) { + const j = await r.json(); + const a = j?.host_action; + if (a && typeof a === "object" && typeof a.kind === "string") value = a; + } + } catch { /* offline or no manifest — the name keeps its chain behaviour */ } + hostActionCache.set(host, { value, at: Date.now() }); + return value; +} function fetchDnsRecords(name) { const c = dnsRecordsCache.get(name); if (c?.pending) return c.pending; @@ -1696,6 +1717,14 @@ async function serveBns(request) { return new Response(body, { status: up.status, headers: { "content-type": ct } }); }; try { + // A subdomain the owner has ruled on: blocked, or sent elsewhere. The + // gateway applies this for anything it serves, so this only covers the + // paths Theseus takes on its own — `ip` and inline `h`. + if (isSubdomain && (r.ip || r.h)) { + const act = await fetchHostAction(host); + if (act?.kind === "block") return new Response("not found", { status: 404 }); + if (act?.kind === "redirect" && act.url) return Response.redirect(act.url, 302); + } if (isSubdomain && r.ip) return await serveIp(); if (r.h) { if (reqPath === "/") return new Response(r.h, { headers: { "content-type": "text/html; charset=utf-8" } }); return new Response("not found", { status: 404 }); } if (r.s3) {