1import { EnvironmentId } from "@t3tools/contracts";
2import * as Effect from "effect/Effect";
3import * as Schema from "effect/Schema";
4
5import {
6 type ConnectionRegistration,
7 ConnectionCredential,
8 ConnectionProfile,
9} from "../connection/catalog.ts";
10import { type ConnectionTarget, PersistedConnectionTarget } from "../connection/model.ts";
11import * as TokenStore from "../authorization/tokenStore.ts";
12import { StoredGitHubRoutingPermission } from "../connection/githubRoutingPermissions.ts";
13
14export const StoredConnectionCredential = Schema.Struct({
15 connectionId: Schema.String,
16 credential: ConnectionCredential,
17});
18export type StoredConnectionCredential = typeof StoredConnectionCredential.Type;
19
20export const ConnectionCatalogDocument = Schema.Struct({
21 schemaVersion: Schema.Literal(1),
22 targets: Schema.Array(PersistedConnectionTarget),
23 profiles: Schema.Array(ConnectionProfile),
24 credentials: Schema.Array(StoredConnectionCredential),
25 remoteDpopTokens: Schema.Array(TokenStore.RemoteDpopAccessToken),
26 githubRoutingPermissions: Schema.optionalKey(Schema.Array(StoredGitHubRoutingPermission)),
27 // Saved environments the user switched off. They stay registered with their
28 // credentials and cache but never connect until switched back on. Older
29 // documents predate the key, so decoding defaults it to none.
30 disabledEnvironmentIds: Schema.Array(EnvironmentId).pipe(
31 Schema.withDecodingDefaultKey(Effect.succeed([])),
32 ),
33});
34export type ConnectionCatalogDocument = typeof ConnectionCatalogDocument.Type;
35
36export const EMPTY_CONNECTION_CATALOG_DOCUMENT: ConnectionCatalogDocument = Object.freeze({
37 schemaVersion: 1,
38 targets: [],
39 profiles: [],
40 credentials: [],
41 remoteDpopTokens: [],
42 disabledEnvironmentIds: [],
43});
44
45export function replaceCatalogValue<A>(
46 values: ReadonlyArray<A>,
47 key: (value: A) => string,
48 next: A,
49): ReadonlyArray<A> {
50 const nextKey = key(next);
51 return [...values.filter((value) => key(value) !== nextKey), next];
52}
53
54export function removeCatalogValue<A>(
55 values: ReadonlyArray<A>,
56 key: (value: A) => string,
57 removedKey: string,
58): ReadonlyArray<A> {
59 return values.filter((value) => key(value) !== removedKey);
60}
61
62function connectionIdOf(target: ConnectionTarget): string | null {
63 switch (target._tag) {
64 case "PrimaryConnectionTarget":
65 case "RelayConnectionTarget":
66 return null;
67 case "BearerConnectionTarget":
68 case "SshConnectionTarget":
69 return target.connectionId;
70 }
71}
72
73function routeKey(target: ConnectionTarget): string {
74 return connectionIdOf(target) ?? target._tag;
75}
76
77function removeRouteMetadata(
78 document: ConnectionCatalogDocument,
79 removed: ReadonlyArray<ConnectionTarget>,
80): ConnectionCatalogDocument {
81 const connectionIds = new Set(removed.flatMap((target) => connectionIdOf(target) ?? []));
82 const relayRemoved = removed.some((target) => target._tag === "RelayConnectionTarget");
83 const environmentIds = new Set(removed.map((target) => target.environmentId));
84 return {
85 ...document,
86 profiles: document.profiles.filter((value) => !connectionIds.has(value.connectionId)),
87 credentials: document.credentials.filter((value) => !connectionIds.has(value.connectionId)),
88 // The DPoP token belongs to the T3 Connect route.
89 remoteDpopTokens: relayRemoved
90 ? document.remoteDpopTokens.filter((value) => !environmentIds.has(value.environmentId))
91 : document.remoteDpopTokens,
92 };
93}
94
95/**
96 * An environment's saved routes in preference order. Targets of one
97 * environment keep their relative order in `targets`, so a document written
98 * before routes existed is one environment with one route.
99 */
100export function catalogRoutes(
101 document: ConnectionCatalogDocument,
102 environmentId: EnvironmentId,
103): ReadonlyArray<PersistedConnectionTarget> {
104 return document.targets.filter((target) => target.environmentId === environmentId);
105}
106
107/**
108 * Replaces an environment's routes with `routes`, preferred first. Records
109 * owned by a dropped route go with it; the environment keeps its position in
110 * the catalog. An empty list leaves the environment's other records in place;
111 * use `removeConnectionFromCatalog` to forget the environment.
112 */
113export function setRoutesInCatalog(
114 document: ConnectionCatalogDocument,
115 environmentId: EnvironmentId,
116 routes: ReadonlyArray<PersistedConnectionTarget>,
117): ConnectionCatalogDocument {
118 const kept = new Set(routes.map(routeKey));
119 const dropped = catalogRoutes(document, environmentId).filter(
120 (target) => !kept.has(routeKey(target)),
121 );
122 const firstIndex = document.targets.findIndex((target) => target.environmentId === environmentId);
123 const others = document.targets.filter((target) => target.environmentId !== environmentId);
124 const insertAt =
125 firstIndex === -1
126 ? others.length
127 : document.targets.slice(0, firstIndex).filter((t) => t.environmentId !== environmentId)
128 .length;
129 return {
130 ...removeRouteMetadata(document, dropped),
131 targets: [...others.slice(0, insertAt), ...routes, ...others.slice(insertAt)],
132 };
133}
134
135/** Saves one route of an environment, keeping its other routes. */
136export function registerConnectionInCatalog(
137 document: ConnectionCatalogDocument,
138 registration: ConnectionRegistration,
139 routes: ReadonlyArray<PersistedConnectionTarget> = [registration.target],
140): ConnectionCatalogDocument {
141 // Re-registering (for example editing a label or URL) keeps the disabled
142 // flag; only `setConnectionEnabledInCatalog` or removal changes it.
143 const next = setRoutesInCatalog(document, registration.target.environmentId, routes);
144
145 switch (registration._tag) {
146 case "RelayConnectionRegistration":
147 return next;
148 case "BearerConnectionRegistration":
149 return {
150 ...next,
151 profiles: replaceCatalogValue(
152 next.profiles,
153 (value) => value.connectionId,
154 registration.profile,
155 ),
156 credentials: replaceCatalogValue(next.credentials, (value) => value.connectionId, {
157 connectionId: registration.target.connectionId,
158 credential: registration.credential,
159 }),
160 };
161 case "SshConnectionRegistration":
162 return {
163 ...next,
164 profiles: replaceCatalogValue(
165 next.profiles,
166 (value) => value.connectionId,
167 registration.profile,
168 ),
169 };
170 }
171}
172
173/** Forgets an environment and every route it had. */
174export function removeConnectionFromCatalog(
175 document: ConnectionCatalogDocument,
176 environmentId: EnvironmentId,
177): ConnectionCatalogDocument {
178 const next = setRoutesInCatalog(document, environmentId, []);
179 return {
180 ...next,
181 remoteDpopTokens: removeCatalogValue(
182 next.remoteDpopTokens,
183 (value) => value.environmentId,
184 environmentId,
185 ),
186 disabledEnvironmentIds: removeCatalogValue(
187 next.disabledEnvironmentIds,
188 (value) => value,
189 environmentId,
190 ),
191 ...(next.githubRoutingPermissions === undefined
192 ? {}
193 : {
194 githubRoutingPermissions: next.githubRoutingPermissions.filter(
195 (permission) => permission.environmentId !== environmentId,
196 ),
197 }),
198 };
199}
200
201/** Flips the disabled flag for a saved environment; unknown ids are ignored. */
202export function setConnectionEnabledInCatalog(
203 document: ConnectionCatalogDocument,
204 environmentId: EnvironmentId,
205 enabled: boolean,
206): ConnectionCatalogDocument {
207 const registered = document.targets.some((target) => target.environmentId === environmentId);
208 const without = removeCatalogValue(
209 document.disabledEnvironmentIds,
210 (value) => value,
211 environmentId,
212 );
213 return {
214 ...document,
215 disabledEnvironmentIds: registered && !enabled ? [...without, environmentId] : without,
216 };
217}
218
219export function putRemoteDpopTokenInCatalog(
220 document: ConnectionCatalogDocument,
221 token: TokenStore.RemoteDpopAccessToken,
222): ConnectionCatalogDocument {
223 const registered = document.targets.some(
224 (target) =>
225 target._tag === "RelayConnectionTarget" && target.environmentId === token.environmentId,
226 );
227 if (!registered) {
228 return document;
229 }
230 return {
231 ...document,
232 remoteDpopTokens: replaceCatalogValue(
233 document.remoteDpopTokens,
234 (value) => value.environmentId,
235 token,
236 ),
237 };
238}