marmar-context / web / context / server / hosting.ts
hosting.ts
Raw
import { ModelError } from "./model-runtime";
import type { IncomingHttpHeaders } from "node:http";

export function hostingConfig(env: NodeJS.ProcessEnv = process.env) {
  const hosted = env.CONTEXT_HOSTED === "true";
  const port = Number(env.PORT || env.CONTEXT_PORT || 4191);
  if (!Number.isInteger(port) || port < 1 || port > 65535)
    throw new Error("Invalid server port.");
  const origins = (env.CONTEXT_ALLOWED_ORIGINS || "")
    .split(",")
    .map((x) => x.trim())
    .filter(Boolean);
  const reviewers = (env.CONTEXT_REVIEWER_IDS || "")
    .split(",")
    .map((x) => x.trim())
    .filter(Boolean);
  if (hosted) {
    if (
      env.CONTEXT_AUTH_PROVIDER !== "azure-easyauth" ||
      !env.WEBSITE_SITE_NAME ||
      !reviewers.length ||
      !origins.length
    )
      throw new Error(
        "Hosted preview requires Azure App Service authentication, reviewer IDs and explicit HTTPS origins."
      );
    for (const origin of origins) {
      const url = new URL(origin);
      if (
        url.origin !== origin ||
        url.protocol !== "https:" ||
        url.username ||
        url.password
      )
        throw new Error("Hosted origins must be exact HTTPS origins.");
    }
    if (!reviewers.every((id) => /^[a-f0-9-]{36}$/i.test(id)))
      throw new Error("Reviewer IDs must be Azure principal IDs.");
  }
  return {
    hosted,
    port,
    origins,
    reviewers,
    bind: hosted ? "0.0.0.0" : "127.0.0.1",
  };
}
export type HostingConfig = ReturnType<typeof hostingConfig>;
export function requestOwner(
  config: HostingConfig,
  headers: IncomingHttpHeaders
) {
  const host = headers.host || "";
  const origin = headers.origin;
  if (!config.hosted) {
    const allowed = new RegExp(
      `^(127\\.0\\.0\\.1|localhost):(?:${config.port}|4190)$`
    );
    if (!allowed.test(host)) throw new ModelError("Local demo only.", 403);
    if (
      origin &&
      (!origin.startsWith("http://") || !allowed.test(origin.slice(7)))
    )
      throw new ModelError("Origin is not allowed.", 403);
    return "local";
  }
  if (
    !config.origins.some((value) => new URL(value).host === host) ||
    (origin && !config.origins.includes(origin))
  )
    throw new ModelError("Origin or host is not allowed.", 403);
  // Azure App Service Authentication must be enabled with authentication required.
  // App Service injects these headers; never expose this process outside its proxy.
  const principal = headers["x-ms-client-principal-id"];
  if (typeof principal !== "string" || !config.reviewers.includes(principal))
    throw new ModelError(
      "This preview is restricted to approved reviewers. Sign in with your invited account.",
      401
    );
  return principal;
}
export function createPreviewLimiter() {
  const windows = new Map<
    string,
    { minute: number; count: number; day: number; daily: number }
  >();
  return (owner: string, now = Date.now()) => {
    const minute = Math.floor(now / 60_000),
      day = Math.floor(now / 86_400_000);
    for (const [id, item] of windows) if (item.day !== day) windows.delete(id);
    const value = windows.get(owner) || { minute, count: 0, day, daily: 0 };
    if (value.minute !== minute) {
      value.minute = minute;
      value.count = 0;
    }
    if (value.count >= 6 || value.daily >= 100)
      throw new ModelError(
        "Preview extraction limit reached. Your saved work is unchanged. Try again later.",
        429,
        value.count >= 6 ? 60 : 3600
      );
    value.count++;
    value.daily++;
    windows.set(owner, value);
  };
}