1/**
2 * Whether a webhook request really came through this environment's relay.
3 *
4 * The relay's delivery id, receive time and trace context arrive as plain
5 * headers, and the webhook URL can also be called directly, so they are only
6 * trusted alongside a proof the relay signed with its mint key. A request
7 * without a valid proof is handled as a direct request.
8 */
9import { RelayHookDeliveryProofPayload } from "@t3tools/contracts/relay";
10import {
11 normalizeRelayIssuer,
12 RELAY_HOOK_DELIVERY_HEADER,
13 RELAY_HOOK_DELIVERY_TYP,
14 verifyRelayJwt,
15} from "@t3tools/shared/relayJwt";
16import * as Clock from "effect/Clock";
17import * as Context from "effect/Context";
18import * as Effect from "effect/Effect";
19import * as Layer from "effect/Layer";
20import * as Option from "effect/Option";
21import * as Schema from "effect/Schema";
22
23import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
24import { CLOUD_MINT_PUBLIC_KEY, RELAY_ISSUER_SECRET, RELAY_URL_SECRET } from "../cloud/config.ts";
25import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
26
27/** Covers the relay's 24-hour hold plus a margin. */
28const MAX_PROOF_AGE_SECONDS = 25 * 60 * 60;
29
30const decodePayload = Schema.decodeUnknownOption(RelayHookDeliveryProofPayload);
31const text = (bytes: Option.Option<Uint8Array>) =>
32 Option.map(bytes, (value) => new TextDecoder().decode(value));
33
34export class RelayDeliveryProof extends Context.Service<
35 RelayDeliveryProof,
36 {
37 /** The relay's own delivery id and receive time, when the request proves it came from the relay. */
38 readonly verify: (input: {
39 readonly headers: Readonly<Record<string, string>>;
40 readonly hookId: string;
41 }) => Effect.Effect<
42 Option.Option<{ readonly deliveryId: string; readonly receivedAt: string }>
43 >;
44 }
45>()("t3/scheduledTasks/RelayDeliveryProof") {}
46
47const make = Effect.gen(function* () {
48 const secrets = yield* ServerSecretStore.ServerSecretStore;
49 const environment = yield* ServerEnvironment.ServerEnvironment;
50 const verify: RelayDeliveryProof["Service"]["verify"] = (input) =>
51 Effect.gen(function* () {
52 const proof = input.headers[RELAY_HOOK_DELIVERY_HEADER];
53 const deliveryId = input.headers["x-t3-relay-delivery-id"];
54 const receivedAt = input.headers["x-t3-relay-received-at"];
55 if (proof === undefined || deliveryId === undefined || receivedAt === undefined) {
56 return Option.none();
57 }
58 const publicKey = text(yield* secrets.get(CLOUD_MINT_PUBLIC_KEY));
59 const issuer = Option.orElse(text(yield* secrets.get(RELAY_ISSUER_SECRET)), () =>
60 Option.none<string>(),
61 );
62 const relayUrl = text(yield* secrets.get(RELAY_URL_SECRET));
63 const relayIssuer = Option.isSome(issuer) ? issuer : relayUrl;
64 // Not linked to T3 Connect: no relay can be delivering to us.
65 if (Option.isNone(publicKey) || Option.isNone(relayIssuer)) return Option.none();
66 const environmentId = yield* environment.getEnvironmentId;
67 const payload = yield* verifyRelayJwt({
68 publicKey: publicKey.value,
69 token: proof,
70 typ: RELAY_HOOK_DELIVERY_TYP,
71 issuer: normalizeRelayIssuer(relayIssuer.value),
72 audience: `t3-env:${environmentId}`,
73 nowEpochSeconds: Math.floor((yield* Clock.currentTimeMillis) / 1_000),
74 maxTokenAge: MAX_PROOF_AGE_SECONDS,
75 }).pipe(Effect.map(decodePayload), Effect.orElseSucceed(Option.none));
76 // The proof must name exactly this delivery, so it cannot be lifted onto another one.
77 if (
78 Option.isNone(payload) ||
79 payload.value.environmentId !== environmentId ||
80 payload.value.deliveryId !== deliveryId ||
81 payload.value.receivedAt !== receivedAt ||
82 payload.value.hookId !== input.hookId
83 ) {
84 return Option.none();
85 }
86 return Option.some({ deliveryId, receivedAt });
87 }).pipe(Effect.orElseSucceed(Option.none), Effect.withSpan("webhook.verifyRelayDelivery"));
88 return RelayDeliveryProof.of({ verify });
89});
90
91export const layer = Layer.effect(RelayDeliveryProof, make);