1import type * as cf from "@cloudflare/workers-types";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as Redacted from "effect/Redacted";
5import {
6 RepositoryEventSource,
7 webhookPath,
8 webhookSecretEnvName,
9 type RepositoryEventSourceProps,
10 type RepositoryEventSourceService,
11 type WebhookEvent,
12} from "../../GitHub/RepositoryEventSource.ts";
13import { Webhook } from "../../GitHub/Webhook.ts";
14import * as Namespace from "../../Namespace.ts";
15import * as Output from "../../Output.ts";
16import { isWorkerEvent, Worker } from "./Worker.ts";
17
18/**
19 * GitHub event source for Cloudflare Workers.
20 *
21 * Deploy-time: provisions a {@link Webhook} on the repository whose delivery
22 * URL points at this Worker (at a deterministic per-repo path). The webhook
23 * secret is bound onto the Worker via an `Output` accessor so the runtime can
24 * verify delivery signatures.
25 *
26 * Runtime: registers a `fetch` listener that claims requests on the
27 * repository's delivery path, verifies the `HMAC-SHA256` signature against the
28 * bound secret, and forwards each delivery to the subscriber. Requests on any
29 * other path fall through to the Worker's own `fetch` handler.
30 *
31 * @binding
32 * @product Workers
33 * @category Workers & Compute
34 */
35export const GitHubRepositoryEventSourceLive = Layer.effect(
36 RepositoryEventSource,
37 Effect.gen(function* () {
38 const ctx = yield* Worker;
39 // Loosely-typed constructor — yielding the resource class erases its
40 // `GitHub.Providers` requirement. The requirement is satisfied by the
41 // stack at plan time.
42 const createWebhook = yield* Webhook;
43
44 return Effect.fn(function* (
45 props: RepositoryEventSourceProps,
46 process: (event: WebhookEvent) => Effect.Effect<void, never, never>,
47 ) {
48 const path = webhookPath(props);
49
50 // Deploy-time: provision the repository webhook pointing at this Worker.
51 // Skipped once running inside the deployed Worker (the global guard).
52 // Namespaced under the host so the webhook's logical identity matches the
53 // previous Binding.Policy.
54 if (!globalThis.__ALCHEMY_RUNTIME__) {
55 yield* Namespace.push(
56 ctx.LogicalId,
57 Effect.gen(function* () {
58 yield* createWebhook(`${props.owner}/${props.repository}`, {
59 owner: props.owner,
60 repository: props.repository,
61 url: Output.interpolate`${ctx.url}${path}`,
62 events: [...(props.events ?? ["push"])],
63 secret: props.secret,
64 contentType: "json",
65 });
66 }),
67 );
68 }
69
70 // Bind the webhook secret as a Worker env accessor under a
71 // deterministic key. This single `yield*` does both halves: at plan
72 // time it registers a `secret_text` binding (the engine deploys
73 // `Redacted` values as Cloudflare secrets), and it returns an Effect
74 // that reads the value back from `WorkerEnvironment` at runtime —
75 // reconstructing the `Redacted` wrapper. No direct `event.env` access.
76 const secret = props.secret
77 ? yield* Output.named(
78 Output.asOutput(props.secret),
79 webhookSecretEnvName(props),
80 )
81 : undefined;
82
83 yield* ctx.listen((event) => {
84 if (!isWorkerEvent(event) || event.type !== "fetch") return;
85 const request = event.input as cf.Request;
86
87 let pathname: string;
88 try {
89 pathname = new URL(request.url).pathname;
90 } catch {
91 return;
92 }
93 // Not our delivery path — let the Worker's own handler take it.
94 if (pathname !== path) return;
95
96 return handleDelivery(request, secret, process);
97 });
98 }) as RepositoryEventSourceService;
99 }),
100);
101
102const handleDelivery = <Req>(
103 request: cf.Request,
104 // The bound secret accessor (see `Output.named` above). `undefined` when
105 // no secret was configured, in which case deliveries are accepted
106 // unverified.
107 secret: Effect.Effect<Redacted.Redacted<string> | undefined> | undefined,
108 process: (event: WebhookEvent<any>) => Effect.Effect<void, never, Req>,
109): Effect.Effect<Response, never, Req> =>
110 Effect.gen(function* () {
111 if (request.method !== "POST") {
112 return new Response("method not allowed", { status: 405 });
113 }
114
115 const body = yield* Effect.promise(() =>
116 (request as unknown as Request).text(),
117 );
118
119 if (secret !== undefined) {
120 const resolved = yield* secret;
121 const signature = request.headers.get("x-hub-signature-256") ?? undefined;
122 const valid = yield* verifySignature(
123 resolved ? Redacted.value(resolved) : undefined,
124 body,
125 signature,
126 );
127 if (!valid) {
128 return new Response("invalid signature", { status: 401 });
129 }
130 }
131
132 const name = request.headers.get("x-github-event") ?? "unknown";
133 const id = request.headers.get("x-github-delivery") ?? "";
134
135 let payload: unknown;
136 try {
137 payload = JSON.parse(body);
138 } catch {
139 payload = body;
140 }
141
142 // The wire shape matches `EmitterWebhookEvent`, but the runtime can't
143 // statically prove `name`/`payload` line up with a specific member of
144 // the discriminated union — GitHub's headers are the source of truth, so
145 // cast across the boundary.
146 const delivery = { id, name, payload } as unknown as WebhookEvent;
147
148 yield* process(delivery).pipe(Effect.orDie);
149
150 return new Response(null, { status: 202 });
151 });
152
153const verifySignature = (
154 secret: string | undefined,
155 body: string,
156 signature: string | undefined,
157): Effect.Effect<boolean> =>
158 Effect.gen(function* () {
159 if (!secret || !signature || !signature.startsWith("sha256=")) {
160 return false;
161 }
162 const expected = yield* Effect.promise(async () => {
163 const encoder = new TextEncoder();
164 const key = await crypto.subtle.importKey(
165 "raw",
166 encoder.encode(secret),
167 { name: "HMAC", hash: "SHA-256" },
168 false,
169 ["sign"],
170 );
171 const digest = await crypto.subtle.sign(
172 "HMAC",
173 key,
174 encoder.encode(body),
175 );
176 const hex = Array.from(new Uint8Array(digest))
177 .map((byte) => byte.toString(16).padStart(2, "0"))
178 .join("");
179 return `sha256=${hex}`;
180 });
181 return timingSafeEqual(expected, signature);
182 });
183
184/**
185 * Constant-time string comparison. Avoids leaking signature bytes through
186 * early-exit timing differences. `crypto.subtle.timingSafeEqual` isn't
187 * available on Workers without `nodejs_compat`, so we roll a small one.
188 */
189const timingSafeEqual = (a: string, b: string): boolean => {
190 if (a.length !== b.length) return false;
191 let mismatch = 0;
192 for (let i = 0; i < a.length; i++) {
193 mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i);
194 }
195 return mismatch === 0;
196};