1import { EnvironmentId, HubError, authScopeResponse } from "@t3tools/contracts";
2import * as Context from "effect/Context";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";
6import * as Schema from "effect/Schema";
7import * as Redacted from "effect/Redacted";
8import * as Scope from "effect/Scope";
9import * as Stream from "effect/Stream";
10import * as Socket from "effect/socket/Socket";
11import * as NodeSocket from "@effect/platform-node/NodeSocket";
12import {
13 FetchHttpClient,
14 HttpClient,
15 HttpClientRequest,
16 HttpServerRequest,
17 HttpServerResponse,
18} from "effect/http";
19import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
20import * as SessionStore from "../auth/SessionStore.ts";
21import * as Hub from "./Hub.ts";
22
23const isHubError = Schema.is(HubError);
24
25export class HubProxy extends Context.Service<
26 HubProxy,
27 {
28 readonly forward: (
29 request: HttpServerRequest.HttpServerRequest,
30 id: EnvironmentId,
31 path: string,
32 ) => Effect.Effect<
33 HttpServerResponse.HttpServerResponse,
34 | HubError
35 | EnvironmentAuth.ServerAuthCredentialError
36 | EnvironmentAuth.ServerAuthInternalError,
37 Scope.Scope
38 >;
39 }
40>()("t3/environment/HubProxy") {}
41
42const forwardHeaders = (request: HttpServerRequest.HttpServerRequest, token: string) => {
43 const headers: Record<string, string> = {
44 authorization: `Bearer ${token}`,
45 "accept-encoding": "identity",
46 };
47 const dropped = new Set([
48 "authorization",
49 "cookie",
50 "dpop",
51 "host",
52 "origin",
53 "connection",
54 "upgrade",
55 "sec-websocket-key",
56 "sec-websocket-version",
57 "sec-websocket-extensions",
58 "sec-websocket-protocol",
59 "content-length",
60 "accept-encoding",
61 "proxy-authorization",
62 "proxy-authenticate",
63 "te",
64 "trailer",
65 "keep-alive",
66 "forwarded",
67 "x-forwarded-host",
68 "x-forwarded-proto",
69 "x-forwarded-prefix",
70 ]);
71 for (const [name, value] of Object.entries(request.headers)) {
72 if (value !== undefined && !dropped.has(name)) headers[name] = value;
73 }
74 return headers;
75};
76
77const pump = (source: Socket.Socket, sink: Socket.Writer) =>
78 Effect.gen(function* () {
79 const { pull } = yield* source.reader;
80 while (true) yield* sink.writeAll(yield* pull);
81 });
82
83const make = Effect.gen(function* () {
84 const hub = yield* Hub.Hub;
85 const auth = yield* EnvironmentAuth.EnvironmentAuth;
86 const sessions = yield* SessionStore.SessionStore;
87 const client = HttpClient.withScope(yield* HttpClient.HttpClient);
88 return HubProxy.of({
89 forward: (request, id, path) =>
90 Effect.gen(function* () {
91 const parsed = HttpServerRequest.toURL(request);
92 if (Option.isNone(parsed)) return HttpServerResponse.empty({ status: 400 });
93 if (request.method === "GET" && path === "/.well-known/t3/environment") {
94 return HttpServerResponse.jsonUnsafe(yield* hub.descriptor(id));
95 }
96 const session = yield* auth.authenticateWebSocketUpgrade(request);
97 const decodedPath = yield* Effect.try({
98 try: () => decodeURIComponent(path),
99 catch: () => new HubError({ reason: "not-found" }),
100 });
101 if (
102 decodedPath !== path &&
103 /^\/(api\/auth(?:\/|$)|oauth(?:\/|$)|\.well-known\/oauth)/i.test(decodedPath)
104 ) {
105 return HttpServerResponse.empty({ status: 404 });
106 }
107 if (request.method === "GET" && path === "/api/auth/session") {
108 const scopes = yield* hub.authorize(id, session);
109 return HttpServerResponse.jsonUnsafe(
110 {
111 authenticated: true,
112 auth: yield* auth.getDescriptor(),
113 ...authScopeResponse(scopes),
114 sessionMethod: session.method,
115 ...(session.expiresAt ? { expiresAt: session.expiresAt } : {}),
116 },
117 { headers: { "cache-control": "no-store" } },
118 );
119 }
120 if (request.method === "POST" && path === "/api/auth/websocket-ticket") {
121 yield* hub.authorize(id, session);
122 return HttpServerResponse.jsonUnsafe(yield* auth.issueWebSocketTicket(session), {
123 headers: { "cache-control": "no-store" },
124 });
125 }
126 // These responses can reveal or mint private leaf credentials and must stay on the hub.
127 if (
128 path.startsWith("/api/auth/") ||
129 path === "/oauth" ||
130 path.startsWith("/oauth/") ||
131 path.startsWith("/.well-known/oauth")
132 ) {
133 return HttpServerResponse.empty({ status: 404 });
134 }
135 const upstream = yield* hub.connect(id, session);
136 const invalidated = Effect.raceFirst(
137 upstream.invalidated,
138 sessions.awaitInvalidation(session.sessionId),
139 );
140 const url = new URL(upstream.url);
141 url.pathname += path.slice(1);
142 url.search = parsed.value.search;
143 url.searchParams.delete("wsTicket");
144 const headers = forwardHeaders(request, upstream.token);
145 if (request.headers.upgrade?.toLowerCase() === "websocket") {
146 url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
147 return yield* Effect.scoped(
148 Effect.gen(function* () {
149 const socket = yield* Socket.makeWebSocket(url.href, {
150 headers,
151 openTimeout: "10 seconds",
152 }).pipe(Effect.provide(NodeSocket.layerWebSocketConstructorWS));
153 const client = yield* request.upgrade;
154 const clientWriter = yield* client.writer;
155 const upstreamWriter = yield* socket.writer;
156 yield* Effect.raceFirst(
157 Effect.raceFirst(pump(socket, clientWriter), pump(client, upstreamWriter)),
158 invalidated,
159 ).pipe(Effect.ignoreCause);
160 return HttpServerResponse.empty();
161 }),
162 );
163 }
164 const outgoing = HttpClientRequest.make(request.method)(url.href).pipe(
165 HttpClientRequest.setHeaders(headers),
166 request.method === "GET" || request.method === "HEAD"
167 ? (self) => self
168 : HttpClientRequest.bodyStream(request.stream),
169 );
170 const response = yield* Effect.raceFirst(
171 client
172 .execute(outgoing)
173 .pipe(Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" })),
174 invalidated.pipe(Effect.andThen(Effect.fail(new HubError({ reason: "credential" })))),
175 );
176 const responseHeaders: Record<string, string> = {};
177 for (const [name, value] of Object.entries(response.headers)) {
178 if (
179 value === undefined ||
180 ![
181 "content-type",
182 "content-length",
183 "content-range",
184 "accept-ranges",
185 "content-disposition",
186 "etag",
187 "last-modified",
188 "vary",
189 "content-security-policy",
190 "x-content-type-options",
191 "retry-after",
192 ].includes(name)
193 )
194 continue;
195 responseHeaders[name] = value;
196 }
197 if (response.headers["content-encoding"]) delete responseHeaders["content-length"];
198 if (response.headers.location) {
199 const destination = new URL(response.headers.location, url);
200 const privateBase = new URL(upstream.url);
201 if (
202 destination.origin === privateBase.origin &&
203 destination.pathname.startsWith(privateBase.pathname)
204 ) {
205 const prefix = parsed.value.pathname.slice(
206 0,
207 parsed.value.pathname.length - path.length + 1,
208 );
209 responseHeaders.location = `${prefix}${destination.pathname.slice(privateBase.pathname.length)}${destination.search}${destination.hash}`;
210 }
211 }
212 responseHeaders["cache-control"] = "no-store, no-transform";
213 return HttpServerResponse.stream(response.stream.pipe(Stream.interruptWhen(invalidated)), {
214 status: response.status,
215 headers: responseHeaders,
216 ...(responseHeaders["content-type"]
217 ? { contentType: responseHeaders["content-type"] }
218 : {}),
219 });
220 }).pipe(
221 Effect.mapError((cause) =>
222 isHubError(cause) ||
223 EnvironmentAuth.isServerAuthCredentialError(cause) ||
224 EnvironmentAuth.isServerAuthInternalError(cause)
225 ? cause
226 : new HubError({ reason: "upstream", cause: Redacted.make(cause) }),
227 ),
228 ),
229 });
230});
231
232export const layer = Layer.effect(HubProxy, make);