1import { EnvironmentId } from "@t3tools/contracts";
2import * as Context from "effect/Context";
3import * as Effect from "effect/Effect";
4import * as Option from "effect/Option";
5import * as Schema from "effect/Schema";
6import * as Semaphore from "effect/Semaphore";
7import * as Stream from "effect/Stream";
8import * as SubscriptionRef from "effect/SubscriptionRef";
9
10import type { ConnectionCatalogEntry } from "./catalog.ts";
11import { ConnectionBlockedError, type ConnectionAttemptError } from "./model.ts";
12
13export const GitHubRoutingPermission = Schema.Literals(["off", "read", "read-write"]);
14export type GitHubRoutingPermission = typeof GitHubRoutingPermission.Type;
15export const StoredGitHubRoutingPermission = Schema.Struct({
16 environmentId: EnvironmentId,
17 connectionKey: Schema.String,
18 permission: GitHubRoutingPermission,
19});
20export type StoredGitHubRoutingPermission = typeof StoredGitHubRoutingPermission.Type;
21
22/**
23 * Trust belongs to the saved endpoints, never to an environment id advertised
24 * by a server alone. With several routes the key covers all of them, sorted so
25 * reordering keeps trust but adding or changing an address revokes it.
26 */
27export function gitHubRoutingConnectionKey(entry: ConnectionCatalogEntry): string | null {
28 // Learned routes come and go with the server's addresses and reuse a saved
29 // route's credential, so they leave trust where the user put it.
30 const routes = [
31 { target: entry.target, profile: entry.profile },
32 ...(entry.alternateRoutes ?? []),
33 ].filter((route) => {
34 const profile = Option.getOrNull(route.profile);
35 return !(profile?._tag === "BearerConnectionProfile" && profile.learned === true);
36 });
37 if (routes.length === 1) return routeConnectionKey(routes[0]!.target, routes[0]!.profile);
38 const keys = routes.map((route) => routeConnectionKey(route.target, route.profile));
39 return keys.every((key) => key !== null) ? JSON.stringify([...keys].sort()) : null;
40}
41
42function routeConnectionKey(
43 target: ConnectionCatalogEntry["target"],
44 routeProfile: ConnectionCatalogEntry["profile"],
45): string | null {
46 const entry = { profile: routeProfile };
47 if (target._tag === "RelayConnectionTarget")
48 return JSON.stringify([target._tag, target.environmentId]);
49 const profile = Option.getOrNull(entry.profile);
50 if (target._tag === "SshConnectionTarget") {
51 if (profile?._tag !== "SshConnectionProfile") return null;
52 const { alias, hostname, username, port } = profile.target;
53 return JSON.stringify([target._tag, target.environmentId, alias, hostname, username, port]);
54 }
55 const baseUrls =
56 target._tag === "PrimaryConnectionTarget"
57 ? [target.httpBaseUrl, target.wsBaseUrl]
58 : profile?._tag === "BearerConnectionProfile"
59 ? [profile.httpBaseUrl, profile.wsBaseUrl]
60 : null;
61 if (baseUrls === null) return null;
62 try {
63 const urls = baseUrls.map((baseUrl) => new URL(baseUrl));
64 if (
65 !["http:", "https:"].includes(urls[0]!.protocol) ||
66 !["ws:", "wss:"].includes(urls[1]!.protocol) ||
67 urls.some((url) => url.username || url.password)
68 )
69 return null;
70 return JSON.stringify([
71 target._tag,
72 target.environmentId,
73 ...urls.map((url) => url.href.replace(/\/+$/, "")),
74 ]);
75 } catch {
76 return null;
77 }
78}
79
80export function gitHubRoutingPermissionFor(
81 entry: ConnectionCatalogEntry,
82 permissions: ReadonlyArray<StoredGitHubRoutingPermission>,
83): GitHubRoutingPermission {
84 const key = gitHubRoutingConnectionKey(entry);
85 return key === null
86 ? "off"
87 : (permissions.find((permission) => permission.connectionKey === key)?.permission ?? "off");
88}
89
90export class GitHubRoutingPermissions extends Context.Reference<{
91 readonly get: (entry: ConnectionCatalogEntry) => Effect.Effect<GitHubRoutingPermission>;
92 readonly changes: Stream.Stream<ReadonlyArray<StoredGitHubRoutingPermission>>;
93 readonly set: (
94 entry: ConnectionCatalogEntry,
95 permission: GitHubRoutingPermission,
96 ) => Effect.Effect<void, ConnectionAttemptError>;
97 readonly forget: (environmentId: EnvironmentId) => Effect.Effect<void, ConnectionAttemptError>;
98}>("@t3tools/client-runtime/connection/GitHubRoutingPermissions", {
99 defaultValue: () => ({
100 get: () => Effect.succeed("off"),
101 changes: Stream.succeed([]),
102 set: () =>
103 Effect.fail(
104 new ConnectionBlockedError({
105 reason: "unsupported",
106 detail: "GitHub routing preferences are unavailable on this client.",
107 }),
108 ),
109 forget: () => Effect.void,
110 }),
111}) {}
112
113export const makeGitHubRoutingPermissions = Effect.fn("makeGitHubRoutingPermissions")(
114 function* (storage: {
115 readonly read: Effect.Effect<
116 ReadonlyArray<StoredGitHubRoutingPermission>,
117 ConnectionAttemptError
118 >;
119 readonly write: (
120 permissions: ReadonlyArray<StoredGitHubRoutingPermission>,
121 ) => Effect.Effect<void, ConnectionAttemptError>;
122 }) {
123 const state = yield* SubscriptionRef.make(yield* storage.read);
124 const lock = yield* Semaphore.make(1);
125 const update = (
126 transform: (
127 current: ReadonlyArray<StoredGitHubRoutingPermission>,
128 ) => ReadonlyArray<StoredGitHubRoutingPermission>,
129 ) =>
130 lock.withPermits(1)(
131 Effect.gen(function* () {
132 const current = yield* SubscriptionRef.get(state);
133 const next = transform(current);
134 if (next === current) return;
135 yield* storage.write(next);
136 yield* SubscriptionRef.set(state, next);
137 }),
138 );
139 return GitHubRoutingPermissions.of({
140 get: (entry) =>
141 SubscriptionRef.get(state).pipe(
142 Effect.map((permissions) => gitHubRoutingPermissionFor(entry, permissions)),
143 ),
144 changes: SubscriptionRef.changes(state),
145 set: (entry, permission) => {
146 const connectionKey = gitHubRoutingConnectionKey(entry);
147 if (connectionKey === null)
148 return Effect.fail(
149 new ConnectionBlockedError({
150 reason: "configuration",
151 detail: "This environment does not have a saved connection endpoint.",
152 }),
153 );
154 return update((current) => [
155 ...current.filter((value) => value.environmentId !== entry.target.environmentId),
156 ...(permission === "off"
157 ? []
158 : [{ environmentId: entry.target.environmentId, connectionKey, permission }]),
159 ]);
160 },
161 forget: (environmentId) =>
162 update((current) =>
163 current.some((permission) => permission.environmentId === environmentId)
164 ? current.filter((permission) => permission.environmentId !== environmentId)
165 : current,
166 ),
167 });
168 },
169);