Skip to the content

Capsid menu

src/portal-host.ts

66 lines and 6 functions in DrDustinEdwards/capsid, at commit daa875b on 2026-10-24. This file on GitHub.

Functions

The functions of portal-host.ts
FunctionLinesComplexityCoveredCRAPMutation
isPortalHost25 to 271100%OkCRAP 1No data: mutation testing has not run on this repo yet
portalBase30 to 322100%OkCRAP 2No data: mutation testing has not run on this repo yet
portalCookiePath36 to 382100%OkCRAP 2No data: mutation testing has not run on this repo yet
publicPortalPath41 to 454100%OkCRAP 4No data: mutation testing has not run on this repo yet
isSameOriginPath49 to 513100%OkCRAP 3No data: mutation testing has not run on this repo yet
portalHostRequest55 to 748100%WatchCRAP 8No data: mutation testing has not run on this repo yet

Imports

It imports no other module of this repo. Imported by src/digest.ts, src/index.ts, src/ops-feed.ts, src/portal-actions.ts, src/portal-app.ts, src/portal-auth.ts, src/push.ts, src/routes.ts.

Source

// Capsid Portal on its own host ("one address pattern for the family"):
// https://portal.dustinedwards.info, a Custom Domain on this Worker. It is a
// separate origin from every public site, and its cookies are host-only.
//
// The Portal's routes are written once, under /portal (src/routes.ts). On the Portal host a
// request for /x is handled as /portal/x, so every route, the gate and every check apply
// unchanged; the workers.dev /portal keeps answering as before until the new address is
// verified live, and is then retired by a follow-up PR. Two exceptions on the Portal host:
//   - a browser asking for /portal/... is sent to the same path at the root (308), so an
//     old link lands on the new address;
//   - /portal/assets/... is served as it is, not redirected. The built app names its
//     hashed files by that absolute path (dashboard/vite.config.ts base), on both hosts.
// Nothing else is reachable on the Portal host: /mcp, /health and the /ops routes are on
// mcp.dustinedwards.info (src/mcp-host.ts), and on this host they are Portal paths behind
// the gate.

// The same values as portal-auth's PORTAL_PATH and PORTAL_PREFIX, stated here rather than
// imported, because portal-auth imports this module.
const PORTAL_PATH = "/portal";
const PORTAL_PREFIX = "/portal/";

export const PORTAL_HOST = "portal.dustinedwards.info";
const PORTAL_ASSETS_PREFIX = "/portal/assets/";

export function isPortalHost(url: URL): boolean {
  return url.hostname === PORTAL_HOST;
}

/** The Portal's path as the browser sees it: "" on the Portal host, "/portal" elsewhere. */
export function portalBase(url: URL): string {
  return isPortalHost(url) ? "" : PORTAL_PATH;
}

/** Where the Portal's cookies are scoped: the whole Portal host, or /portal elsewhere.
 *  Never a Domain attribute, so no other subdomain of the family can read or set them. */
export function portalCookiePath(url: URL): string {
  return isPortalHost(url) ? "/" : PORTAL_PATH;
}

/** An internal Portal path (/portal, /portal/x) as the browser on this host names it. */
export function publicPortalPath(url: URL, internal: string): string {
  if (!isPortalHost(url)) return internal;
  if (internal === PORTAL_PATH) return "/";
  return internal.startsWith(PORTAL_PREFIX) ? internal.slice(PORTAL_PATH.length) : internal;
}

/** A path the browser may be sent to on this origin: one leading slash and no backslash.
 *  "//host/x" and "/\\host/x" are protocol-relative to a browser and would leave the origin. */
export function isSameOriginPath(path: string): boolean {
  return path.startsWith("/") && !path.startsWith("//") && !path.includes("\\");
}

/** On the Portal host: a redirect for an old /portal path, or the request rewritten to its
 *  internal /portal path. Anywhere else: the request unchanged. */
export function portalHostRequest(request: Request): { redirect: Response } | { request: Request } {
  const url = new URL(request.url);
  if (!isPortalHost(url)) return { request };
  const path = url.pathname;
  const underPortal = path === PORTAL_PATH || path.startsWith(PORTAL_PREFIX);
  if (underPortal && !path.startsWith(PORTAL_ASSETS_PREFIX)) {
    const publicPath = publicPortalPath(url, path);
    // /portal//attacker.example would otherwise become a 308 to https://attacker.example/,
    // carrying an OAuth code or a phishing visitor off the host (Grok audit 2026-10, C1).
    if (!isSameOriginPath(publicPath)) return { redirect: new Response("Not found", { status: 404 }) };
    const target = new URL(publicPath + url.search, url.origin);
    // 308 keeps the method and body, so a stray POST to an old address is not turned
    // into a GET.
    return { redirect: new Response(null, { status: 308, headers: { Location: target.toString(), "Cache-Control": "no-store" } }) };
  }
  if (underPortal) return { request };
  const inner = new URL(url);
  inner.pathname = path === "/" ? PORTAL_PREFIX : PORTAL_PATH + path;
  return { request: new Request(inner.toString(), request) };
}