1import {
2 clientRpcRequiredScopes,
3 sessionGrantsScope,
4 authScopeRequiredResponse,
5 EnvironmentAuthorizationError,
6 type EnvironmentId,
7 type AuthSessionState,
8 type AuthEnvironmentScope,
9} from "@t3tools/contracts";
10import * as Effect from "effect/Effect";
11import * as Option from "effect/Option";
12import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity";
13import type { EnvironmentRegistry } from "../connection/registry.ts";
14import { createEnvironmentSessionAtoms } from "./session.ts";
15
16/** UI availability and dispatch use the same target session and method policy. */
17function makeCommandPermissions<R, E>(
18 runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>,
19 method: string,
20) {
21 const sessions = createEnvironmentSessionAtoms(runtime);
22 const requiredScopes = (input?: unknown) => clientRpcRequiredScopes(method, input);
23 const atomsByEnvironment = Atom.family((environmentId: EnvironmentId | null) =>
24 Atom.family((scopeKey: string) =>
25 Atom.make((get) => {
26 if (environmentId === null) return false;
27 const scopes = scopeKey.split(",").filter(Boolean) as AuthEnvironmentScope[];
28 if (scopes.length === 0) return true;
29 const result = get(sessions.sessionStateAtom(environmentId));
30 const session = Option.getOrNull(AsyncResult.value(result));
31 return (
32 result._tag !== "Failure" &&
33 session !== null &&
34 scopes.every((scope) => sessionGrantsScope(session, scope))
35 );
36 }),
37 ),
38 );
39 const permissionAtom = (environmentId: EnvironmentId | null, input?: unknown) =>
40 atomsByEnvironment(environmentId)(requiredScopes(input).join(","));
41 const authorize = (
42 registry: AtomRegistry.AtomRegistry,
43 environmentId: EnvironmentId,
44 input?: unknown,
45 ) =>
46 Effect.suspend(() => {
47 const scopes = requiredScopes(input);
48 if (scopes.length === 0) return Effect.void;
49 const atom = sessions.sessionStateAtom(environmentId);
50 return Effect.scoped(
51 Effect.gen(function* () {
52 yield* AtomRegistry.mount(registry, atom);
53 const session = yield* AtomRegistry.getResult(registry, atom, {
54 suspendOnWaiting: false,
55 }).pipe(
56 Effect.timeoutOption(6_000),
57 Effect.catch(() => Effect.succeed(Option.none<AuthSessionState>())),
58 );
59 const missing = scopes.find(
60 (scope) => Option.isNone(session) || !sessionGrantsScope(session.value, scope),
61 );
62 if (missing !== undefined)
63 return yield* Effect.fail(
64 new EnvironmentAuthorizationError({
65 ...authScopeRequiredResponse(missing),
66 message: `This connection requires ${missing}.`,
67 }),
68 );
69 }),
70 );
71 });
72 return { requiredScopes, permissionAtom, authorize };
73}
74
75const permissionsByRuntime = new WeakMap<
76 object,
77 Map<string, ReturnType<typeof makeCommandPermissions>>
78>();
79
80/** Reuse availability atoms and dispatch guards across commands on the same runtime. */
81export function createCommandPermissions<R, E>(
82 runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>,
83 method: string,
84) {
85 let permissions = permissionsByRuntime.get(runtime);
86 if (permissions === undefined) {
87 permissions = new Map();
88 permissionsByRuntime.set(runtime, permissions);
89 }
90 const existing = permissions.get(method);
91 if (existing !== undefined) return existing;
92 const created = makeCommandPermissions(runtime, method);
93 permissions.set(method, created);
94 return created;
95}