1import * as Effect from "effect/Effect";
2import * as Option from "effect/Option";
3import {
4 ORCHESTRATION_PROTOCOL_HEADER,
5 ORCHESTRATION_PROTOCOL_VERSION_TEXT,
6} from "@t3tools/contracts";
7import * as Result from "effect/Result";
8import { FetchHttpClient, type HttpMethod } from "effect/http";
9
10import { HostedAuthAccess } from "../platform/capabilities.ts";
11import type { RemoteEnvironmentAuthorization } from "../authorization/service.ts";
12import type { PreparedConnection, PreparedHttpAuthorization } from "../connection/model.ts";
13import type { ManagedRelayDpopSigner } from "../relay/managedRelay.ts";
14import {
15 executeEnvironmentHttpRequest,
16 makeEnvironmentHttpApiGroupClient,
17 makeEnvironmentHttpApiUrlBuilder,
18 RemoteEnvironmentAuthFetchError,
19 RemoteEnvironmentAuthTimeoutError,
20 type RemoteEnvironmentRequestError,
21} from "../rpc/http.ts";
22
23export interface EnvironmentHttpAuthHeaders {
24 readonly "x-csrf-token"?: string;
25 readonly authorization?: string;
26 readonly dpop?: string;
27}
28
29export function withOrchestrationProtocolHeader(
30 headers: EnvironmentHttpAuthHeaders,
31): EnvironmentHttpAuthHeaders & {
32 readonly [ORCHESTRATION_PROTOCOL_HEADER]: typeof ORCHESTRATION_PROTOCOL_VERSION_TEXT;
33} {
34 return {
35 ...headers,
36 [ORCHESTRATION_PROTOCOL_HEADER]: ORCHESTRATION_PROTOCOL_VERSION_TEXT,
37 };
38}
39
40/**
41 * Primary/local environments with no bearer or DPoP credential authenticate the
42 * browser via a session cookie. A cross-origin `fetch` does not send cookies by
43 * default, so those requests must opt into credentialed mode; bearer/DPoP
44 * connections carry their credential in a header and need no cookies. Applied
45 * per-request via `FetchHttpClient.RequestInit`, which the fetch client reads
46 * from the fiber context at request time.
47 */
48const withEnvironmentCredentials = <A, E, R>(
49 authorization: PreparedHttpAuthorization | null,
50 request: Effect.Effect<A, E, R>,
51): Effect.Effect<A, E, R> =>
52 authorization === null
53 ? request.pipe(Effect.provideService(FetchHttpClient.RequestInit, { credentials: "include" }))
54 : request;
55
56/**
57 * Build request-bound headers from the current environment credential:
58 * - primary/local connections carry no credential,
59 * - bearer connections send a static `Bearer` token,
60 * - relay connections send a `DPoP` access token with a freshly signed proof
61 * bound to this request's method and URL.
62 *
63 * The DPoP signer is passed in (not resolved from context) and is only required
64 * for relay/DPoP connections, so bearer/primary connections work even when no
65 * signer is available.
66 */
67const buildEnvironmentAuthHeaders = (
68 authorization: PreparedHttpAuthorization | null,
69 method: HttpMethod.HttpMethod,
70 url: string,
71 signer: Option.Option<ManagedRelayDpopSigner["Service"]>,
72): Effect.Effect<EnvironmentHttpAuthHeaders, RemoteEnvironmentAuthFetchError> =>
73 Effect.gen(function* () {
74 if (authorization === null) {
75 const hosted = yield* Effect.serviceOption(HostedAuthAccess);
76 if (Option.isNone(hosted)) return {};
77 return yield* hosted.value
78 .headers(new URL(url).origin)
79 .pipe(Effect.orElseSucceed(() => ({})));
80 }
81 if (authorization._tag === "Bearer") {
82 return { authorization: `Bearer ${authorization.token}` };
83 }
84 if (Option.isNone(signer)) {
85 return yield* new RemoteEnvironmentAuthFetchError({
86 message: "No DPoP signer is available to authorize the environment request.",
87 cause: authorization._tag,
88 });
89 }
90 const proof = yield* signer.value
91 .createProof({ method, url, accessToken: authorization.accessToken })
92 .pipe(
93 Effect.mapError(
94 (cause) =>
95 new RemoteEnvironmentAuthFetchError({
96 message: "Could not create the environment request authorization proof.",
97 cause,
98 }),
99 ),
100 );
101 return { authorization: `DPoP ${authorization.accessToken}`, dpop: proof };
102 });
103
104/**
105 * Resolve relay credentials at request time without replacing the live socket.
106 * A rejected credential gets one refresh and retry, with a new request-bound
107 * proof. Cookie and bearer requests keep their existing authentication behavior.
108 */
109export const executeAuthenticatedEnvironmentHttpRequest = <
110 Group extends Parameters<typeof makeEnvironmentHttpApiGroupClient>[1],
111 A,
112 E,
113 R,
114>(
115 input: Parameters<typeof executeEnvironmentRequest<Group, A, E, R>>[0],
116) => executeEnvironmentRequest(input).pipe(Effect.withSpan(ENVIRONMENT_REQUEST_SPAN));
117
118const ENVIRONMENT_REQUEST_SPAN = "clientRuntime.state.executeAuthenticatedEnvironmentHttpRequest";
119
120const executeEnvironmentRequest = Effect.fnUntraced(function* <
121 Group extends Parameters<typeof makeEnvironmentHttpApiGroupClient>[1],
122 A,
123 E,
124 R,
125>(input: {
126 readonly prepared: PreparedConnection;
127 readonly signer: Option.Option<ManagedRelayDpopSigner["Service"]>;
128 readonly remoteAuthorization?: Option.Option<RemoteEnvironmentAuthorization["Service"]>;
129 readonly method: HttpMethod.HttpMethod;
130 /**
131 * The URL to sign, from the group's contract URL builder. It encodes path
132 * params exactly like the request client, so the DPoP proof matches the
133 * URL actually sent.
134 */
135 readonly url: (urls: ReturnType<typeof makeEnvironmentHttpApiUrlBuilder>[Group]) => string;
136 readonly timeoutMs: number;
137 readonly group: Group;
138 readonly request: (input: {
139 readonly client: Effect.Success<ReturnType<typeof makeEnvironmentHttpApiGroupClient<Group>>>;
140 readonly headers: EnvironmentHttpAuthHeaders;
141 }) => Effect.Effect<A, E, R>;
142 /** Some endpoints report rejected credentials in a successful response. */
143 readonly isUnauthorizedResponse?: (response: NoInfer<A>) => boolean;
144}): Effect.fn.Return<
145 A,
146 RemoteEnvironmentRequestError,
147 Effect.Services<ReturnType<typeof makeEnvironmentHttpApiGroupClient<Group>>> | R
148> {
149 let httpBaseUrl = input.prepared.httpBaseUrl;
150 const requestUrlFor = (baseUrl: string) =>
151 input.url(makeEnvironmentHttpApiUrlBuilder(baseUrl)[input.group]);
152 return yield* Effect.gen(function* () {
153 let rejectedAccessToken: string | undefined;
154 for (;;) {
155 let authorization = input.prepared.httpAuthorization;
156 if (authorization?._tag === "Dpop") {
157 const remote = input.remoteAuthorization;
158 if (remote === undefined || Option.isNone(remote)) {
159 return yield* new RemoteEnvironmentAuthFetchError({
160 message: "No relay authorization service is available for the environment request.",
161 cause: input.prepared.target._tag,
162 });
163 }
164 const current = yield* remote.value
165 .authorizeDpopHttp({
166 expectedEnvironmentId: input.prepared.environmentId,
167 ...(rejectedAccessToken === undefined ? {} : { rejectedAccessToken }),
168 })
169 .pipe(
170 Effect.mapError(
171 (cause) =>
172 new RemoteEnvironmentAuthFetchError({
173 message: "Could not authorize the environment request.",
174 cause,
175 }),
176 ),
177 );
178 // A learned direct route keeps its own origin; only the token renews.
179 if (input.prepared.target._tag === "RelayConnectionTarget") {
180 httpBaseUrl = current.httpBaseUrl;
181 }
182 authorization = current.httpAuthorization;
183 }
184
185 const requestUrl = requestUrlFor(httpBaseUrl);
186 const client = yield* makeEnvironmentHttpApiGroupClient(httpBaseUrl, input.group);
187 const headers = yield* buildEnvironmentAuthHeaders(
188 authorization,
189 input.method,
190 requestUrl,
191 input.signer,
192 );
193 const result = yield* executeEnvironmentHttpRequest(
194 requestUrl,
195 input.timeoutMs,
196 withEnvironmentCredentials(authorization, input.request({ client, headers })),
197 ).pipe(Effect.result);
198
199 if (Result.isFailure(result)) {
200 if (
201 authorization?._tag === "Dpop" &&
202 rejectedAccessToken === undefined &&
203 result.failure._tag === "EnvironmentAuthInvalidError" &&
204 result.failure.reason === "invalid_credential"
205 ) {
206 rejectedAccessToken = authorization.accessToken;
207 continue;
208 }
209 return yield* result.failure;
210 }
211
212 if (
213 authorization?._tag === "Dpop" &&
214 input.isUnauthorizedResponse?.(result.success) === true
215 ) {
216 if (rejectedAccessToken === undefined) {
217 rejectedAccessToken = authorization.accessToken;
218 continue;
219 }
220 return yield* new RemoteEnvironmentAuthFetchError({
221 message: "The environment rejected the renewed session authorization.",
222 cause: result.success,
223 });
224 }
225 return result.success;
226 }
227 }).pipe(
228 Effect.timeoutOrElse({
229 duration: input.timeoutMs,
230 orElse: () =>
231 Effect.fail(
232 new RemoteEnvironmentAuthTimeoutError(requestUrlFor(httpBaseUrl), input.timeoutMs),
233 ),
234 }),
235 );
236});