Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/host/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"test:perf:base": "PERF_RUNS=20 PERF_SAVE_BASE=1 PERF_DOMAIN_A=explore PERF_DOMAIN_B=host-playground npx playwright test --config=tests/performance/playwright.config.ts tests/performance/cold-start.spec.ts",
"test:perf:compare": "bun tests/performance/compare.ts",
"test:perf:compare:md": "bun tests/performance/compare.ts --markdown",
"test": "vitest run",
"lint": "bunx eslint src/",
"typecheck": "tsc --noEmit"
},
Expand All @@ -23,11 +24,13 @@
"playwright": "^1.59.1",
"@types/node": "^25.9.3",
"eslint": "^10.5.0",
"happy-dom": "^20.10.3",
"jsqr": "^1.4.0",
"prettier": "^3.8.3",
"simple-statistics": "^7.9.0",
"typescript": "~6.0.3",
"vite": "^8.0.16",
"vitest": "^4.1.8",
"vite-plugin-pwa": "^1.2.0",
"vite-plugin-wasm": "^3.5.0",
"workbox-window": "^7.4.0"
Expand Down
87 changes: 87 additions & 0 deletions apps/host/src/host-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// Copyright 2026 Parity Technologies (UK) Ltd.
// SPDX-License-Identifier: AGPL-3.0-only

// URL/label parsing helpers for the host shell. Kept separate from the
// main entry wiring so the parsing logic can be unit-tested in isolation.

import { BASE_DOMAIN, DEBUG } from "@dotli/config/config";
import { isValidDotLabel } from "@dotli/shared/html";

export function parseLocalhostUrl(): string | null {
if (!DEBUG) {
return null;
}
const path = window.location.pathname;
const match = /^\/(localhost(?::\d+)?)(.*)$/.exec(path);
if (match === null) {
return null;
}
const host = match[1];
const rest = match[2] || "";
// Strip every reserved host-URL param so they do not leak into the
// proxied product. Covers the five settings axes, the sandbox contract's
// host-only signals (`fullReset`, `v`), and the Playwright auth hook.
const productSearch = new URLSearchParams(window.location.search);
for (const k of RESERVED_HOST_PARAMS) {
productSearch.delete(k);
}
const query = productSearch.toString();
return `http://${host}${rest}${query ? `?${query}` : ""}${window.location.hash}`;
}

const RESERVED_HOST_PARAMS = [
"network",
"chainBackend",
"skipArchiveCache",
"skipCidCache",
"skipWorkerCache",
"fullReset",
"v",
"initAuthSubscribe",
] as const;

/**
* Extract the `.dot` label from the current hostname.
*
* Returns `"myapp"` for `myapp.dot.li` or `myapp.localhost`. Returns `null`
* for the bare landing pages (`dot.li`, `localhost`) and for sandbox origins
* (`*.app.dot.li`, `*.app.localhost`), which are handled by `app-main.ts`.
*
* The parsed label is validated against the closed `.dot` label charset as
* defense-in-depth before it is threaded into key derivation, origin
* construction (`<label>.app.<root>`), and host-shell sinks. A malformed
* label can never be a registered `.dot` name, so returning `null` (which
* routes to the landing/preview path) is the safe outcome.
*/
export function parseDotLabel(): string | null {
const hostname = window.location.hostname;

// Production: name.{BASE_DOMAIN} (but NOT *.app.{BASE_DOMAIN})
if (hostname.endsWith(`.${BASE_DOMAIN}`)) {
if (hostname.endsWith(`.app.${BASE_DOMAIN}`)) {
return null;
}
const label = hostname.slice(0, -(BASE_DOMAIN.length + 1));
return isValidDotLabel(label) ? label : null;
}

// Local dev: name.localhost (but NOT *.app.localhost)
if (hostname.endsWith(".localhost")) {
if (hostname.endsWith(".app.localhost")) {
return null;
}
const label = hostname.slice(0, -".localhost".length);
return isValidDotLabel(label) ? label : null;
}

return null;
}

