1import {
2 AuthRelayReadScope,
3 AuthRelayWriteScope,
4 EnvironmentCloudEndpointUnavailableError,
5 EnvironmentHttpApi,
6 EnvironmentHttpBadRequestError,
7 EnvironmentHttpConflictError,
8 EnvironmentHttpForbiddenError,
9 EnvironmentHttpInternalServerError,
10 EnvironmentHttpUnauthorizedError,
11} from "@t3tools/contracts";
12import * as Effect from "effect/Effect";
13import * as HttpEffect from "effect/http/HttpEffect";
14import { HttpServerRequest, HttpServerResponse } from "effect/http";
15import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder";
16
17import { requireEnvironmentScope } from "../auth/http.ts";
18import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
19import * as CloudLink from "./CloudLink.ts";
20import type { RelayRequestError } from "./relayResponse.ts";
21
22const CLOUD_CREDENTIAL_RESPONSE_HEADERS = {
23 "cache-control": "no-store",
24 pragma: "no-cache",
25} as const;
26
27const appendCloudCredentialResponseHeaders = HttpEffect.appendPreResponseHandler(
28 (_request, response) =>
29 Effect.succeed(HttpServerResponse.setHeaders(response, CLOUD_CREDENTIAL_RESPONSE_HEADERS)),
30);
31
32const internalServerError = (error: { readonly message: string }, cause: unknown) =>
33 Effect.logError(error.message, { cause }).pipe(
34 Effect.andThen(Effect.fail(new EnvironmentHttpInternalServerError({ message: error.message }))),
35 );
36
37const relayFailure = (error: RelayRequestError) => {
38 const message = error.message;
39 switch (error.rejection) {
40 case "unauthorized":
41 return Effect.fail(new EnvironmentHttpUnauthorizedError({ message }));
42 case "forbidden":
43 return Effect.fail(new EnvironmentHttpForbiddenError({ message }));
44 case "rejected":
45 return Effect.fail(new EnvironmentHttpBadRequestError({ message }));
46 case "unavailable":
47 return Effect.fail(new EnvironmentHttpInternalServerError({ message }));
48 }
49};
50
51const badRequest = (error: { readonly message: string }) =>
52 Effect.fail(new EnvironmentHttpBadRequestError({ message: error.message }));
53const unauthorized = (error: { readonly message: string }) =>
54 Effect.fail(new EnvironmentHttpUnauthorizedError({ message: error.message }));
55const conflict = (error: { readonly message: string }) =>
56 Effect.fail(new EnvironmentHttpConflictError({ message: error.message }));
57
58/** How a connect route answers each CloudLink failure. Messages carry through unchanged. */
59const connectErrorCases = {
60 CloudLinkRelayConfigInvalidError: badRequest,
61 CloudLinkOriginInvalidError: badRequest,
62 CloudLinkNotLinkedError: badRequest,
63 CloudLinkAccountMismatchError: conflict,
64 CloudLinkAuthorizationMissingError: unauthorized,
65 CloudLinkProofRejectedError: unauthorized,
66 CloudLinkProofReplayedError: conflict,
67 CloudLinkTunnelSupersededError: conflict,
68 CloudLinkInternalError: (error: CloudLink.CloudLinkInternalError) =>
69 internalServerError(error, error.cause),
70 RelayRequestError: relayFailure,
71} as const;
72
73type ConnectFailure =
74 | Exclude<CloudLink.CloudLinkError, CloudLink.CloudLinkEndpointUnavailableError>
75 | EnvironmentAuth.ServerAuthInternalError
76 | RelayRequestError;
77
78/** Internal failures are logged with their cause before the route answers 500. */
79const toHttpError = <A, R>(effect: Effect.Effect<A, ConnectFailure, R>) =>
80 effect.pipe(
81 Effect.catchTags(connectErrorCases),
82 Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>
83 internalServerError(error, error),
84 ),
85 );
86
87/** Relay configuration is the one route that answers 503 when the tunnel cannot serve. */
88const toHttpErrorOrUnavailable = <A, R>(
89 effect: Effect.Effect<A, ConnectFailure | CloudLink.CloudLinkEndpointUnavailableError, R>,
90) =>
91 effect.pipe(
92 Effect.catchTags({
93 ...connectErrorCases,
94 CloudLinkEndpointUnavailableError: (error) =>
95 Effect.fail(
96 new EnvironmentCloudEndpointUnavailableError({
97 message: error.message,
98 endpointRuntimeStatus: error.endpointRuntimeStatus,
99 }),
100 ),
101 }),
102 Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>
103 internalServerError(error, error),
104 ),
105 );
106
107export const layer = HttpApiBuilder.group(
108 EnvironmentHttpApi,
109 "connect",
110 Effect.fnUntraced(function* (handlers) {
111 const cloudLink = yield* CloudLink.CloudLink;
112 return handlers
113 .handle("linkProof", ({ payload }) =>
114 Effect.gen(function* () {
115 yield* requireEnvironmentScope(AuthRelayWriteScope);
116 const request = yield* HttpServerRequest.HttpServerRequest;
117 const proof = yield* toHttpError(cloudLink.linkProof(payload, request));
118 yield* appendCloudCredentialResponseHeaders;
119 return proof;
120 }),
121 )
122 .handle("relayConfig", ({ payload }) =>
123 requireEnvironmentScope(AuthRelayWriteScope).pipe(
124 Effect.andThen(toHttpErrorOrUnavailable(cloudLink.applyRelayConfig(payload))),
125 ),
126 )
127 .handle("linkState", () =>
128 requireEnvironmentScope(AuthRelayReadScope).pipe(
129 Effect.andThen(toHttpError(cloudLink.linkState())),
130 ),
131 )
132 .handle("unlink", () =>
133 requireEnvironmentScope(AuthRelayWriteScope).pipe(
134 Effect.andThen(toHttpError(cloudLink.unlink())),
135 ),
136 )
137 .handle("preferences", ({ payload }) =>
138 requireEnvironmentScope(AuthRelayWriteScope).pipe(
139 Effect.andThen(toHttpError(cloudLink.updatePreferences(payload))),
140 ),
141 )
142 .handle("health", ({ payload }) =>
143 toHttpError(cloudLink.answerHealthRequest(payload)).pipe(
144 Effect.tap(() => appendCloudCredentialResponseHeaders),
145 ),
146 )
147 .handle("mintCredential", ({ payload }) =>
148 toHttpError(cloudLink.mintCredential(payload)).pipe(
149 Effect.tap(() => appendCloudCredentialResponseHeaders),
150 ),
151 )
152 .handle("t3MintCredential", ({ payload }) =>
153 toHttpError(cloudLink.mintCredential(payload)).pipe(
154 Effect.tap(() => appendCloudCredentialResponseHeaders),
155 ),
156 );
157 }),
158);