1import * as Effect from "effect/Effect";
2import * as HttpClient from "effect/http/HttpClient";
3import * as HttpClientRequest from "effect/http/HttpClientRequest";
4import type { HttpServerRequest } from "effect/http/HttpServerRequest";
5import * as HttpServerResponse from "effect/http/HttpServerResponse";
6import * as Binding from "../Binding.ts";
7import * as Output from "../Output.ts";
8import { isGcpListenHost, type GcpHostRuntimeContext } from "./HostContext.ts";
9import { Member } from "./IAM/Member.ts";
10import { unverifiedAudience, verifyGoogleIdToken } from "./Oidc.ts";
11
12/**
13 * Shared scaffolding for event sources that deliver over HTTPS to a GCP
14 * host (Pub/Sub push, Cloud Scheduler): the host's endpoint and push
15 * identity, the `run.invoker` grant, the delivery path, and per-request
16 * OIDC verification.
17 *
18 * NOT exported from `index.ts`.
19 */
20
21/** An HTTP-serving GCP host: `GCP.Run.Service` or `GCP.CloudFunctions.Function`. */
22export type PushHost = GcpHostRuntimeContext & {
23 LogicalId: string;
24 Type: string;
25};
26
27export class PushHostRequired extends Error {
28 constructor(source: string, host: string | undefined) {
29 super(
30 `${source} delivers over HTTPS and needs a GCP.Run.Service (GCP.Function) or GCP.CloudFunctions.Function host; got ${host ?? "no host"}. Use GCP.Run.TopicPullEventSource on Jobs and WorkerPools.`,
31 );
32 }
33}
34
35/** The ambient push host, or a defect naming what is missing. */
36export const pushHost = (source: string) =>
37 Effect.gen(function* () {
38 const host = yield* Binding.Host;
39 if (!isGcpListenHost(host)) {
40 return yield* Effect.die(
41 new PushHostRequired(source, (host as { Type?: string })?.Type),
42 );
43 }
44 return host as PushHost;
45 });
46
47/**
48 * Deploy-time outputs of the host an event source delivers to. The host's
49 * runtime service account is the push identity, so no extra account is
50 * minted and the runtime can check the token's email against its own.
51 */
52export const hostEndpoint = (host: PushHost) => {
53 const attrs = host as unknown as Record<string, Output.Output<string>>;
54 return host.Type === "GCP.CloudFunctions.Function"
55 ? {
56 // The function's Cloud Run URL: the address Cloud Run's front end
57 // validates token audiences against.
58 url: attrs.uri!,
59 serviceAccount: attrs.serviceAccountEmail!,
60 // Gen2 functions are invoked through their Cloud Run service.
61 invokerService: attrs.service!,
62 }
63 : {
64 url: attrs.uri!,
65 serviceAccount: attrs.serviceAccount!,
66 invokerService: attrs.name!,
67 };
68};
69
70/** Deterministic path segment for a logical id. */
71export const pathSegment = (value: string) =>
72 value.replace(/[^a-zA-Z0-9_-]/g, "-").toLowerCase();
73
74/**
75 * OIDC audience for one delivery route: the route's own URL on the host.
76 * Cloud Run's front end only accepts tokens addressed to the service's
77 * URL (or a configured custom audience) when the invoker check is on, and
78 * binding the path means a token minted for one route is rejected on
79 * every other.
80 */
81export const deliveryAudience = (url: Output.Output<string>, path: string) =>
82 Output.interpolate`${url}${path}`;
83
84/**
85 * The audience a delivery to `path` must carry. Cloud Run routes on the
86 * `Host` header, so it names the URL the sender addressed — the same URL
87 * `deliveryAudience` pinned into the push config.
88 */
89const expectedAudience = (request: HttpServerRequest, path: string) =>
90 `https://${request.headers["host"] ?? ""}${path}`;
91
92/**
93 * Let the host's runtime service account invoke the host, so Cloud Run
94 * accepts the OIDC-authenticated deliveries even when the service requires
95 * authentication.
96 */
97export const grantSelfInvoker = (host: PushHost) =>
98 Effect.gen(function* () {
99 const endpoint = hostEndpoint(host);
100 const member = yield* Member;
101 yield* member(`${host.LogicalId}-PushInvoker`, {
102 kind: "run.service",
103 name: endpoint.invokerService,
104 role: "roles/run.invoker",
105 member: Output.interpolate`serviceAccount:${endpoint.serviceAccount}`,
106 });
107 });
108
109let runtimeEmail: string | undefined;
110
111/** The instance's own service account email, from the metadata server. */
112const ownServiceAccount = Effect.gen(function* () {
113 if (runtimeEmail !== undefined) return runtimeEmail;
114 const http = yield* HttpClient.HttpClient;
115 const response = yield* http.execute(
116 HttpClientRequest.get(
117 "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/email",
118 ).pipe(HttpClientRequest.setHeader("Metadata-Flavor", "Google")),
119 );
120 const email = (yield* response.text).trim();
121 runtimeEmail = email;
122 return email;
123});
124
125const unauthorized = HttpServerResponse.text("unauthorized", { status: 401 });
126const unavailable = HttpServerResponse.text("unavailable", { status: 503 });
127
128/**
129 * Claim `POST {path}` deliveries: verify the OIDC token (audience + the
130 * host's own service account), then run `handle`. Any other request is
131 * left for the next listener or the user's `fetch`.
132 */
133export const listenForDeliveries = (
134 host: PushHost,
135 path: string,
136 handle: (
137 request: HttpServerRequest,
138 ) => Effect.Effect<HttpServerResponse.HttpServerResponse, never, any>,
139) =>
140 host.listen((request) => {
141 const pathname = new URL(request.url, "http://host").pathname;
142 if (pathname !== path) return undefined;
143 return Effect.gen(function* () {
144 if (request.method !== "POST") {
145 return HttpServerResponse.text("method not allowed", { status: 405 });
146 }
147 // Infrastructure failures (metadata server, Google's signing keys)
148 // answer 503 so the sender retries instead of dropping the event.
149 const email = yield* ownServiceAccount.pipe(
150 Effect.tapError((error) =>
151 Effect.logWarning("Cannot read the instance service account", error),
152 ),
153 Effect.option,
154 );
155 if (email._tag === "None") return unavailable;
156 const verified = yield* verifyGoogleIdToken({
157 authorization: request.headers["authorization"],
158 audience: expectedAudience(request, path),
159 email: email.value,
160 }).pipe(
161 Effect.tapError((error) =>
162 Effect.logWarning("Cannot load Google's signing keys", error),
163 ),
164 Effect.option,
165 );
166 if (verified._tag === "None") return unavailable;
167 if (!verified.value) {
168 yield* Effect.logWarning(
169 `Rejected delivery to ${path}: invalid OIDC token (aud=${unverifiedAudience(request.headers["authorization"]) ?? "none"})`,
170 );
171 return unauthorized;
172 }
173 return yield* handle(request);
174 });
175 });