export function hexToBytes(s: string): Uint8Array {
const h = s.startsWith("0x") ? s.slice(2) : s;
const bytes = new Uint8Array(h.length / 2);
for (let i = 0; i < bytes.length; i++) {
bytes[i] = parseInt(h.slice(i * 2, i * 2 + 2), 16);
}
return bytes;
}
84 changes: 3 additions & 81 deletions apps/host/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,10 @@ import type {
ManifestResult,
RootManifest,
} from "@dotli/resolver/manifest";
import { BASE_DOMAIN, DEBUG, SITE_ID } from "@dotli/config/config";
import { DEBUG, SITE_ID } from "@dotli/config/config";
import { log } from "@dotli/shared/log";
import { serializeError } from "@dotli/shared/errors";
import { escapeHtml, isValidDotLabel } from "@dotli/shared/html";
import { escapeHtml } from "@dotli/shared/html";
import { isMobileDevice } from "@dotli/shared/device";
import { showNotification } from "@dotli/ui/notification";
import { initScheduledNotifications } from "@dotli/ui/scheduled-notifications";
Expand All @@ -95,6 +95,7 @@ import {
REFRESH_BTN_LABEL,
} from "./errors";
import { parsePreviewTargetUrl } from "./preview-route";
import { hexToBytes, parseDotLabel, parseLocalhostUrl } from "./host-url";

// Surface chunk-load failures explicitly: capture the original cause to
// Sentry and let the user opt into a reload, instead of reloading silently.
Expand Down Expand Up @@ -181,76 +182,6 @@ const T0 = performance.now();
* "/localhost" yields "http://localhost"
* "/starter-template.dot" yields null (not a localhost URL)
*/
function parseLocalhostUrl(): string | null {
if (!DEBUG) {
return null;
}
const path = window.location.pathname;
const match = /^\/(localhost(?::\d+)?)(.*)$/.exec(path);
if (match === null) {
return null;
}
const host = match[1];
const rest = match[2] || "";
// Strip every reserved host-URL param so they do not leak into the
// proxied product. Covers the five settings axes, the sandbox contract's
// host-only signals (`fullReset`, `v`), and the Playwright auth hook.
const productSearch = new URLSearchParams(window.location.search);
for (const k of RESERVED_HOST_PARAMS) {
productSearch.delete(k);
}
const query = productSearch.toString();
return `http://${host}${rest}${query ? `?${query}` : ""}${window.location.hash}`;
}

const RESERVED_HOST_PARAMS = [
"network",
"chainBackend",
"skipArchiveCache",
"skipCidCache",
"skipWorkerCache",
"fullReset",
"v",
"initAuthSubscribe",
] as const;

/**
* Extract the `.dot` label from the current hostname.
*
* Returns `"myapp"` for `myapp.dot.li` or `myapp.localhost`. Returns `null`
* for the bare landing pages (`dot.li`, `localhost`) and for sandbox origins
* (`*.app.dot.li`, `*.app.localhost`), which are handled by `app-main.ts`.
*
* The parsed label is validated against the closed `.dot` label charset as
* defense-in-depth before it is threaded into key derivation, origin
* construction (`<label>.app.<root>`), and host-shell sinks. A malformed
* label can never be a registered `.dot` name, so returning `null` (which
* routes to the landing/preview path) is the safe outcome.
*/
function parseDotLabel(): string | null {
const hostname = window.location.hostname;

// Production: name.{BASE_DOMAIN} (but NOT *.app.{BASE_DOMAIN})
if (hostname.endsWith(`.${BASE_DOMAIN}`)) {
if (hostname.endsWith(`.app.${BASE_DOMAIN}`)) {
return null;
}
const label = hostname.slice(0, -(BASE_DOMAIN.length + 1));
return isValidDotLabel(label) ? label : null;
}

// Local dev: name.localhost (but NOT *.app.localhost)
if (hostname.endsWith(".localhost")) {
if (hostname.endsWith(".app.localhost")) {
return null;
}
const label = hostname.slice(0, -".localhost".length);
return isValidDotLabel(label) ? label : null;
}

return null;
}

