Skip to the content

Capsid menu

src/server.ts

97 lines and 9 functions in DrDustinEdwards/site-api, at commit d3b340f on 2026-10-24. This file on GitHub.

Functions

The functions of server.ts
FunctionLinesComplexityCoveredCRAPMutation
insidePrefix37 to 392100%OkCRAP 2Ok87.5%
bearer41 to 452100%OkCRAP 2Watch63.6%
createSiteApi47 to 1156100%WatchCRAP 6No data: changed since the last mutation run
<anonymous:49>49 to 491100%OkCRAP 1No data: changed since the last mutation run
handle63 to 10917100%WatchCRAP 17No data: changed since the last mutation run
<anonymous:94>94 to 941100%OkCRAP 1No data: changed since the last mutation run
<anonymous:95>95 to 951100%OkCRAP 1No data: changed since the last mutation run
<anonymous:97>97 to 971100%OkCRAP 1No data: changed since the last mutation run
matches112 to 1121100%OkCRAP 1Ok100%

Imports

It imports src/adapter.ts, src/contract.ts, src/server/content.ts, src/server/http.ts, src/server/media.ts, src/server/mentions.ts. Imported by src/index.ts, src/local.ts.

Source

// The site side: one handler for everything under /api/carrel/v1.
//
// Order is the guard: prefix, then rate limit, then key, then route. A request outside the prefix
// is never served, whatever key it carries; a request without the key learns nothing about routes.

import { safeEqual } from "@dustinedwards/site-runtime/compare";

import type { SiteAdapter } from "./adapter.js";
import { PREFIX } from "./contract.js";
import { contentRoutes } from "./server/content.js";
import { describeFailure, fail, type Route } from "./server/http.js";
import { mediaRoutes } from "./server/media.js";
import { mentionsRoutes } from "./server/mentions.js";

/** The shape of Cloudflare's `ratelimit` binding, so a site passes its binding straight in. */
export interface RateLimiter {
  limit(options: { key: string }): Promise<{ success: boolean }>;
}

export interface SiteApiConfig {
  adapter: SiteAdapter;
  /** The site's Carrel key, from its secrets. Shorter than 32 characters refuses every request. */
  key: string | undefined;
  limiter: RateLimiter;
  /** Where refusals and adapter failures are logged. Defaults to console. */
  log?: (entry: Record<string, unknown>) => void;
}

export interface SiteApi {
  /** True when the request's path is inside the prefix. Mount the handler only where this holds. */
  matches(request: Request): boolean;
  handle(request: Request): Promise<Response>;
}

export const MIN_KEY_LENGTH = 32;

export function insidePrefix(pathname: string): boolean {
  return pathname === PREFIX || pathname.startsWith(`${PREFIX}/`);
}

function bearer(request: Request): string | null {
  const header = request.headers.get("authorization");
  const match = header?.match(/^Bearer[ ]+(\S+)\s*$/i);
  return match ? match[1]! : null;
}

export function createSiteApi(config: SiteApiConfig): SiteApi {
  const { adapter, limiter } = config;
  const log = config.log ?? ((entry) => console.warn(JSON.stringify(entry)));
  const { media, mentions } = adapter;

  const routes: Route[] = [
    ...contentRoutes(adapter),
    ...(media ? mediaRoutes(media, log) : []),
    ...(mentions ? mentionsRoutes(mentions) : []),
  ];

  // Groups a site has not implemented. Media and mentions leave this list when the adapter has them.
  const LATER = new RegExp(
    `^/(${[...(media ? [] : ["media"]), ...(mentions ? [] : ["mentions"]), "inbox", "insight", "publications"].join("|")})(/|$)`,
  );

  async function handle(request: Request): Promise<Response> {
    const url = new URL(request.url);
    // The URL parser has already resolved dot segments, so /api/carrel/v1/../admin arrives as /admin.
    if (!insidePrefix(url.pathname)) return fail("not-found", "Not found.");

    try {
      const ip = request.headers.get("cf-connecting-ip") ?? "unknown";
      if (!(await limiter.limit({ key: ip })).success) {
        log({ siteApi: "refused", reason: "rate-limited" });
        return fail("rate-limited", "Too many requests.");
      }
    } catch (error) {
      log({ siteApi: "refused", reason: "limiter-failed", error: String(error) });
      return fail("unavailable", "The site API is unavailable.");
    }

    const expected = config.key?.trim() ?? "";
    if (expected.length < MIN_KEY_LENGTH) {
      log({ siteApi: "refused", reason: "not-configured" });
      return fail("not-configured", "The site API is not configured.");
    }
    const presented = bearer(request);
    if (!presented || !(await safeEqual(presented, expected))) {
      log({ siteApi: "refused", reason: presented ? "wrong-key" : "no-key" });
      return fail("unauthorized", "Unauthorized.");
    }

    const path = url.pathname.slice(PREFIX.length) || "/";
    if (LATER.test(path)) return fail("not-implemented", "This group arrives in a later stage.");

    const candidates = routes
      .map((route) => ({ route, match: path.match(route.pattern) }))
      .filter((c): c is { route: Route; match: RegExpMatchArray } => c.match !== null);
    if (candidates.length === 0) return fail("not-found", "No such route.");
    const hit = candidates.find((c) => c.route.method === request.method);
    if (!hit) return fail("method-not-allowed", "Method not allowed.");

    try {
      return await hit.route.run(hit.match, url, request);
    } catch (error) {
      const failure = describeFailure(error, log);
      return fail(failure.code, failure.message, {
        ...(failure.currentVersion !== undefined ? { currentVersion: failure.currentVersion } : {}),
        ...(failure.usedBy ? { usedBy: failure.usedBy } : {}),
      });
    }
  }

  return {
    matches: (request) => insidePrefix(new URL(request.url).pathname),
    handle,
  };
}