Open History to see the last commit
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 ManagedLimits from "../policy/managedLimits.ts";
20import * as CloudLink from "./CloudLink.ts";
21import * as ConnectReachability from "./ConnectReachability.ts";
22import { canLinkHere } from "./linkChecks.ts";
23import type { RelayRequestError } from "./relayResponse.ts";
24import { traceRelayRequest } from "./traceRelayRequest.ts";
25
26const CLOUD_CREDENTIAL_RESPONSE_HEADERS = {
27 "cache-control": "no-store",
28 pragma: "no-cache",
29} as const;
30
31const appendCloudCredentialResponseHeaders = HttpEffect.appendPreResponseHandler(
32 (_request, response) =>
33 Effect.succeed(HttpServerResponse.setHeaders(response, CLOUD_CREDENTIAL_RESPONSE_HEADERS)),
34);
35
36const internalServerError = (error: { readonly message: string }, cause: unknown) =>
37 Effect.logError(error.message, { cause }).pipe(
38 Effect.andThen(Effect.fail(new EnvironmentHttpInternalServerError({ message: error.message }))),
39 );
40
41const relayFailure = (error: RelayRequestError) => {
42 const message = error.message;
43 switch (error.rejection) {
44 case "unauthorized":
45 return Effect.fail(new EnvironmentHttpUnauthorizedError({ message }));
46 case "forbidden":
47 return Effect.fail(new EnvironmentHttpForbiddenError({ message }));
48 case "rejected":
49 return Effect.fail(new EnvironmentHttpBadRequestError({ message }));
50 case "unavailable":
51 return Effect.fail(new EnvironmentHttpInternalServerError({ message }));
52 }
53};
54
55const badRequest = (error: { readonly message: string }) =>
56 Effect.fail(new EnvironmentHttpBadRequestError({ message: error.message }));
57const unauthorized = (error: { readonly message: string }) =>
58 Effect.fail(new EnvironmentHttpUnauthorizedError({ message: error.message }));
59const conflict = (error: { readonly message: string }) =>
60 Effect.fail(new EnvironmentHttpConflictError({ message: error.message }));
61
62const connectErrorCases = {
63 CloudLinkRelayConfigInvalidError: badRequest,
64 CloudLinkOriginInvalidError: badRequest,
65 CloudLinkNotLinkedError: badRequest,
66 CloudLinkAccountMismatchError: conflict,
67 CloudLinkAuthorizationMissingError: unauthorized,
68 CloudLinkProofRejectedError: unauthorized,
69 CloudLinkProofReplayedError: conflict,
70 CloudLinkTunnelSupersededError: conflict,
71 ManagedPolicyRefusedError: (error: ManagedLimits.ManagedPolicyRefusedError) =>
72 Effect.fail(new EnvironmentHttpForbiddenError({ message: error.message })),
73 CloudLinkInternalError: (error: CloudLink.CloudLinkInternalError) =>
74 internalServerError(error, error.cause),
75 RelayRequestError: relayFailure,
76} as const;
77
78type ConnectFailure =
79 | Exclude<CloudLink.CloudLinkError, CloudLink.CloudLinkEndpointUnavailableError>
80 | EnvironmentAuth.ServerAuthInternalError
81 | ManagedLimits.ManagedPolicyRefusedError
82 | RelayRequestError;
83
84const toHttpError = <A, R>(effect: Effect.Effect<A, ConnectFailure, R>) =>
85 effect.pipe(
86 Effect.catchTags(connectErrorCases),
87 Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>
88 internalServerError(error, error),
89 ),
90 );
91
92const toHttpErrorOrUnavailable = <A, R>(
93 effect: Effect.Effect<A, ConnectFailure | CloudLink.CloudLinkEndpointUnavailableError, R>,
94) =>
95 effect.pipe(
96 Effect.catchTags({
97 ...connectErrorCases,
98 CloudLinkEndpointUnavailableError: (error) =>
99 Effect.fail(
100 new EnvironmentCloudEndpointUnavailableError({
101 message: error.message,
102 endpointRuntimeStatus: error.endpointRuntimeStatus,
103 }),
104 ),
105 }),
106 Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>
107 internalServerError(error, error),
108 ),
109 );
110
111export const layer = HttpApiBuilder.group(
112 EnvironmentHttpApi,
113 "connect",
114 Effect.fnUntraced(function* (handlers) {
115 const cloudLink = yield* CloudLink.CloudLink;
116 const reachability = yield* ConnectReachability.ConnectReachability;
117 return handlers
118 .handle("linkProof", ({ payload }) =>
119 Effect.gen(function* () {
120 yield* requireEnvironmentScope(AuthRelayWriteScope);
121 const request = yield* HttpServerRequest.HttpServerRequest;
122 const proof = yield* toHttpError(cloudLink.linkProof(payload, request));
123 yield* appendCloudCredentialResponseHeaders;
124 return proof;
125 }),
126 )
127 .handle("relayConfig", ({ payload }) =>
128 requireEnvironmentScope(AuthRelayWriteScope).pipe(
129 Effect.andThen(toHttpErrorOrUnavailable(cloudLink.applyRelayConfig(payload))),
130 ),
131 )
132 .handle("linkState", () =>
133 Effect.gen(function* () {
134 const principal = yield* requireEnvironmentScope(AuthRelayReadScope);
135 const state = yield* toHttpError(cloudLink.linkState());
136 const request = yield* HttpServerRequest.HttpServerRequest;
137 return { ...state, linkableHere: canLinkHere(request, principal.subject) };
138 }),
139 )
140 .handle("checkReachability", () =>
141 requireEnvironmentScope(AuthRelayReadScope).pipe(Effect.andThen(reachability.check)),
142 )
143 .handle("unlink", () =>
144 requireEnvironmentScope(AuthRelayWriteScope).pipe(
145 Effect.andThen(toHttpError(cloudLink.unlink())),
146 ),
147 )
148 .handle("preferences", ({ payload }) =>
149 requireEnvironmentScope(AuthRelayWriteScope).pipe(
150 Effect.andThen(toHttpError(cloudLink.updatePreferences(payload))),
151 ),
152 )
153 .handle("health", ({ payload }) =>
154 toHttpError(cloudLink.answerHealthRequest(payload)).pipe(
155 Effect.tap(() => appendCloudCredentialResponseHeaders),
156 ),
157 )
158 .handle("t3MintCredential", ({ payload }) =>
159 traceRelayRequest(
160 toHttpError(cloudLink.mintCredential(payload)).pipe(
161 Effect.tap(() => appendCloudCredentialResponseHeaders),
162 ),
163 ),
164 );
165 }),
166);