/**
* Set the verification shield state in the URL pill.
*
Expand Down Expand Up @@ -691,15 +622,6 @@ interface AuthSubscribeApi {
) => Promise<{ count: number; isComplete: boolean }>;
}

function hexToBytes(s: string): Uint8Array {
const h = s.startsWith("0x") ? s.slice(2) : s;
const bytes = new Uint8Array(h.length / 2);
for (let i = 0; i < bytes.length; i++) {
bytes[i] = parseInt(h.slice(i * 2, i * 2 + 2), 16);
}
return bytes;
}

async function runAuthSubscribeHook(chainBackend: string): Promise<void> {
log.warn("[dot.li auth-subscribe] init auth + statement store");
const authMod = await import("@dotli/auth/auth");
Expand Down
105 changes: 105 additions & 0 deletions apps/host/tests/unit/host-url.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
// Copyright 2026 Parity Technologies (UK) Ltd.
// SPDX-License-Identifier: AGPL-3.0-only

import { afterEach, describe, expect, it } from "vitest";
import {
hexToBytes,
parseDotLabel,
parseLocalhostUrl,
} from "../../src/host-url.ts";

// host-url reads window.location live, so each test stubs it. BASE_DOMAIN is
// derived once at config import time from happy-dom's default localhost
// origin, so it stays "dot.li" regardless of these per-test stubs.
const realLocation = window.location;

function setLocation(parts: {
hostname?: string;
pathname?: string;
search?: string;
hash?: string;
}): void {
Object.defineProperty(window, "location", {
value: {
hostname: "localhost",
pathname: "/",
search: "",
hash: "",
...parts,
},
writable: true,
configurable: true,
});
}

afterEach(() => {
Object.defineProperty(window, "location", {
value: realLocation,
writable: true,
configurable: true,
});
});

describe("hexToBytes", () => {
it("decodes a 0x-prefixed hex string", () => {
expect(Array.from(hexToBytes("0x010203"))).toEqual([1, 2, 3]);
});

it("decodes a bare hex string", () => {
expect(Array.from(hexToBytes("ff00ab"))).toEqual([255, 0, 171]);
});

it("returns an empty array for an empty input", () => {
expect(Array.from(hexToBytes("0x"))).toEqual([]);
expect(Array.from(hexToBytes(""))).toEqual([]);
});
});

describe("parseDotLabel", () => {
it("extracts the label from a production .dot.li host", () => {
setLocation({ hostname: "myapp.dot.li" });
expect(parseDotLabel()).toBe("myapp");
});

it("extracts the label from a .localhost host", () => {
setLocation({ hostname: "myapp.localhost" });
expect(parseDotLabel()).toBe("myapp");
});

it("returns null for sandbox app subdomains", () => {
setLocation({ hostname: "myapp.app.dot.li" });
expect(parseDotLabel()).toBeNull();
setLocation({ hostname: "myapp.app.localhost" });
expect(parseDotLabel()).toBeNull();
});

it("returns null for the bare landing pages", () => {
setLocation({ hostname: "dot.li" });
expect(parseDotLabel()).toBeNull();
setLocation({ hostname: "localhost" });
expect(parseDotLabel()).toBeNull();
});

it("returns null for a structurally invalid label (uppercase)", () => {
setLocation({ hostname: "MyApp.dot.li" });
expect(parseDotLabel()).toBeNull();
});
});

describe("parseLocalhostUrl", () => {
it("rebuilds the proxied URL and strips reserved host params", () => {
setLocation({
pathname: "/localhost:5173/app",
search: "?network=foo&keep=1",
hash: "#section",
});
expect(parseLocalhostUrl()).toBe(
"http://localhost:5173/app?keep=1#section",
);
});

it("returns null when the path is not a localhost proxy path", () => {
setLocation({ pathname: "/some/other/path" });
expect(parseLocalhostUrl()).toBeNull();
});
});
21 changes: 21 additions & 0 deletions apps/host/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Copyright 2026 Parity Technologies (UK) Ltd.
// SPDX-License-Identifier: AGPL-3.0-only

import { defineConfig } from "vitest/config";

export default defineConfig({
test: {
// Scope strictly to the unit-test folder so the Playwright `.spec.ts`
// suites under tests/{functional,e2e,performance} are never picked up.
include: ["tests/unit/**/*.test.ts"],
environment: "happy-dom",
globals: false,
},
define: {
"import.meta.env.DEV": "false",
"import.meta.env.VITE_APP_DEBUG": '"true"',
// getEnabledNetworks() requires VITE_NETWORKS (no default by design); the
// test build supplies it the same way a deployment does.
"import.meta.env.VITE_NETWORKS": '"paseo-next-v2,previewnet"',
},
});
2 changes: 2 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading