1import type { AuthSessionState, EnvironmentId, ServerConfig } from "@t3tools/contracts";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Option from "effect/Option";
5import * as Stream from "effect/Stream";
6import * as SubscriptionRef from "effect/SubscriptionRef";
7import { HttpClient } from "effect/http";
8import { AsyncResult, Atom } from "effect/reactivity";
9
10import * as RemoteEnvironmentAuthorization from "../authorization/service.ts";
11import * as EnvironmentRegistry from "../connection/registry.ts";
12import type { PreparedConnection } from "../connection/model.ts";
13import * as EnvironmentSupervisor from "../connection/supervisor.ts";
14import * as ManagedRelay from "../relay/managedRelay.ts";
15import { safeErrorLogAttributes } from "../errors/safeLog.ts";
16import { executeAuthenticatedEnvironmentHttpRequest } from "./environmentHttpAuth.ts";
17import { followStreamInEnvironment } from "./environmentStreams.ts";
18
19/** @public Required to name the error in consumers' inferred session results. */
20export class SessionHttpClientUnavailable extends Data.TaggedError(
21 "SessionHttpClientUnavailable",
22) {}
23
24function initialConfigOption<E>(
25 initialConfig: Effect.Effect<ServerConfig, E>,
26): Effect.Effect<Option.Option<ServerConfig>> {
27 return initialConfig.pipe(
28 Effect.asSome,
29 Effect.catch((error) =>
30 Effect.logWarning("Could not load the initial environment configuration.").pipe(
31 Effect.annotateLogs({ ...safeErrorLogAttributes(error) }),
32 Effect.as(Option.none<ServerConfig>()),
33 ),
34 ),
35 );
36}
37
38// Bounded so a wedged environment cannot pin the permissions check (and with it
39// the settings UI) in a loading state for long.
40const DEFAULT_SESSION_STATE_TIMEOUT_MS = 6_000;
41
42/**
43 * Read the granted scopes of this client's session on one environment via its
44 * `/api/auth/session` endpoint, using the connection's authentication method
45 * and refreshing relay credentials when needed.
46 */
47export const fetchEnvironmentSessionState = Effect.fn(
48 "clientRuntime.state.fetchEnvironmentSessionState",
49)(function* (input: {
50 readonly prepared: PreparedConnection;
51 readonly signer: Option.Option<ManagedRelay.ManagedRelayDpopSigner["Service"]>;
52 readonly remoteAuthorization?: Option.Option<
53 RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization["Service"]
54 >;
55 readonly timeoutMs?: number;
56}) {
57 return yield* executeAuthenticatedEnvironmentHttpRequest({
58 ...input,
59 group: "auth",
60 method: "GET",
61 url: (urls) => urls.session(),
62 timeoutMs: input.timeoutMs ?? DEFAULT_SESSION_STATE_TIMEOUT_MS,
63 request: ({ client, headers }) => client.session({ headers }),
64 // This endpoint returns 200 with authenticated:false for expired credentials.
65 isUnauthorizedResponse: (response) => !response.authenticated,
66 });
67});
68
69function makeEnvironmentSessionAtoms<R, E>(
70 runtime: Atom.AtomRuntime<EnvironmentRegistry.EnvironmentRegistry | R, E>,
71) {
72 const initialConfigAtom = Atom.family((environmentId: EnvironmentId) =>
73 runtime.atom(
74 followStreamInEnvironment(
75 environmentId,
76 Stream.unwrap(
77 EnvironmentSupervisor.EnvironmentSupervisor.pipe(
78 Effect.map((supervisor) =>
79 SubscriptionRef.changes(supervisor.session).pipe(
80 Stream.mapEffect(
81 Option.match({
82 onNone: () => Effect.succeed(Option.none<ServerConfig>()),
83 onSome: (session) => initialConfigOption(session.initialConfig),
84 }),
85 ),
86 ),
87 ),
88 ),
89 ),
90 ),
91 { initialValue: Option.none() },
92 ),
93 );
94
95 // This is only the bootstrap config captured when a transport session is
96 // established. Consumers that need current provider/settings state must use
97 // createServerEnvironmentAtoms(...).configValueAtom instead.
98 const initialConfigValueAtom = Atom.family((environmentId: EnvironmentId) =>
99 Atom.make((get): ServerConfig | null =>
100 Option.getOrNull(
101 Option.getOrElse(AsyncResult.value(get(initialConfigAtom(environmentId))), () =>
102 Option.none(),
103 ),
104 ),
105 ).pipe(Atom.withLabel(`environment-config-value:${environmentId}`)),
106 );
107
108 const preparedConnectionAtom = Atom.family((environmentId: EnvironmentId) =>
109 runtime.atom(
110 followStreamInEnvironment(
111 environmentId,
112 Stream.unwrap(
113 EnvironmentSupervisor.EnvironmentSupervisor.pipe(
114 Effect.map((supervisor) => SubscriptionRef.changes(supervisor.prepared)),
115 ),
116 ),
117 ),
118 { initialValue: Option.none<PreparedConnection>() },
119 ),
120 );
121
122 const preparedConnectionValueAtom = Atom.family((environmentId: EnvironmentId) =>
123 Atom.make((get) =>
124 Option.getOrElse(AsyncResult.value(get(preparedConnectionAtom(environmentId))), () =>
125 Option.none<PreparedConnection>(),
126 ),
127 ).pipe(Atom.withLabel(`environment-prepared-connection:${environmentId}`)),
128 );
129
130 // Keyed on the prepared connection's identity: a reconnect (new credential,
131 // new base URL) swaps the prepared value, which re-runs the fetch, so scope
132 // changes from re-pairing are picked up without an explicit refresh.
133 const sessionStateAtom = Atom.family((environmentId: EnvironmentId) =>
134 runtime
135 .atom((get) => {
136 const prepared = Option.getOrNull(get(preparedConnectionValueAtom(environmentId)));
137 if (prepared === null) {
138 return Effect.never;
139 }
140 return Effect.gen(function* () {
141 const signer = yield* Effect.serviceOption(ManagedRelay.ManagedRelayDpopSigner);
142 const remoteAuthorization = yield* Effect.serviceOption(
143 RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization,
144 );
145 const client = yield* Effect.serviceOption(HttpClient.HttpClient);
146 if (Option.isNone(client)) return yield* new SessionHttpClientUnavailable();
147 return yield* fetchEnvironmentSessionState({
148 prepared,
149 signer,
150 remoteAuthorization,
151 }).pipe(Effect.provideService(HttpClient.HttpClient, client.value));
152 });
153 })
154 .pipe(
155 Atom.swr({ staleTime: 30_000, revalidateOnMount: true }),
156 Atom.setIdleTTL(5 * 60_000),
157 Atom.withLabel(`environment-session-state:${environmentId}`),
158 ),
159 );
160
161 const sessionStateValueAtom = Atom.family((environmentId: EnvironmentId) =>
162 Atom.make(
163 (get): AuthSessionState | null =>
164 Option.getOrNull(AsyncResult.value(get(sessionStateAtom(environmentId)))) ?? null,
165 ).pipe(Atom.withLabel(`environment-session-state-value:${environmentId}`)),
166 );
167
168 return {
169 initialConfigAtom,
170 initialConfigValueAtom,
171 preparedConnectionAtom,
172 preparedConnectionValueAtom,
173 sessionStateAtom,
174 sessionStateValueAtom,
175 };
176}
177
178const sessionAtomsByRuntime = new WeakMap<object, ReturnType<typeof makeEnvironmentSessionAtoms>>();
179
180/** Commands and UI share one session fetch and the same reconnect invalidation. */
181export function createEnvironmentSessionAtoms<R, E>(
182 runtime: Atom.AtomRuntime<EnvironmentRegistry.EnvironmentRegistry | R, E>,
183): ReturnType<typeof makeEnvironmentSessionAtoms<R, E>> {
184 const existing = sessionAtomsByRuntime.get(runtime);
185 if (existing) return existing as ReturnType<typeof makeEnvironmentSessionAtoms<R, E>>;
186 const atoms = makeEnvironmentSessionAtoms(runtime);
187 sessionAtomsByRuntime.set(runtime, atoms);
188 return atoms;
189}