diff --git a/docs-site/docs/intro.md b/docs-site/docs/intro.md
index 09b178c..c7256ab 100644
--- a/docs-site/docs/intro.md
+++ b/docs-site/docs/intro.md
@@ -10,7 +10,7 @@ Welcome to the JobOps documentation. This site contains guides for setup, config
## Getting Started
-- **[Self-Hosting Guide](/docs/next/getting-started/self-hosting)**
+- **Self-Hosting Guide**
- Docker setup instructions
- Gmail OAuth configuration for email tracking
- Environment variables reference
diff --git a/docs-site/docusaurus.config.ts b/docs-site/docusaurus.config.ts
index 09c1a62..2a759c5 100644
--- a/docs-site/docusaurus.config.ts
+++ b/docs-site/docusaurus.config.ts
@@ -29,6 +29,7 @@ const normalizedBaseUrl = configuredBaseUrl.startsWith("/")
const siteBaseUrl = normalizedBaseUrl.endsWith("/")
? normalizedBaseUrl
: `${normalizedBaseUrl}/`;
+const docsBuildDemoMode = process.env.DEMO_MODE === "true";
const config: Config = {
title: "JobOps Documentation",
@@ -49,6 +50,16 @@ const config: Config = {
defaultLocale: "en",
locales: ["en"],
},
+ customFields: {
+ umami: {
+ docsBuildDemoMode,
+ defaultWebsiteId: "a3d08b50-443f-4d21-8ebb-9355ba67578b",
+ demoWebsiteId: "7956a54d-63f5-4528-af0f-f823dd421752",
+ proxyBasePath: "/stats",
+ upstreamOrigin: "https://umami.dakheera47.com",
+ standaloneDevPort: "3006",
+ },
+ },
presets: [
[
"classic",
@@ -113,12 +124,13 @@ const config: Config = {
{
type: "html",
value:
- 'Back to App',
+ 'Back to App',
position: "right",
},
{
- href: "https://github.com/DaKheera47/job-ops",
- label: "GitHub",
+ type: "html",
+ value:
+ 'GitHub',
position: "right",
},
],
diff --git a/docs-site/src/lib/umami.test.ts b/docs-site/src/lib/umami.test.ts
new file mode 100644
index 0000000..f88411e
--- /dev/null
+++ b/docs-site/src/lib/umami.test.ts
@@ -0,0 +1,95 @@
+import {
+ DEFAULT_DOCS_UMAMI_WEBSITE_ID,
+ DEMO_DOCS_UMAMI_WEBSITE_ID,
+ ensureDocsUmamiScript,
+ installDocsUmamiClickTracking,
+ resolveDocsUmamiConfig,
+} from "./umami";
+
+describe("docs umami", () => {
+ beforeEach(() => {
+ document.head.innerHTML = "";
+ document.body.innerHTML = "";
+ });
+
+ it("uses the direct umami script for standalone docs dev", () => {
+ const config = resolveDocsUmamiConfig({
+ demoMode: false,
+ location: {
+ hostname: "localhost",
+ port: "3006",
+ },
+ });
+
+ expect(config.websiteId).toBe(DEFAULT_DOCS_UMAMI_WEBSITE_ID);
+ expect(config.scriptSrc).toBe("https://umami.dakheera47.com/script.js");
+ expect(config.hostUrl).toBe("https://umami.dakheera47.com");
+ });
+
+ it("uses the proxy path and demo website id when demo mode is enabled", () => {
+ const config = resolveDocsUmamiConfig({
+ demoMode: true,
+ location: {
+ hostname: "jobops.dakheera47.com",
+ port: "",
+ },
+ });
+
+ expect(config.websiteId).toBe(DEMO_DOCS_UMAMI_WEBSITE_ID);
+ expect(config.scriptSrc).toBe("/stats/script.js");
+ expect(config.hostUrl).toBe("/stats");
+ });
+
+ it("injects the umami script once with the resolved attributes", () => {
+ ensureDocsUmamiScript({
+ config: {
+ demoMode: false,
+ scriptSrc: "/stats/script.js",
+ hostUrl: "/stats",
+ websiteId: DEFAULT_DOCS_UMAMI_WEBSITE_ID,
+ },
+ document,
+ });
+ ensureDocsUmamiScript({
+ config: {
+ demoMode: true,
+ scriptSrc: "/stats/script.js",
+ hostUrl: "/stats",
+ websiteId: DEMO_DOCS_UMAMI_WEBSITE_ID,
+ },
+ document,
+ });
+
+ const scripts = document.querySelectorAll(
+ 'script[data-jobops-umami="docs"]',
+ );
+ expect(scripts).toHaveLength(1);
+ expect(scripts[0]?.getAttribute("data-website-id")).toBe(
+ DEFAULT_DOCS_UMAMI_WEBSITE_ID,
+ );
+ expect(scripts[0]?.getAttribute("data-host-url")).toBe("/stats");
+ });
+
+ it("tracks custom docs CTA clicks", () => {
+ const track = vi.fn();
+ window.umami = { track };
+ const cleanup = installDocsUmamiClickTracking({
+ document,
+ windowObject: window,
+ });
+
+ const anchor = document.createElement("a");
+ anchor.dataset.umamiEvent = "docs_intro_self_hosting_click";
+ document.body.appendChild(anchor);
+
+ anchor.click();
+
+ expect(track).toHaveBeenCalledWith(
+ "docs_intro_self_hosting_click",
+ undefined,
+ );
+
+ cleanup();
+ delete window.umami;
+ });
+});
diff --git a/docs-site/src/lib/umami.ts b/docs-site/src/lib/umami.ts
new file mode 100644
index 0000000..a6757fb
--- /dev/null
+++ b/docs-site/src/lib/umami.ts
@@ -0,0 +1,136 @@
+export const DEFAULT_DOCS_UMAMI_WEBSITE_ID =
+ "a3d08b50-443f-4d21-8ebb-9355ba67578b";
+export const DEMO_DOCS_UMAMI_WEBSITE_ID =
+ "7956a54d-63f5-4528-af0f-f823dd421752";
+export const UMAMI_PROXY_BASE_PATH = "/stats";
+export const UMAMI_UPSTREAM_ORIGIN = "https://umami.dakheera47.com";
+export const DOCS_STANDALONE_DEV_PORT = "3006";
+const JOBOPS_UMAMI_SCRIPT_SELECTOR = 'script[data-jobops-umami="docs"]';
+
+export type DocsUmamiRuntimeConfig = {
+ demoMode: boolean;
+ scriptSrc: string;
+ hostUrl: string;
+ websiteId: string;
+};
+
+export type DocsUmamiSiteConfig = {
+ defaultWebsiteId?: string;
+ demoWebsiteId?: string;
+ docsBuildDemoMode?: boolean;
+ proxyBasePath?: string;
+ standaloneDevPort?: string;
+ upstreamOrigin?: string;
+};
+
+type LocationLike = Pick;
+
+type DocumentLike = Pick;
+
+type WindowLike = Window & {
+ umami?: {
+ track: (eventName: string, payload?: Record) => void;
+ };
+};
+
+export function isStandaloneDocsDev(
+ location: LocationLike,
+ standaloneDevPort = DOCS_STANDALONE_DEV_PORT,
+): boolean {
+ return (
+ location.hostname === "localhost" && location.port === standaloneDevPort
+ );
+}
+
+export function resolveDocsUmamiConfig(args: {
+ demoMode: boolean;
+ location: LocationLike;
+ siteConfig?: DocsUmamiSiteConfig;
+}): DocsUmamiRuntimeConfig {
+ const siteConfig = args.siteConfig ?? {};
+ const standaloneDevPort =
+ siteConfig.standaloneDevPort ?? DOCS_STANDALONE_DEV_PORT;
+ const proxyBasePath = siteConfig.proxyBasePath ?? UMAMI_PROXY_BASE_PATH;
+ const upstreamOrigin = siteConfig.upstreamOrigin ?? UMAMI_UPSTREAM_ORIGIN;
+ const useDirectScript = isStandaloneDocsDev(args.location, standaloneDevPort);
+
+ return {
+ demoMode: args.demoMode,
+ scriptSrc: useDirectScript
+ ? `${upstreamOrigin}/script.js`
+ : `${proxyBasePath}/script.js`,
+ hostUrl: useDirectScript ? upstreamOrigin : proxyBasePath,
+ websiteId: args.demoMode
+ ? (siteConfig.demoWebsiteId ?? DEMO_DOCS_UMAMI_WEBSITE_ID)
+ : (siteConfig.defaultWebsiteId ?? DEFAULT_DOCS_UMAMI_WEBSITE_ID),
+ };
+}
+
+export async function getDocsDemoMode(args: {
+ defaultDemoMode: boolean;
+ fetchImpl: typeof fetch;
+ shouldQueryApi: boolean;
+}): Promise {
+ if (!args.shouldQueryApi) {
+ return args.defaultDemoMode;
+ }
+
+ try {
+ const response = await args.fetchImpl("/api/demo/info", {
+ headers: {
+ Accept: "application/json",
+ },
+ });
+ if (!response.ok) return args.defaultDemoMode;
+ const body = (await response.json()) as {
+ ok?: boolean;
+ data?: { demoMode?: boolean };
+ };
+ if (body.ok !== true) return args.defaultDemoMode;
+ return body.data?.demoMode === true;
+ } catch {
+ return args.defaultDemoMode;
+ }
+}
+
+export function ensureDocsUmamiScript(args: {
+ config: DocsUmamiRuntimeConfig;
+ document: DocumentLike;
+}): void {
+ if (args.document.querySelector(JOBOPS_UMAMI_SCRIPT_SELECTOR)) return;
+
+ const script = args.document.createElement("script");
+ script.defer = true;
+ script.src = args.config.scriptSrc;
+ script.setAttribute("data-website-id", args.config.websiteId);
+ script.setAttribute("data-host-url", args.config.hostUrl);
+ script.setAttribute("data-jobops-umami", "docs");
+ args.document.head.appendChild(script);
+}
+
+export function trackDocsUmamiEvent(
+ windowObject: WindowLike,
+ eventName: string,
+ payload?: Record,
+): void {
+ windowObject.umami?.track(eventName, payload);
+}
+
+export function installDocsUmamiClickTracking(args: {
+ document: Document;
+ windowObject: WindowLike;
+}): () => void {
+ const handleClick = (event: MouseEvent) => {
+ const target = event.target;
+ if (!(target instanceof Element)) return;
+ const trackedElement = target.closest("[data-umami-event]");
+ const eventName = trackedElement?.dataset.umamiEvent;
+ if (!eventName) return;
+ trackDocsUmamiEvent(args.windowObject, eventName);
+ };
+
+ args.document.addEventListener("click", handleClick);
+ return () => {
+ args.document.removeEventListener("click", handleClick);
+ };
+}
diff --git a/docs-site/src/theme/Root.tsx b/docs-site/src/theme/Root.tsx
new file mode 100644
index 0000000..5f4f45a
--- /dev/null
+++ b/docs-site/src/theme/Root.tsx
@@ -0,0 +1,72 @@
+import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
+import type { ReactNode } from "react";
+import { useEffect } from "react";
+import {
+ type DocsUmamiSiteConfig,
+ ensureDocsUmamiScript,
+ getDocsDemoMode,
+ installDocsUmamiClickTracking,
+ isStandaloneDocsDev,
+ resolveDocsUmamiConfig,
+} from "../lib/umami";
+
+type RootProps = {
+ children: ReactNode;
+};
+
+type SiteConfigWithUmami = {
+ customFields?: {
+ umami?: DocsUmamiSiteConfig;
+ };
+};
+
+export default function Root({ children }: RootProps) {
+ const { siteConfig } = useDocusaurusContext();
+ const umamiConfig = (siteConfig as SiteConfigWithUmami).customFields?.umami;
+
+ useEffect(() => {
+ if (!umamiConfig) return;
+
+ const cleanupTracking = installDocsUmamiClickTracking({
+ document,
+ windowObject: window,
+ });
+
+ let cancelled = false;
+
+ const boot = async () => {
+ const defaultDemoMode = umamiConfig.docsBuildDemoMode === true;
+ const standaloneDocsDev = isStandaloneDocsDev(
+ window.location,
+ umamiConfig.standaloneDevPort,
+ );
+ const demoMode = standaloneDocsDev
+ ? defaultDemoMode
+ : await getDocsDemoMode({
+ defaultDemoMode,
+ fetchImpl: window.fetch.bind(window),
+ shouldQueryApi: !standaloneDocsDev,
+ });
+
+ if (cancelled) return;
+
+ ensureDocsUmamiScript({
+ config: resolveDocsUmamiConfig({
+ demoMode,
+ location: window.location,
+ siteConfig: umamiConfig,
+ }),
+ document,
+ });
+ };
+
+ void boot();
+
+ return () => {
+ cancelled = true;
+ cleanupTracking();
+ };
+ }, [umamiConfig]);
+
+ return <>{children}>;
+}
diff --git a/docs-site/src/types/umami.d.ts b/docs-site/src/types/umami.d.ts
new file mode 100644
index 0000000..5018997
--- /dev/null
+++ b/docs-site/src/types/umami.d.ts
@@ -0,0 +1,9 @@
+export {};
+
+declare global {
+ interface Window {
+ umami?: {
+ track: (eventName: string, payload?: Record) => void;
+ };
+ }
+}
diff --git a/orchestrator/src/server/api/routes/stats-proxy.test.ts b/orchestrator/src/server/api/routes/stats-proxy.test.ts
new file mode 100644
index 0000000..b5bc9cf
--- /dev/null
+++ b/orchestrator/src/server/api/routes/stats-proxy.test.ts
@@ -0,0 +1,228 @@
+import type { Server } from "node:http";
+import { gzipSync } from "node:zlib";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { startServer, stopServer } from "./test-utils";
+
+describe.sequential("Stats proxy routes", () => {
+ let server: Server;
+ let baseUrl: string;
+ let closeDb: () => void;
+ let tempDir: string;
+
+ beforeEach(async () => {
+ ({ server, baseUrl, closeDb, tempDir } = await startServer());
+ });
+
+ afterEach(async () => {
+ vi.unstubAllGlobals();
+ await stopServer({ server, closeDb, tempDir });
+ });
+
+ it("proxies the umami script through the first-party stats route", async () => {
+ const realFetch = global.fetch;
+ const mockFetch = vi.fn(
+ async (input: URL | RequestInfo, init?: RequestInit) => {
+ const url = typeof input === "string" ? input : input.toString();
+ if (url === "https://umami.dakheera47.com/script.js") {
+ expect(init?.method).toBe("GET");
+ return new Response(gzipSync("console.log('umami')"), {
+ status: 200,
+ headers: {
+ "content-type": "application/javascript; charset=utf-8",
+ "cache-control": "public, max-age=60",
+ "content-encoding": "gzip",
+ },
+ });
+ }
+ return realFetch(input, init);
+ },
+ );
+ vi.stubGlobal("fetch", mockFetch);
+
+ const response = await fetch(`${baseUrl}/stats/script.js`);
+
+ expect(response.status).toBe(200);
+ expect(response.headers.get("content-type")).toContain(
+ "application/javascript",
+ );
+ expect(response.headers.get("cache-control")).toBe("public, max-age=60");
+ expect(response.headers.get("content-encoding")).toBe("gzip");
+ expect(await response.text()).toContain("umami");
+ });
+
+ it("forwards tracking requests to the umami upstream", async () => {
+ const realFetch = global.fetch;
+ const mockFetch = vi.fn(
+ async (input: URL | RequestInfo, init?: RequestInit) => {
+ const url = typeof input === "string" ? input : input.toString();
+ if (url === "https://umami.dakheera47.com/api/send?foo=bar") {
+ expect(init?.method).toBe("POST");
+ expect(init?.headers).toBeInstanceOf(Headers);
+ const headers = init?.headers as Headers;
+ expect(headers.get("content-type")).toBe("application/json");
+ expect(headers.get("authorization")).toBeNull();
+ expect(headers.get("cookie")).toBeNull();
+ expect(headers.get("x-forwarded-for")).toBeNull();
+ const normalizedBody = init?.body
+ ? await new Response(init.body as BodyInit).text()
+ : "";
+ expect(normalizedBody).toBe('{"type":"event"}');
+ return new Response(null, { status: 202 });
+ }
+ return realFetch(input, init);
+ },
+ );
+ vi.stubGlobal("fetch", mockFetch);
+
+ const response = await fetch(`${baseUrl}/stats/api/send?foo=bar`, {
+ method: "POST",
+ headers: {
+ authorization: "Basic abc123",
+ cookie: "session=secret",
+ "content-type": "application/json",
+ "x-forwarded-for": "10.0.0.1",
+ },
+ body: JSON.stringify({ type: "event" }),
+ });
+
+ expect(response.status).toBe(202);
+ });
+
+ it("returns 404 for non-allowlisted stats routes", async () => {
+ const realFetch = global.fetch;
+ const mockFetch = vi.fn(
+ async (input: URL | RequestInfo, init?: RequestInit) =>
+ realFetch(input, init),
+ );
+ vi.stubGlobal("fetch", mockFetch);
+
+ const response = await fetch(`${baseUrl}/stats`);
+
+ expect(response.status).toBe(404);
+ expect(mockFetch).not.toHaveBeenCalledWith(
+ "https://umami.dakheera47.com/",
+ expect.anything(),
+ );
+ });
+
+ it("returns 405 for unsupported methods on allowlisted stats routes", async () => {
+ const realFetch = global.fetch;
+ const mockFetch = vi.fn(
+ async (input: URL | RequestInfo, init?: RequestInit) =>
+ realFetch(input, init),
+ );
+ vi.stubGlobal("fetch", mockFetch);
+
+ const response = await fetch(`${baseUrl}/stats/script.js`, {
+ method: "POST",
+ body: "unexpected",
+ });
+
+ expect(response.status).toBe(405);
+ expect(response.headers.get("allow")).toBe("GET, HEAD");
+ expect(await response.text()).toBe("Method not allowed");
+ expect(mockFetch).not.toHaveBeenCalledWith(
+ "https://umami.dakheera47.com/script.js",
+ expect.anything(),
+ );
+ });
+
+ it("returns a sanitized upstream failure response", async () => {
+ const realFetch = global.fetch;
+ const mockFetch = vi.fn(
+ async (input: URL | RequestInfo, init?: RequestInit) => {
+ const url = typeof input === "string" ? input : input.toString();
+ if (url === "https://umami.dakheera47.com/script.js") {
+ throw new Error("upstream down");
+ }
+ return realFetch(input, init);
+ },
+ );
+ vi.stubGlobal("fetch", mockFetch);
+
+ const response = await fetch(`${baseUrl}/stats/script.js`);
+
+ expect(response.status).toBe(502);
+ expect(await response.text()).toBe("Upstream error");
+ });
+
+ it("returns 504 when the umami upstream times out", async () => {
+ const realFetch = global.fetch;
+ const mockFetch = vi.fn(
+ async (input: URL | RequestInfo, init?: RequestInit) => {
+ const url = typeof input === "string" ? input : input.toString();
+ if (url === "https://umami.dakheera47.com/script.js") {
+ return await new Promise((_resolve, reject) => {
+ init?.signal?.addEventListener("abort", () => {
+ reject(
+ new DOMException("The operation timed out", "TimeoutError"),
+ );
+ });
+ });
+ }
+ return realFetch(input, init);
+ },
+ );
+ vi.stubGlobal("fetch", mockFetch);
+
+ const response = await fetch(`${baseUrl}/stats/script.js`);
+
+ expect(response.status).toBe(504);
+ expect(await response.text()).toBe("Upstream timeout");
+ });
+
+ it("allows stats proxy requests when basic auth is enabled", async () => {
+ await stopServer({ server, closeDb, tempDir });
+ ({ server, baseUrl, closeDb, tempDir } = await startServer({
+ env: {
+ BASIC_AUTH_USER: "admin",
+ BASIC_AUTH_PASSWORD: "secret",
+ },
+ }));
+
+ const realFetch = global.fetch;
+ const mockFetch = vi.fn(
+ async (input: URL | RequestInfo, init?: RequestInit) => {
+ const url = typeof input === "string" ? input : input.toString();
+ if (url === "https://umami.dakheera47.com/script.js") {
+ return new Response("ok", {
+ status: 200,
+ headers: { "content-type": "application/javascript" },
+ });
+ }
+ return realFetch(input, init);
+ },
+ );
+ vi.stubGlobal("fetch", mockFetch);
+
+ const response = await fetch(`${baseUrl}/stats/script.js`);
+
+ expect(response.status).toBe(200);
+ });
+
+ it("does not add CORS headers to stats proxy responses", async () => {
+ const realFetch = global.fetch;
+ const mockFetch = vi.fn(
+ async (input: URL | RequestInfo, init?: RequestInit) => {
+ const url = typeof input === "string" ? input : input.toString();
+ if (url === "https://umami.dakheera47.com/script.js") {
+ return new Response("ok", {
+ status: 200,
+ headers: { "content-type": "application/javascript" },
+ });
+ }
+ return realFetch(input, init);
+ },
+ );
+ vi.stubGlobal("fetch", mockFetch);
+
+ const response = await fetch(`${baseUrl}/stats/script.js`, {
+ headers: {
+ origin: "https://evil.example",
+ },
+ });
+
+ expect(response.status).toBe(200);
+ expect(response.headers.get("access-control-allow-origin")).toBeNull();
+ });
+});
diff --git a/orchestrator/src/server/app.ts b/orchestrator/src/server/app.ts
index 506ccbe..3b1b7f1 100644
--- a/orchestrator/src/server/app.ts
+++ b/orchestrator/src/server/app.ts
@@ -5,6 +5,9 @@
import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { dirname, extname, join } from "node:path";
+import { Readable } from "node:stream";
+import { pipeline } from "node:stream/promises";
+import type { ReadableStream as NodeReadableStream } from "node:stream/web";
import { fileURLToPath } from "node:url";
import { unauthorized } from "@infra/errors";
import {
@@ -15,6 +18,7 @@ import {
requestContextMiddleware,
} from "@infra/http";
import { logger } from "@infra/logger";
+import { sanitizeUnknown } from "@infra/sanitize";
import cors from "cors";
import express from "express";
import { apiRouter } from "./api/index";
@@ -23,6 +27,109 @@ import { isDemoMode } from "./config/demo";
import { resolveTracerRedirect } from "./services/tracer-links";
const __dirname = dirname(fileURLToPath(import.meta.url));
+const UMAMI_UPSTREAM_ORIGIN = "https://umami.dakheera47.com";
+const UMAMI_PROXY_TIMEOUT_MS = 5_000;
+const HOP_BY_HOP_RESPONSE_HEADERS = new Set([
+ "connection",
+ "content-length",
+ "keep-alive",
+ "proxy-authenticate",
+ "proxy-authorization",
+ "te",
+ "trailer",
+ "transfer-encoding",
+ "upgrade",
+]);
+const REQUEST_HEADERS_TO_SKIP = new Set([
+ "authorization",
+ "connection",
+ "content-length",
+ "cookie",
+ "host",
+ "transfer-encoding",
+ "x-forwarded-for",
+ "x-forwarded-host",
+ "x-forwarded-port",
+ "x-forwarded-proto",
+ "x-forwarded-server",
+]);
+const ALLOWED_UMAMI_PROXY_PATHS = new Set(["/script.js", "/api/send"]);
+const ALLOWED_UMAMI_PROXY_METHODS = new Map([
+ ["/script.js", ["GET", "HEAD"]],
+ ["/api/send", ["POST"]],
+]);
+
+function isStatsRoute(path: string): boolean {
+ return path === "/stats" || path.startsWith("/stats/");
+}
+
+function getUmamiUpstreamUrl(originalUrl: string): URL {
+ const incomingUrl = new URL(originalUrl, "http://localhost");
+ const upstreamUrl = new URL(UMAMI_UPSTREAM_ORIGIN);
+ upstreamUrl.pathname = incomingUrl.pathname.replace(/^\/stats/, "") || "/";
+ upstreamUrl.search = incomingUrl.search;
+ return upstreamUrl;
+}
+
+function isAllowedUmamiProxyPath(pathname: string): boolean {
+ return ALLOWED_UMAMI_PROXY_PATHS.has(pathname);
+}
+
+function getAllowedUmamiMethods(pathname: string): string[] {
+ return ALLOWED_UMAMI_PROXY_METHODS.get(pathname) ?? [];
+}
+
+function isAllowedUmamiMethod(method: string, pathname: string): boolean {
+ return getAllowedUmamiMethods(pathname).includes(method.toUpperCase());
+}
+
+function isUmamiProxyTimeoutError(error: unknown): boolean {
+ if (
+ typeof error === "object" &&
+ error !== null &&
+ "name" in error &&
+ (error.name === "AbortError" || error.name === "TimeoutError")
+ ) {
+ return true;
+ }
+ return (
+ error instanceof Error &&
+ (error.name === "AbortError" || error.name === "TimeoutError")
+ );
+}
+
+function buildUmamiProxyBody(req: express.Request): BodyInit | undefined {
+ if (req.method === "GET" || req.method === "HEAD") return undefined;
+ if (Buffer.isBuffer(req.body)) return new Uint8Array(req.body);
+ if (typeof req.body === "string") return req.body;
+ if (req.body === undefined || req.body === null) return undefined;
+ if (
+ typeof req.body === "object" &&
+ Object.keys(req.body as Record).length === 0
+ ) {
+ return undefined;
+ }
+ return JSON.stringify(req.body);
+}
+
+function copyUmamiResponseHeaders(
+ upstreamResponse: Response,
+ res: express.Response,
+): void {
+ for (const [key, value] of upstreamResponse.headers.entries()) {
+ if (HOP_BY_HOP_RESPONSE_HEADERS.has(key.toLowerCase())) continue;
+ res.setHeader(key, value);
+ }
+}
+
+function buildUmamiProxyHeaders(req: express.Request): Headers {
+ const headers = new Headers();
+ for (const [key, value] of Object.entries(req.headers)) {
+ if (!value || REQUEST_HEADERS_TO_SKIP.has(key.toLowerCase())) continue;
+ headers.set(key, Array.isArray(value) ? value.join(", ") : value);
+ }
+ return headers;
+}
export function createBasicAuthGuard() {
function getAuthConfig() {
@@ -67,6 +174,7 @@ export function createBasicAuthGuard() {
function requiresAuth(method: string, path: string): boolean {
if (isPublicReadOnlyRoute(method, path)) return false;
+ if (isStatsRoute(path)) return false;
if (path.startsWith("/api/tracer-links")) {
return method.toUpperCase() !== "OPTIONS";
}
@@ -94,6 +202,7 @@ export function createBasicAuthGuard() {
export function createApp() {
const app = express();
const authGuard = createBasicAuthGuard();
+ const corsMiddleware = cors();
const handleTracerRedirect = async (
req: express.Request,
@@ -139,8 +248,15 @@ export function createApp() {
}
};
- app.use(cors());
+ app.use((req, res, next) => {
+ if (isStatsRoute(req.path)) {
+ next();
+ return;
+ }
+ corsMiddleware(req, res, next);
+ });
app.use(requestContextMiddleware());
+ app.use("/stats", express.raw({ limit: "1mb", type: "*/*" }));
app.use(express.json({ limit: "5mb" }));
app.use(legacyApiResponseShim());
@@ -175,6 +291,77 @@ export function createApp() {
await handleTracerRedirect(req, res, slug, "GET /cv/:slug");
});
+ app.all(/^\/stats(?:\/.*)?$/, async (req, res) => {
+ const upstreamUrl = getUmamiUpstreamUrl(req.originalUrl);
+ if (!isAllowedUmamiProxyPath(upstreamUrl.pathname)) {
+ res.status(404).type("text/plain; charset=utf-8").send("Not found");
+ return;
+ }
+ if (!isAllowedUmamiMethod(req.method, upstreamUrl.pathname)) {
+ res
+ .setHeader(
+ "Allow",
+ getAllowedUmamiMethods(upstreamUrl.pathname).join(", "),
+ )
+ .status(405)
+ .type("text/plain; charset=utf-8")
+ .send("Method not allowed");
+ return;
+ }
+
+ try {
+ const upstreamResponse = await fetch(upstreamUrl, {
+ method: req.method,
+ headers: buildUmamiProxyHeaders(req),
+ body: buildUmamiProxyBody(req),
+ redirect: "manual",
+ signal: AbortSignal.timeout(UMAMI_PROXY_TIMEOUT_MS),
+ });
+
+ res.status(upstreamResponse.status);
+ copyUmamiResponseHeaders(upstreamResponse, res);
+
+ if (req.method === "HEAD") {
+ res.end();
+ return;
+ }
+ if (!upstreamResponse.body) {
+ res.end();
+ return;
+ }
+
+ await pipeline(
+ Readable.fromWeb(upstreamResponse.body as NodeReadableStream),
+ res,
+ );
+ } catch (error) {
+ if (isUmamiProxyTimeoutError(error)) {
+ logger.warn("Umami proxy timed out", {
+ route: req.path,
+ method: req.method,
+ upstreamUrl: upstreamUrl.toString(),
+ requestId:
+ (res.getHeader("x-request-id") as string | undefined) ?? undefined,
+ });
+ res
+ .status(504)
+ .type("text/plain; charset=utf-8")
+ .send("Upstream timeout");
+ return;
+ }
+
+ logger.error("Umami proxy failed", {
+ route: req.path,
+ method: req.method,
+ upstreamUrl: upstreamUrl.toString(),
+ requestId:
+ (res.getHeader("x-request-id") as string | undefined) ?? undefined,
+ error: sanitizeUnknown(error),
+ });
+ res.status(502).type("text/plain; charset=utf-8").send("Upstream error");
+ }
+ });
+
// Serve static files for generated PDFs
const pdfDir = join(getDataDir(), "pdfs");
if (isDemoMode()) {
diff --git a/orchestrator/vite.config.ts b/orchestrator/vite.config.ts
index 02a76af..8511852 100644
--- a/orchestrator/vite.config.ts
+++ b/orchestrator/vite.config.ts
@@ -43,6 +43,8 @@ export default defineConfig({
include: [
"src/**/*.test.ts",
"src/**/*.test.tsx",
+ "../docs-site/src/**/*.test.ts",
+ "../docs-site/src/**/*.test.tsx",
"../shared/src/**/*.test.ts",
"../extractors/**/tests/**/*.test.ts",
],
@@ -68,6 +70,10 @@ export default defineConfig({
target: "http://localhost:3001",
changeOrigin: true,
},
+ "/stats": {
+ target: "http://localhost:3001",
+ changeOrigin: true,
+ },
},
},
build: {