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

packages/forkhorse-gatekeeper/src/worker/index.ts

Snapshot at 34e3175

Containing directory

5,597 bytes · typescriptView raw
1import { WorkerEntrypoint } from "cloudflare:workers";
2import type { GatekeeperConnectCallback, GatekeeperConnectOptions, GatekeeperVendor as Vendor, SupportedResource, VendorDescription } from "@gadgets/workshop-shared/gatekeeper";
3import { generateNonce } from "@gadgets/gatekeeper-kit/connect-nonce";
4import { connectHandoffPageHtml, connectMutationError } from "@gadgets/gatekeeper-kit/connect-pages";
5import { readTextCapped } from "@gadgets/gatekeeper-kit/response-body";
6import { RESOURCE, type Env } from "./account.ts";
7import brand from "../../../../brand.json";
8import { markIconUrl } from "../../../forkhorse-dashboard/src/mark.ts";
9import { TYPES } from "./api-types.ts";
10export { UserAccount, AgentAccount, AgentVerifier } from "./account.ts";
11export { ForkGatekeeper } from "./fork.ts";
12
13export class GatekeeperVendor extends WorkerEntrypoint<Env> implements Vendor {
14  async describe(): Promise<VendorDescription> {
15    return { displayName: brand.name, url: `https://${brand.domains.primary}`, tagline: brand.tagline,
16      logo: { url: markIconUrl(brand.mark) },
17      description: "Connect an enrolled agent to read its forks and submit forward-only Git updates.",
18      autoProvisionsAccount: false, providesAuth: false };
19  }
20  async connectAccount(callback: Fetcher<GatekeeperConnectCallback>, options?: GatekeeperConnectOptions): Promise<{ url: string }> {
21    if (options?.scopes === "auth" || (options?.resourceUrlPatterns !== undefined && (options.resourceUrlPatterns.length === 0 || options.resourceUrlPatterns.some(p => p !== RESOURCE.urlPattern)))) {
22      throw new Error("Unsupported connection scope.");
23    }
24    const id = this.ctx.exports.UserAccount.newUniqueId();
25    const nonce = generateNonce();
26    await this.ctx.exports.UserAccount.get(id).initiate(callback, nonce);
27    return { url: `${this.env.BASE_URL}/${id}/${nonce}` };
28  }
29  async getSupportedResources(): Promise<SupportedResource[]> { return [RESOURCE]; }
30  async getTypeScriptTypes(): Promise<string> { return TYPES; }
31}
32
33const escape = (value: string) => value.replace(/[&<>"']/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"})[c]!);
34function html(body: string, status = 200): Response {
35  return new Response(body, { status, headers: {
36    "content-type": "text/html; charset=utf-8", "cache-control": "no-store",
37    "referrer-policy": "no-referrer", "x-content-type-options": "nosniff",
38    "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'",
39  } });
40}
41function connectForm(action: string): string {
42  return `<!doctype html><html><meta charset="utf-8"><title>${escape(brand.name)}</title>
43    <style>body{font:16px system-ui;max-width:36rem;margin:4rem auto;padding:1rem}label{display:block;margin:1rem 0}input{display:block;width:100%;padding:.6rem;box-sizing:border-box}button{padding:.7rem}</style>
44    <h1>Connect ${escape(brand.name)}</h1><p>Use an enrolled agent credential for your project. Owner credentials are refused.</p>
45    <form method="post" action="${escape(action)}"><label>Core origin<input name="coreOrigin" type="url" required placeholder="https://core.idiot.tax"></label>
46    <label>Project ID<input name="project" maxlength="48" required autocomplete="off"></label>
47    <label>Enrolled agent credential<input name="agentCredential" type="password" maxlength="256" required autocomplete="off"></label>
48    <button>Connect agent</button></form></html>`;
49}
50
51export default {
52  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
53    const url = new URL(request.url);
54    const base = new URL(env.BASE_URL);
55    const prefix = base.pathname.replace(/\/$/, "");
56    if (url.origin !== base.origin || url.search || !url.pathname.startsWith(`${prefix}/`)) return html("Not found.", 404);
57    const match = /^([a-f0-9]{64})\/([a-f0-9]{64})$/.exec(url.pathname.slice(prefix.length + 1));
58    if (!match) return html(`<h1>${escape(brand.name)}</h1><p>Open your connected agent account in the OS sidebar.</p>`, 404);
59    const source = ctx.exports.UserAccount.get(ctx.exports.UserAccount.idFromString(match[1]));
60    if (request.method === "GET") {
61      if (!await source.validNonce(match[2])) return html("Connection link expired. Start again.", 400);
62      const response = html(connectForm(url.href));
63      // A no-referrer HTML form sends Origin: null. Keep same-origin POST verification usable
64      // without disclosing the nonce-bearing URL to another origin.
65      response.headers.set("referrer-policy", "same-origin");
66      return response;
67    }
68    if (request.method !== "POST") return html("Method not allowed.", 405);
69    if (connectMutationError(request, { origin: base.origin, contentType: "application/x-www-form-urlencoded" })) {
70      return html("Invalid connection submission.", 403);
71    }
72    try {
73      const fields = new URLSearchParams(await readTextCapped(request, 8192));
74      const handoff = await source.completeConnection(match[2], {
75        coreOrigin: fields.get("coreOrigin") ?? "", project: fields.get("project") ?? "",
76        agentCredential: fields.get("agentCredential") ?? "",
77      });
78      // The kit handoff navigates to the initiating Workshop; credentials never enter this page.
79      return new Response(connectHandoffPageHtml(handoff), { headers: {
80        "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "referrer-policy": "no-referrer",
81      } });
82    } catch { return html("Agent connection failed. Verify your credential and start again.", 400); }
83  },
84};
85