Fork Horse repository, listed by its owner. Publisher snapshot.
packages/forkhorse-core/src/public.ts
Snapshot at 34e3175
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