1import type {
2 AuthBrowserSessionResult,
3 AuthClientMetadata,
4 AuthEnvironmentScope,
5 AuthGrantScope,
6 AuthPairingCredentialResult,
7 ServerAuthSessionMethod,
8 AuthSessionId,
9 AuthSessionState,
10} from "@t3tools/contracts";
11import { EnvironmentHttpCommonError, PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts";
12import type { EnvironmentHttpCommonError as EnvironmentHttpCommonErrorType } from "@t3tools/contracts";
13import * as Effect from "effect/Effect";
14import * as Schema from "effect/Schema";
15import { HttpClientError } from "effect/http";
16
17import {
18 getPairingTokenFromUrl,
19 stripPairingTokenFromUrl as stripPairingTokenUrl,
20} from "../../pairingUrl";
21
22import { PrimaryEnvironmentHttpClient } from "./httpClient";
23import { runPrimaryHttp } from "../../lib/runtime";
24
25const PrimaryEnvironmentRequestOperation = Schema.Literals([
26 "fetch-session-state",
27 "exchange-bootstrap-credential",
28 "fetch-environment-descriptor",
29 "create-pairing-credential",
30 "list-pairing-links",
31 "revoke-pairing-link",
32 "list-client-sessions",
33 "revoke-client-session",
34 "revoke-other-client-sessions",
35]);
36type PrimaryEnvironmentRequestOperation = typeof PrimaryEnvironmentRequestOperation.Type;
37
38export class PrimaryEnvironmentRequestError extends Schema.TaggedError<PrimaryEnvironmentRequestError>()(
39 "PrimaryEnvironmentRequestError",
40 {
41 operation: PrimaryEnvironmentRequestOperation,
42 status: Schema.Number,
43 pairingLinkId: Schema.optional(Schema.String),
44 sessionId: Schema.optional(Schema.String),
45 cause: Schema.Defect(),
46 },
47) {
48 static fromCause(input: {
49 readonly operation: PrimaryEnvironmentRequestOperation;
50 readonly cause: unknown;
51 readonly pairingLinkId?: string;
52 readonly sessionId?: string;
53 }): PrimaryEnvironmentRequestError {
54 const status = readHttpApiStatus(input.cause) ?? 500;
55 return new PrimaryEnvironmentRequestError({
56 operation: input.operation,
57 status,
58 ...(input.pairingLinkId !== undefined ? { pairingLinkId: input.pairingLinkId } : {}),
59 ...(input.sessionId !== undefined ? { sessionId: input.sessionId } : {}),
60 cause: input.cause,
61 });
62 }
63
64 override get message(): string {
65 return `Primary environment request failed during ${this.operation} (HTTP ${this.status}).`;
66 }
67}
68
69const isPrimaryEnvironmentRequestError = Schema.is(PrimaryEnvironmentRequestError);
70
71export class PrimaryEnvironmentPairingCredentialRejectedError extends Schema.TaggedError<PrimaryEnvironmentPairingCredentialRejectedError>()(
72 "PrimaryEnvironmentPairingCredentialRejectedError",
73 {
74 providedLength: Schema.Number,
75 cause: Schema.Defect(),
76 },
77) {
78 override get message(): string {
79 return "Invalid pairing token. Check the token and try again.";
80 }
81}
82
83export const isPrimaryEnvironmentPairingCredentialRejectedError = Schema.is(
84 PrimaryEnvironmentPairingCredentialRejectedError,
85);
86
87export class PrimaryEnvironmentAuthSessionTimeoutError extends Schema.TaggedError<PrimaryEnvironmentAuthSessionTimeoutError>()(
88 "PrimaryEnvironmentAuthSessionTimeoutError",
89 {
90 timeoutMs: Schema.Number,
91 elapsedMs: Schema.Number,
92 },
93) {
94 override get message(): string {
95 return "Timed out waiting for authenticated session after bootstrap.";
96 }
97}
98
99export class PrimaryEnvironmentPairingCredentialRequiredError extends Schema.TaggedError<PrimaryEnvironmentPairingCredentialRequiredError>()(
100 "PrimaryEnvironmentPairingCredentialRequiredError",
101 {
102 providedLength: Schema.Number,
103 },
104) {
105 override get message(): string {
106 return "Enter a pairing token to continue.";
107 }
108}
109
110const isEnvironmentHttpCommonError = Schema.is(EnvironmentHttpCommonError);
111
112export interface ServerPairingLinkRecord {
113 readonly id: string;
114 readonly scopes: ReadonlyArray<AuthEnvironmentScope>;
115 readonly subject: string;
116 readonly label?: string;
117 readonly createdAt: string;
118 readonly expiresAt: string;
119}
120
121export interface ServerClientSessionRecord {
122 readonly sessionId: AuthSessionId;
123 readonly subject: string;
124 readonly scopes: ReadonlyArray<AuthEnvironmentScope>;
125 readonly method: ServerAuthSessionMethod;
126 readonly client: AuthClientMetadata;
127 readonly issuedAt: string;
128 readonly expiresAt: string;
129 readonly lastConnectedAt: string | null;
130 readonly connected: boolean;
131 readonly current: boolean;
132}
133
134type ServerAuthGateState =
135 | { status: "authenticated" }
136 | {
137 status: "requires-auth";
138 auth: AuthSessionState["auth"];
139 errorMessage?: string;
140 };
141
142let bootstrapPromise: Promise<ServerAuthGateState> | null = null;
143let resolvedAuthenticatedGateState: ServerAuthGateState | null = null;
144let explicitPairingRequested = false;
145const AUTH_SESSION_ESTABLISH_TIMEOUT_MS = 2_000;
146const AUTH_SESSION_ESTABLISH_STEP_MS = 100;
147
148export function peekPairingTokenFromUrl(): string | null {
149 return getPairingTokenFromUrl(new URL(window.location.href));
150}
151
152export function stripPairingTokenFromUrl() {
153 const url = new URL(window.location.href);
154 const next = stripPairingTokenUrl(url);
155 if (next.toString() === url.toString()) {
156 return;
157 }
158 window.history.replaceState({}, document.title, next.toString());
159}
160
161export function takePairingTokenFromUrl(): string | null {
162 const token = peekPairingTokenFromUrl();
163 if (!token) {
164 return null;
165 }
166 stripPairingTokenFromUrl();
167 return token;
168}
169
170function getDesktopBootstrapCredential(): string | null {
171 // Both backends share the same bootstrap token (DesktopBackendConfiguration
172 // mints one tokenRef and feeds it to both resolvers), so picking the
173 // primary entry is fine even when the WSL backend is also registered.
174 const bootstraps = window.desktopBridge?.getLocalEnvironmentBootstraps() ?? [];
175 const primary = bootstraps.find((entry) => entry.id === PRIMARY_LOCAL_ENVIRONMENT_ID);
176 return typeof primary?.bootstrapToken === "string" && primary.bootstrapToken.length > 0
177 ? primary.bootstrapToken
178 : null;
179}
180
181export async function fetchSessionState(): Promise<AuthSessionState> {
182 return retryTransientBootstrap(async () => {
183 try {
184 return await runPrimaryHttp(
185 PrimaryEnvironmentHttpClient.pipe(
186 Effect.flatMap((client) => client.auth.session({ headers: {} })),
187 ),
188 );
189 } catch (error) {
190 throw PrimaryEnvironmentRequestError.fromCause({
191 operation: "fetch-session-state",
192 cause: error,
193 });
194 }
195 });
196}
197
198function readHttpApiStatus(error: unknown): number | null {
199 if (isEnvironmentHttpCommonError(error)) {
200 return readEnvironmentHttpErrorStatus(error);
201 }
202 return HttpClientError.isHttpClientError(error) && error.response !== undefined
203 ? error.response.status
204 : null;
205}
206
207function readEnvironmentHttpErrorStatus(error: EnvironmentHttpCommonErrorType): number {
208 switch (error._tag) {
209 case "EnvironmentRequestInvalidError":
210 return 400;
211 case "EnvironmentAuthInvalidError":
212 return 401;
213 case "EnvironmentScopeRequiredError":
214 case "EnvironmentOperationForbiddenError":
215 return 403;
216 case "EnvironmentResourceNotFoundError":
217 return 404;
218 case "EnvironmentInternalError":
219 return 500;
220 }
221}
222
223async function exchangeBootstrapCredential(credential: string): Promise<AuthBrowserSessionResult> {
224 return retryTransientBootstrap(async () => {
225 try {
226 return await runPrimaryHttp(
227 PrimaryEnvironmentHttpClient.pipe(
228 Effect.flatMap((client) => client.auth.browserSession({ payload: { credential } })),
229 ),
230 );
231 } catch (error) {
232 if (
233 isEnvironmentHttpCommonError(error) &&
234 error._tag === "EnvironmentAuthInvalidError" &&
235 error.reason === "invalid_credential"
236 ) {
237 throw new PrimaryEnvironmentPairingCredentialRejectedError({
238 providedLength: credential.length,
239 cause: error,
240 });
241 }
242 throw PrimaryEnvironmentRequestError.fromCause({
243 operation: "exchange-bootstrap-credential",
244 cause: error,
245 });
246 }
247 });
248}
249
250async function waitForAuthenticatedSessionAfterBootstrap(): Promise<AuthSessionState> {
251 const startedAt = Date.now();
252
253 while (true) {
254 const session = await fetchSessionState();
255 if (session.authenticated) {
256 return session;
257 }
258
259 const elapsedMs = Date.now() - startedAt;
260 if (elapsedMs >= AUTH_SESSION_ESTABLISH_TIMEOUT_MS) {
261 throw new PrimaryEnvironmentAuthSessionTimeoutError({
262 timeoutMs: AUTH_SESSION_ESTABLISH_TIMEOUT_MS,
263 elapsedMs,
264 });
265 }
266
267 await waitForBootstrapRetry(AUTH_SESSION_ESTABLISH_STEP_MS);
268 }
269}
270
271const TRANSIENT_BOOTSTRAP_STATUS_CODES = new Set([502, 503, 504]);
272const BOOTSTRAP_RETRY_TIMEOUT_MS = 15_000;
273const BOOTSTRAP_RETRY_STEP_MS = 500;
274
275export async function retryTransientBootstrap<T>(operation: () => Promise<T>): Promise<T> {
276 const startedAt = Date.now();
277 while (true) {
278 try {
279 return await operation();
280 } catch (error) {
281 if (!isTransientBootstrapError(error)) {
282 throw error;
283 }
284
285 if (Date.now() - startedAt >= BOOTSTRAP_RETRY_TIMEOUT_MS) {
286 throw error;
287 }
288
289 await waitForBootstrapRetry(BOOTSTRAP_RETRY_STEP_MS);
290 }
291 }
292}
293
294function waitForBootstrapRetry(delayMs: number): Promise<void> {
295 return new Promise((resolve) => {
296 setTimeout(resolve, delayMs);
297 });
298}
299
300function isTransientBootstrapError(error: unknown): boolean {
301 if (isPrimaryEnvironmentRequestError(error)) {
302 return TRANSIENT_BOOTSTRAP_STATUS_CODES.has(error.status);
303 }
304
305 if (error instanceof TypeError) {
306 return true;
307 }
308
309 return error instanceof DOMException && error.name === "AbortError";
310}
311
312async function bootstrapServerAuth(urlCredential: string | null): Promise<ServerAuthGateState> {
313 const currentSession = await fetchSessionState();
314 if (currentSession.authenticated && !urlCredential) {
315 return { status: "authenticated" };
316 }
317
318 const bootstrapCredential = urlCredential ?? getDesktopBootstrapCredential();
319 if (!bootstrapCredential) {
320 return {
321 status: "requires-auth",
322 auth: currentSession.auth,
323 };
324 }
325
326 try {
327 await exchangeBootstrapCredential(bootstrapCredential);
328 await waitForAuthenticatedSessionAfterBootstrap();
329 return { status: "authenticated" };
330 } catch (error) {
331 return {
332 status: "requires-auth",
333 auth: currentSession.auth,
334 errorMessage: error instanceof Error ? error.message : "Authentication failed.",
335 };
336 }
337}
338
339export async function submitServerAuthCredential(credential: string): Promise<void> {
340 const trimmedCredential = credential.trim();
341 if (!trimmedCredential) {
342 throw new PrimaryEnvironmentPairingCredentialRequiredError({
343 providedLength: credential.length,
344 });
345 }
346
347 resolvedAuthenticatedGateState = null;
348 await exchangeBootstrapCredential(trimmedCredential);
349 await waitForAuthenticatedSessionAfterBootstrap();
350 resolvedAuthenticatedGateState = { status: "authenticated" };
351 bootstrapPromise = null;
352 explicitPairingRequested = false;
353 stripPairingTokenFromUrl();
354}
355
356export async function createServerPairingCredential(input?: {
357 readonly label?: string;
358 readonly scopes?: ReadonlyArray<AuthGrantScope>;
359}): Promise<AuthPairingCredentialResult> {
360 const trimmedLabel = input?.label?.trim();
361 try {
362 return await runPrimaryHttp(
363 PrimaryEnvironmentHttpClient.pipe(
364 Effect.flatMap((client) =>
365 client.auth.pairingCredential({
366 headers: {},
367 payload: {
368 ...(trimmedLabel ? { label: trimmedLabel } : {}),
369 ...(input?.scopes ? { scopes: input.scopes } : {}),
370 },
371 }),
372 ),
373 ),
374 );
375 } catch (error) {
376 throw PrimaryEnvironmentRequestError.fromCause({
377 operation: "create-pairing-credential",
378 cause: error,
379 });
380 }
381}
382
383export async function revokeServerPairingLink(id: string): Promise<void> {
384 try {
385 await runPrimaryHttp(
386 PrimaryEnvironmentHttpClient.pipe(
387 Effect.flatMap((client) => client.auth.revokePairingLink({ headers: {}, payload: { id } })),
388 ),
389 );
390 } catch (error) {
391 throw PrimaryEnvironmentRequestError.fromCause({
392 operation: "revoke-pairing-link",
393 pairingLinkId: id,
394 cause: error,
395 });
396 }
397}
398
399export async function revokeServerClientSession(sessionId: AuthSessionId): Promise<void> {
400 try {
401 await runPrimaryHttp(
402 PrimaryEnvironmentHttpClient.pipe(
403 Effect.flatMap((client) =>
404 client.auth.revokeClient({ headers: {}, payload: { sessionId } }),
405 ),
406 ),
407 );
408 } catch (error) {
409 throw PrimaryEnvironmentRequestError.fromCause({
410 operation: "revoke-client-session",
411 sessionId,
412 cause: error,
413 });
414 }
415}
416
417export async function revokeOtherServerClientSessions(): Promise<number> {
418 try {
419 const result = await runPrimaryHttp(
420 PrimaryEnvironmentHttpClient.pipe(
421 Effect.flatMap((client) => client.auth.revokeOtherClients({ headers: {} })),
422 ),
423 );
424 return result.revokedCount;
425 } catch (error) {
426 throw PrimaryEnvironmentRequestError.fromCause({
427 operation: "revoke-other-client-sessions",
428 cause: error,
429 });
430 }
431}
432
433export async function resolveInitialServerAuthGateState(): Promise<ServerAuthGateState> {
434 // An explicit pairing link replaces this browser's grant, even when the
435 // current cookie or a cached gate already authenticates it. Keep that intent
436 // after stripping the token, which causes the router to load this gate again.
437 if (window.location.pathname.replace(/\/+$/, "") !== "/pair") {
438 explicitPairingRequested = false;
439 } else if (peekPairingTokenFromUrl()) {
440 explicitPairingRequested = true;
441 }
442 if (explicitPairingRequested) {
443 const currentSession = await fetchSessionState();
444 return { status: "requires-auth", auth: currentSession.auth };
445 }
446
447 const urlCredential = takePairingTokenFromUrl();
448 const previousPromise = bootstrapPromise;
449 if (urlCredential) {
450 resolvedAuthenticatedGateState = null;
451 } else {
452 if (previousPromise) {
453 return previousPromise;
454 }
455
456 if (resolvedAuthenticatedGateState?.status === "authenticated") {
457 return resolvedAuthenticatedGateState;
458 }
459 }
460
461 const nextPromise = previousPromise
462 ? previousPromise
463 .catch(() => undefined)
464 .then(() => {
465 resolvedAuthenticatedGateState = null;
466 return bootstrapServerAuth(urlCredential);
467 })
468 : bootstrapServerAuth(urlCredential);
469 bootstrapPromise = nextPromise;
470 return nextPromise
471 .then((result) => {
472 if (bootstrapPromise === nextPromise && result.status === "authenticated") {
473 resolvedAuthenticatedGateState = result;
474 }
475 return result;
476 })
477 .finally(() => {
478 if (bootstrapPromise === nextPromise) {
479 bootstrapPromise = null;
480 }
481 });
482}
483
484export function __resetServerAuthBootstrapForTests() {
485 bootstrapPromise = null;
486 resolvedAuthenticatedGateState = null;
487 explicitPairingRequested = false;
488}