feat(theseus): a subdomain rule applies wherever the name is served from

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.
This commit is contained in:
Silent Mode 2026-09-28 01:24:08 +02:00
parent 27819684d5
commit 8d96e979fa

29
main.js
View file

@ -240,6 +240,27 @@ function dnsRecordsCached(name) {
const c = dnsRecordsCache.get(name); const c = dnsRecordsCache.get(name);
return c && Date.now() - c.at < DNS_RECORDS_TTL ? c.value : undefined; 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) { function fetchDnsRecords(name) {
const c = dnsRecordsCache.get(name); const c = dnsRecordsCache.get(name);
if (c?.pending) return c.pending; 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 } }); return new Response(body, { status: up.status, headers: { "content-type": ct } });
}; };
try { 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 (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.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) { if (r.s3) {