| 1 | import { |
| 2 | AuthRelayReadScope, |
| 3 | AuthRelayWriteScope, |
| 4 | EnvironmentCloudEndpointUnavailableError, |
| 5 | EnvironmentHttpApi, |
| 6 | EnvironmentHttpBadRequestError, |
| 7 | EnvironmentHttpConflictError, |
| 8 | EnvironmentHttpForbiddenError, |
| 9 | EnvironmentHttpInternalServerError, |
| 10 | EnvironmentHttpUnauthorizedError, |
| 11 | } from "@t3tools/contracts"; |
| 12 | import * as Effect from "effect/Effect"; |
| 13 | import * as HttpEffect from "effect/http/HttpEffect"; |
| 14 | import { HttpServerRequest, HttpServerResponse } from "effect/http"; |
| 15 | import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; |
| 16 | |
| 17 | import { requireEnvironmentScope } from "../auth/http.ts"; |
| 18 | import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; |
| 19 | import * as CloudLink from "./CloudLink.ts"; |
| 20 | import type { RelayRequestError } from "./relayResponse.ts"; |
| 21 | |
| 22 | const CLOUD_CREDENTIAL_RESPONSE_HEADERS = { |
| 23 | "cache-control": "no-store", |
| 24 | pragma: "no-cache", |
| 25 | } as const; |
| 26 | |
| 27 | const appendCloudCredentialResponseHeaders = HttpEffect.appendPreResponseHandler( |
| 28 | (_request, response) => |
| 29 | Effect.succeed(HttpServerResponse.setHeaders(response, CLOUD_CREDENTIAL_RESPONSE_HEADERS)), |
| 30 | ); |
| 31 | |
| 32 | const 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 | |
| 37 | const 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 | |
| 51 | const badRequest = (error: { readonly message: string }) => |
| 52 | Effect.fail(new EnvironmentHttpBadRequestError({ message: error.message })); |
| 53 | const unauthorized = (error: { readonly message: string }) => |
| 54 | Effect.fail(new EnvironmentHttpUnauthorizedError({ message: error.message })); |
| 55 | const 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. */ |
| 59 | const 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 | |
| 73 | type 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. */ |
| 79 | const 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. */ |
| 88 | const 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 | |
| 107 | export 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 | ); |