1import { describe, expect, it } from "@effect/vitest";
2// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no generateKeyPairSync.
3import * as NodeCrypto from "node:crypto";
4import * as Clock from "effect/Clock";
5import * as Effect from "effect/Effect";
6import * as Layer from "effect/Layer";
7import * as Option from "effect/Option";
8import * as NodeServices from "@effect/platform-node/NodeServices";
9import * as Etag from "effect/http/Etag";
10import * as HttpPlatform from "effect/http/HttpPlatform";
11import * as HttpRouter from "effect/http/HttpRouter";
12import * as HttpApi from "effect/http-api/HttpApi";
13import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder";
14
15import { RELAY_HOOK_DELIVERY_TYP, signRelayJwt } from "@t3tools/shared/relayJwt";
16import {
17 EnvironmentHttpApi,
18 EnvironmentId,
19 ScheduledTaskWebhookDeliveryId,
20 ScheduledTaskError,
21} from "@t3tools/contracts";
22import {
23 ScheduledTaskService,
24 type WebhookTriggerRequest,
25 type WebhookTriggerResult,
26} from "./ScheduledTaskService.ts";
27import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
28import { CLOUD_MINT_PUBLIC_KEY, RELAY_ISSUER_SECRET } from "../cloud/config.ts";
29import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
30import * as RelayDeliveryProof from "./RelayDeliveryProof.ts";
31import { WEBHOOK_MAX_BODY_BYTES } from "./webhookRoute.ts";
32import * as WebhookRoute from "./webhookRoute.ts";
33
34class WebhookTestApi extends HttpApi.make("environment").add(EnvironmentHttpApi.groups.webhooks) {}
35
36const environmentId = EnvironmentId.make("env-1");
37const relayIssuer = "https://relay.example.test";
38const mintKeys = NodeCrypto.generateKeyPairSync("ed25519", {
39 privateKeyEncoding: { format: "pem", type: "pkcs8" },
40 publicKeyEncoding: { format: "pem", type: "spki" },
41});
42/** The secrets a T3 Connect-linked environment holds, keyed by name. */
43const linkedSecrets: ReadonlyMap<string, string> = new Map([
44 [CLOUD_MINT_PUBLIC_KEY, mintKeys.publicKey],
45 [RELAY_ISSUER_SECRET, relayIssuer],
46]);
47
48/** A proof as the relay signs it for one delivery. */
49const relayProof = Effect.fn("relayProof")(function* (claims: {
50 readonly deliveryId: string;
51 readonly receivedAt: string;
52 readonly hookId: string;
53 readonly privateKey?: string;
54}) {
55 const now = Math.floor((yield* Clock.currentTimeMillis) / 1_000);
56 return yield* signRelayJwt({
57 privateKey: claims.privateKey ?? mintKeys.privateKey,
58 typ: RELAY_HOOK_DELIVERY_TYP,
59 payload: {
60 iss: relayIssuer,
61 aud: `t3-env:${environmentId}`,
62 sub: environmentId,
63 jti: `proof:${claims.deliveryId}`,
64 iat: now,
65 exp: now + 3_600,
66 environmentId,
67 deliveryId: claims.deliveryId,
68 receivedAt: claims.receivedAt,
69 hookId: claims.hookId,
70 },
71 });
72});
73
74const handlerFor = (
75 trigger: (
76 request: WebhookTriggerRequest,
77 ) => Effect.Effect<WebhookTriggerResult, ScheduledTaskError>,
78 secrets: ReadonlyMap<string, string> = linkedSecrets,
79) =>
80 HttpRouter.toWebHandler(
81 HttpApiBuilder.layer(WebhookTestApi).pipe(
82 Layer.provide(WebhookRoute.layer),
83 Layer.provide(RelayDeliveryProof.layer),
84 Layer.provide(Layer.mock(ScheduledTaskService)({ triggerWebhook: trigger })),
85 Layer.provide(
86 Layer.mock(ServerSecretStore.ServerSecretStore)({
87 get: (name) =>
88 Effect.succeed(
89 Option.map(Option.fromUndefinedOr(secrets.get(name)), (value) =>
90 new TextEncoder().encode(value),
91 ),
92 ),
93 }),
94 ),
95 Layer.provide(
96 Layer.mock(ServerEnvironment.ServerEnvironment)({
97 getEnvironmentId: Effect.succeed(environmentId),
98 }),
99 ),
100 Layer.provide(
101 HttpPlatform.layer.pipe(
102 Layer.provideMerge(NodeServices.layer),
103 Layer.provideMerge(Etag.layerWeak),
104 ),
105 ),
106 Layer.provide(NodeServices.layer),
107 ),
108 { disableLogger: true },
109 );
110
111const post = (
112 path: string,
113 body: string | Uint8Array<ArrayBuffer>,
114 headers: Record<string, string> = {},
115) => new Request(`http://env.local${path}`, { method: "POST", body, headers });
116
117describe("webhook route", () => {
118 it("passes the raw request to the service and answers 202 with the delivery id", async () => {
119 let received: WebhookTriggerRequest | undefined;
120 const { handler, dispose } = handlerFor((request) => {
121 received = request;
122 return Effect.succeed({
123 _tag: "accepted",
124 deliveryId: ScheduledTaskWebhookDeliveryId.make("delivery:1"),
125 outcome: "accepted",
126 });
127 });
128 try {
129 const response = await handler(
130 post("/api/hooks/scheduled-task%3Ahook/tok?x=1", '{"a":1}', {
131 "Content-Type": "application/json",
132 "X-GitHub-Event": "push",
133 }),
134 );
135 expect(response.status).toBe(202);
136 expect(await response.json()).toEqual({ deliveryId: "delivery:1" });
137 expect(received?.hookId).toBe("scheduled-task:hook");
138 expect(received?.token).toBe("tok");
139 expect(received?.query).toBe("x=1");
140 expect(received?.headers["x-github-event"]).toBe("push");
141 expect(received?.bodyText).toBe('{"a":1}');
142 } finally {
143 await dispose();
144 }
145 });
146
147 // Live clock: the server checks proofs against real time.
148 it.live("trusts the relay's delivery id and receive time only with its signed proof", () =>
149 Effect.gen(function* () {
150 const received: Array<WebhookTriggerRequest> = [];
151 const { handler, dispose } = handlerFor((request) => {
152 received.push(request);
153 return Effect.succeed({
154 _tag: "accepted",
155 deliveryId: ScheduledTaskWebhookDeliveryId.make("delivery:1"),
156 outcome: "accepted",
157 });
158 });
159 const send = (path: string, headers: Record<string, string>) =>
160 Effect.promise(() => handler(post(path, "{}", headers)));
161 const receivedAt = "2026-10-04T10:00:00.000Z";
162 const relayHeaders = {
163 "x-t3-relay-delivery-id": "relay-1",
164 "x-t3-relay-received-at": receivedAt,
165 };
166 const forged = NodeCrypto.generateKeyPairSync("ed25519", {
167 privateKeyEncoding: { format: "pem", type: "pkcs8" },
168 publicKeyEncoding: { format: "pem", type: "spki" },
169 });
170 const proof = yield* relayProof({ deliveryId: "relay-1", receivedAt, hookId: "id" });
171 const forgedProof = yield* relayProof({
172 deliveryId: "relay-1",
173 receivedAt,
174 hookId: "id",
175 privateKey: forged.privateKey,
176 });
177 // 0: signed by the relay for exactly this delivery.
178 yield* send("/api/hooks/id/tok", { ...relayHeaders, "x-t3-relay-delivery": proof });
179 // 1: a direct caller claiming to be the relay, without a proof.
180 yield* send("/api/hooks/id/tok", relayHeaders);
181 // 2: a proof signed with any other key.
182 yield* send("/api/hooks/id/tok", { ...relayHeaders, "x-t3-relay-delivery": forgedProof });
183 // 3: a real proof lifted onto another delivery id.
184 yield* send("/api/hooks/id/tok", {
185 ...relayHeaders,
186 "x-t3-relay-delivery-id": "relay-2",
187 "x-t3-relay-delivery": proof,
188 });
189 // 4: a real proof for another hook.
190 yield* send("/api/hooks/other/tok", { ...relayHeaders, "x-t3-relay-delivery": proof });
191 yield* Effect.promise(() => dispose());
192 expect(received.map((request) => request.relayDeliveryId)).toEqual([
193 "relay-1",
194 undefined,
195 undefined,
196 undefined,
197 undefined,
198 ]);
199 expect(received[0]?.receivedAt).toBe(receivedAt);
200 expect(received.slice(1).every((request) => request.receivedAt === undefined)).toBe(true);
201 }),
202 );
203
204 // Live clock: the server checks proofs against real time.
205 it.live("trusts no relay headers on an environment not linked to T3 Connect", () =>
206 Effect.gen(function* () {
207 const received: Array<WebhookTriggerRequest> = [];
208 const { handler, dispose } = handlerFor((request) => {
209 received.push(request);
210 return Effect.succeed({ _tag: "not_found" });
211 }, new Map());
212 const receivedAt = "2026-10-04T10:00:00.000Z";
213 const proof = yield* relayProof({ deliveryId: "relay-1", receivedAt, hookId: "id" });
214 yield* Effect.promise(() =>
215 handler(
216 post("/api/hooks/id/tok", "{}", {
217 "x-t3-relay-delivery-id": "relay-1",
218 "x-t3-relay-received-at": receivedAt,
219 "x-t3-relay-delivery": proof,
220 }),
221 ),
222 );
223 yield* Effect.promise(() => dispose());
224 expect(received[0]?.relayDeliveryId).toBeUndefined();
225 }),
226 );
227
228 it("maps service outcomes to status codes and names each outcome for the relay", async () => {
229 const deliveryId = ScheduledTaskWebhookDeliveryId.make("delivery:1");
230 const cases: ReadonlyArray<[WebhookTriggerResult, number, string]> = [
231 [{ _tag: "accepted", deliveryId, outcome: "accepted" }, 202, "accepted"],
232 // Same status as a started run; only the header tells them apart.
233 [{ _tag: "accepted", deliveryId, outcome: "duplicate" }, 202, "duplicate"],
234 [{ _tag: "accepted", deliveryId, outcome: "prompt_too_long" }, 202, "prompt_too_long"],
235 [{ _tag: "not_found" }, 404, "not_found"],
236 [{ _tag: "rejected_signature" }, 401, "rejected_signature"],
237 [{ _tag: "disabled" }, 409, "disabled"],
238 [{ _tag: "rate_limited", outcome: "rate_limited" }, 429, "rate_limited"],
239 [{ _tag: "rate_limited", outcome: "queue_full" }, 429, "queue_full"],
240 [{ _tag: "expired" }, 410, "expired"],
241 ];
242 for (const [result, status, outcome] of cases) {
243 const { handler, dispose } = handlerFor(() => Effect.succeed(result));
244 try {
245 const response = await handler(post("/api/hooks/id/tok", "{}"));
246 expect(response.status).toBe(status);
247 expect(response.headers.get("x-t3-hook-outcome")).toBe(outcome);
248 } finally {
249 await dispose();
250 }
251 }
252 });
253
254 // Live clock: the server checks proofs against real time.
255 it.live("joins the relay's trace only for requests the relay forwarded", () =>
256 Effect.gen(function* () {
257 const parents: Array<string | undefined> = [];
258 const { handler, dispose } = handlerFor(() =>
259 Effect.gen(function* () {
260 const span = yield* Effect.currentParentSpan.pipe(Effect.option);
261 parents.push(span._tag === "Some" ? span.value.traceId : undefined);
262 return { _tag: "not_found" } as const;
263 }),
264 );
265 const send = (headers: Record<string, string>) =>
266 Effect.promise(() => handler(post("/api/hooks/id/tok", "{}", headers)));
267 const traceparent = "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01";
268 const receivedAt = "2026-10-04T10:00:00.000Z";
269 const relayHeaders = {
270 "x-t3-relay-delivery-id": "relay-1",
271 "x-t3-relay-received-at": receivedAt,
272 };
273 const proof = yield* relayProof({ deliveryId: "relay-1", receivedAt, hookId: "id" });
274 yield* send({ ...relayHeaders, traceparent, "x-t3-relay-delivery": proof });
275 // Claiming to be the relay without its proof cannot attach to our traces.
276 yield* send({ ...relayHeaders, traceparent });
277 yield* send({ traceparent });
278 yield* Effect.promise(() => dispose());
279 expect(parents).toEqual(["0af7651916cd43dd8448eb211c80319c", undefined, undefined]);
280 }),
281 );
282
283 it("rejects oversized bodies and malformed paths before reaching the service", async () => {
284 let calls = 0;
285 const { handler, dispose } = handlerFor(() => {
286 calls += 1;
287 return Effect.succeed({ _tag: "not_found" });
288 });
289 try {
290 const big = new Uint8Array(WEBHOOK_MAX_BODY_BYTES + 1);
291 expect((await handler(post("/api/hooks/id/tok", big))).status).toBe(413);
292 expect((await handler(post("/api/hooks/id", "{}"))).status).toBe(404);
293 expect((await handler(post("/api/hooks/id/tok/extra", "{}"))).status).toBe(404);
294 expect((await handler(post("/api/hooks/%E0/tok", "{}"))).status).toBe(404);
295 // No content-length: the reader cap must still apply.
296 const chunked = new ReadableStream<Uint8Array>({
297 start(controller) {
298 for (let sent = 0; sent <= WEBHOOK_MAX_BODY_BYTES; sent += 64 * 1024) {
299 controller.enqueue(new Uint8Array(64 * 1024));
300 }
301 controller.close();
302 },
303 });
304 const streamed = await handler(
305 new Request("http://env.local/api/hooks/id/tok", {
306 method: "POST",
307 body: chunked,
308 // Node's fetch needs duplex for streamed bodies.
309 duplex: "half",
310 }),
311 );
312 expect(streamed.status).toBe(413);
313 expect(calls).toBe(0);
314 } finally {
315 await dispose();
316 }
317 });
318
319 it("hides service failures and defects behind a fixed 500", async () => {
320 const failures = [
321 Effect.fail(new ScheduledTaskError({ message: "database locked" })),
322 Effect.die(new Error("database exploded")),
323 ];
324 for (const failure of failures) {
325 const { handler, dispose } = handlerFor(() => failure);
326 try {
327 const response = await handler(post("/api/hooks/id/tok", "{}"));
328 expect(response.status).toBe(500);
329 expect(await response.json()).toEqual({ error: "internal_error" });
330 } finally {
331 await dispose();
332 }
333 }
334 });
335});