Fork Horse repository, listed by its owner. Publisher snapshot.

packages/forkhorse-core/src/public.ts

Snapshot at 34e3175

Containing directory

7,173 bytes · typescriptView raw
1import { createCoreClient, createOAuthHandler, PublicError } from "forkhorse-mcp";
2import type { OAuthEnv, OwnerIdentity } from "forkhorse-mcp";
3import { dispatchApi } from "./api";
4import { verifyOwner } from "./access";
5import { errorResponse, HttpError, json, operation, slug } from "./http";
6import type { Env } from "./types";
7import brand from "../../../brand.json";
8import { markIconUrl } from "../../forkhorse-dashboard/src/mark";
9
10function settings(env: Env) {
11  const origin = env.CORE_PUBLIC_ORIGIN;
12  const path = env.MCP_PATH ?? "/mcp";
13  const project = env.MCP_PROJECT;
14  if (
15    !origin ||
16    new URL(origin).origin !== origin ||
17    !origin.startsWith("https://") ||
18    !/^\/[a-z0-9-]+$/.test(path) ||
19    ["/app", "/projects", "/authorize", "/oauth"].includes(path) ||
20    !slug(project)
21  )
22    throw new HttpError(503, "Public core configuration is incomplete.");
23  return { origin, path, project };
24}
25
26function requireHost(request: Request, origin: string) {
27  if (new URL(request.url).origin !== origin) throw new HttpError(403, "Host is not allowed.");
28}
29
30async function oauth(request: Request, env: Env, ctx: ExecutionContext) {
31  const config = settings(env);
32  if (!env.OAUTH_KV) throw new HttpError(503, "OAuth storage is not configured.");
33  // This proof lives for one request; only the Access verifier can populate it.
34  let verifiedOwner: OwnerIdentity | null = null;
35  const core = createCoreClient({
36    async authenticate(token, signal) {
37      signal.throwIfAborted();
38      const identity = await env.SUPERVISOR.get(
39        env.SUPERVISOR.idFromName(config.project),
40      ).authenticateAgent(token);
41      signal.throwIfAborted();
42      return identity;
43    },
44    fetch: (coreRequest) => dispatchApi(coreRequest, env),
45    async enrollAgent(owner, project, agent, signal) {
46      signal.throwIfAborted();
47      if (!verifiedOwner || owner.subject !== verifiedOwner.subject || project !== config.project)
48        throw new PublicError(403, "Owner authorization is required.");
49      const response = await dispatchApi(
50        new Request(`https://core.invalid/projects/${project}/agents`, {
51          method: "POST",
52          headers: { "content-type": "application/json" },
53          body: JSON.stringify({ agent }),
54          signal,
55        }),
56        env,
57        true,
58      );
59      if (response.status !== 201) throw new PublicError(502, "Agent enrollment failed.");
60      const enrolled = await response.json<{ agent: string; apiToken: string }>();
61      return { project, ...enrolled };
62    },
63  });
64  const response = await createOAuthHandler({
65    core,
66    publicOrigin: config.origin,
67    path: config.path,
68    project: config.project,
69    async authorizeOwner(ownerRequest, signal) {
70      signal.throwIfAborted();
71      verifiedOwner = await verifyOwner(ownerRequest, env);
72      signal.throwIfAborted();
73      return verifiedOwner;
74    },
75  })(request, { OAUTH_KV: env.OAUTH_KV } as unknown as OAuthEnv, ctx);
76  // Add identity only to successful consent HTML; preserve redirects, cookies and auth checks.
77  if (new URL(request.url).pathname === "/authorize" && request.method === "GET" &&
78      response.status === 200 && response.headers.get("content-type")?.includes("text/html")) {
79    return new HTMLRewriter().on("head", {
80      element(element) {
81        element.append(`<link rel="icon" type="image/svg+xml" href="${markIconUrl(brand.mark)}">`, { html: true });
82      },
83    }).transform(response);
84  }
85  return response;
86}
87
88async function dashboard(request: Request, env: Env): Promise<Response> {
89  const config = settings(env);
90  requireHost(request, config.origin);
91  if (!(await verifyOwner(request, env)))
92    throw new HttpError(401, "Owner Access authentication is required.");
93  if (!["GET", "HEAD"].includes(request.method)) return json({ error: "Method not allowed." }, 405);
94  if (!env.ASSETS) throw new HttpError(503, "Dashboard assets are not configured.");
95  const url = new URL(request.url);
96  url.pathname = url.pathname.replace(/^\/app\/?/, "/");
97  if (url.pathname === "/") url.pathname = "/index.html";
98  // The binding is reached only after verification, even for JS/CSS. Do not forward credentials.
99  const asset = await env.ASSETS.fetch(new Request(url, { method: request.method }));
100  const headers = new Headers(asset.headers);
101  headers.set("cache-control", "private, no-store");
102  headers.set("x-content-type-options", "nosniff");
103  headers.set("referrer-policy", "same-origin");
104  headers.set(
105    "content-security-policy",
106    "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'",
107  );
108  headers.delete("etag");
109  let response = new Response(asset.body, { status: asset.status, headers });
110  if (request.method === "GET" && asset.ok && headers.get("content-type")?.includes("text/html")) {
111    const data = JSON.stringify({
112      projects: [{ id: config.project, label: config.project }],
113    }).replaceAll("<", "\\u003c");
114    response = new HTMLRewriter()
115      .on("head", {
116        element(element) {
117          element.append(`<script id="dashboard-config" type="application/json">${data}</script>`, {
118            html: true,
119          });
120        },
121      })
122      .transform(response);
123  }
124  return response;
125}
126
127export async function publicFetch(
128  request: Request,
129  env: Env,
130  ctx?: ExecutionContext,
131): Promise<Response> {
132  try {
133    const url = new URL(request.url);
134    if (url.pathname === "/app" || url.pathname.startsWith("/app/"))
135      return await dashboard(request, env);
136    if (
137      url.pathname === (env.MCP_PATH ?? "/mcp") ||
138      url.pathname === "/authorize" ||
139      url.pathname.startsWith("/oauth/") ||
140      url.pathname.startsWith("/.well-known/")
141    ) {
142      if (!ctx) throw new HttpError(503, "OAuth execution context is unavailable.");
143      return await oauth(request, env, ctx);
144    }
145    if (url.pathname === "/projects" || url.pathname.startsWith("/projects/")) {
146      // Browser owner authority comes from Access, never a client-selected identity/header.
147      if (
148        request.headers.has("origin") ||
149        !request.headers.has("authorization") ||
150        request.headers.has("cf-access-jwt-assertion")
151      ) {
152        const { origin } = settings(env);
153        requireHost(request, origin);
154        if (!(await verifyOwner(request, env)))
155          throw new HttpError(401, "Owner Access authentication is required.");
156        const suppliedOrigin = request.headers.get("origin");
157        if (
158          (suppliedOrigin && suppliedOrigin !== origin) ||
159          (!["GET", "HEAD"].includes(request.method) &&
160            (suppliedOrigin !== origin ||
161              request.headers.get("content-type")?.split(";")[0]?.trim() !== "application/json"))
162        ) {
163          throw new HttpError(403, "Owner request origin or content type is not allowed.");
164        }
165        return await dispatchApi(request, env, true);
166      }
167      return await dispatchApi(request, env);
168    }
169    return json({ error: "Not found." }, 404);
170  } catch (error) {
171    return errorResponse(error, { op: operation(request) });
172  }
173}
174