Skip to content
Source
Sign inSign up

RelayDeliveryProof.ts

82 lines3.4 KB

1// Trust relay headers only with a mint-key-signed proof; otherwise handle the webhook as a direct request.
2import { RelayHookDeliveryProofPayload } from "@t3tools/contracts/relay";
3import {
4 normalizeRelayIssuer,
5 RELAY_HOOK_DELIVERY_HEADER,
6 RELAY_HOOK_DELIVERY_TYP,
7 verifyRelayJwt,
8} from "@t3tools/shared/relayJwt";
9import * as Context from "effect/Context";
10import * as Signature from "effect/crypto/Signature";
11import * as Effect from "effect/Effect";
12import * as Layer from "effect/Layer";
13import * as Option from "effect/Option";
14​
15import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
16import { CLOUD_MINT_PUBLIC_KEY, RELAY_ISSUER_SECRET, RELAY_URL_SECRET } from "../cloud/config.ts";
17import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
18​
19/** Covers the relay's 24-hour hold plus a margin. */
20const MAX_PROOF_AGE = "25 hours";
21​
22const text = (bytes: Option.Option<Uint8Array>) =>
23 Option.map(bytes, (value) => new TextDecoder().decode(value));
24​
25export class RelayDeliveryProof extends Context.Service<
26 RelayDeliveryProof,
27 {
28 readonly verify: (input: {
29 readonly headers: Readonly<Record<string, string>>;
30 readonly hookId: string;
31 }) => Effect.Effect<
32 Option.Option<{ readonly deliveryId: string; readonly receivedAt: string }>
33 >;
34 }
35>()("t3/scheduledTasks/RelayDeliveryProof") {}
36​
37const make = Effect.gen(function* () {
38 const secrets = yield* ServerSecretStore.ServerSecretStore;
39 const environment = yield* ServerEnvironment.ServerEnvironment;
40 const signature = yield* Signature.Signature;
41 const verify: RelayDeliveryProof["Service"]["verify"] = Effect.fn("webhook.verifyRelayDelivery")(
42 function* (input) {
43 const proof = input.headers[RELAY_HOOK_DELIVERY_HEADER];
44 const deliveryId = input.headers["x-t3-relay-delivery-id"];
45 const receivedAt = input.headers["x-t3-relay-received-at"];
46 if (proof === undefined || deliveryId === undefined || receivedAt === undefined) {
47 return Option.none();
48 }
49 const publicKey = text(yield* secrets.get(CLOUD_MINT_PUBLIC_KEY));
50 const issuer = Option.orElse(text(yield* secrets.get(RELAY_ISSUER_SECRET)), () =>
51 Option.none<string>(),
52 );
53 const relayUrl = text(yield* secrets.get(RELAY_URL_SECRET));
54 const relayIssuer = Option.isSome(issuer) ? issuer : relayUrl;
55 if (Option.isNone(publicKey) || Option.isNone(relayIssuer)) return Option.none();
56 const environmentId = yield* environment.getEnvironmentId;
57 const payload = yield* verifyRelayJwt(RelayHookDeliveryProofPayload, {
58 publicKey: publicKey.value,
59 token: proof,
60 typ: RELAY_HOOK_DELIVERY_TYP,
61 issuer: normalizeRelayIssuer(relayIssuer.value),
62 audience: `t3-env:${environmentId}`,
63 maxTokenAge: MAX_PROOF_AGE,
64 }).pipe(Effect.provideService(Signature.Signature, signature), Effect.option);
65 // The proof must name exactly this delivery, so it cannot be lifted onto another one.
66 if (
67 Option.isNone(payload) ||
68 payload.value.environmentId !== environmentId ||
69 payload.value.deliveryId !== deliveryId ||
70 payload.value.receivedAt !== receivedAt ||
71 payload.value.hookId !== input.hookId
72 ) {
73 return Option.none();
74 }
75 return Option.some({ deliveryId, receivedAt });
76 },
77 Effect.orElseSucceed(Option.none),
78 );
79 return RelayDeliveryProof.of({ verify });
80});
81​
82export const layer = Layer.effect(RelayDeliveryProof, make);