| 1 | import { EnvironmentId } from "@t3tools/contracts"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Schema from "effect/Schema"; |
| 4 | |
| 5 | import { |
| 6 | type ConnectionRegistration, |
| 7 | ConnectionCredential, |
| 8 | ConnectionProfile, |
| 9 | } from "../connection/catalog.ts"; |
| 10 | import { type ConnectionTarget, PersistedConnectionTarget } from "../connection/model.ts"; |
| 11 | import * as TokenStore from "../authorization/tokenStore.ts"; |
| 12 | import { StoredGitHubRoutingPermission } from "../connection/githubRoutingPermissions.ts"; |
| 13 | |
| 14 | export const StoredConnectionCredential = Schema.Struct({ |
| 15 | connectionId: Schema.String, |
| 16 | credential: ConnectionCredential, |
| 17 | }); |
| 18 | export type StoredConnectionCredential = typeof StoredConnectionCredential.Type; |
| 19 | |
| 20 | export 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 | }); |
| 34 | export type ConnectionCatalogDocument = typeof ConnectionCatalogDocument.Type; |
| 35 | |
| 36 | export const EMPTY_CONNECTION_CATALOG_DOCUMENT: ConnectionCatalogDocument = Object.freeze({ |
| 37 | schemaVersion: 1, |
| 38 | targets: [], |
| 39 | profiles: [], |
| 40 | credentials: [], |
| 41 | remoteDpopTokens: [], |
| 42 | disabledEnvironmentIds: [], |
| 43 | }); |
| 44 | |
| 45 | export 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 | |
| 54 | export 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 | |
| 62 | function 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 | |
| 73 | function routeKey(target: ConnectionTarget): string { |
| 74 | return connectionIdOf(target) ?? target._tag; |
| 75 | } |
| 76 | |
| 77 | function 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 | */ |
| 100 | export 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 | */ |
| 113 | export 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. */ |
| 136 | export 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. */ |
| 174 | export 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. */ |
| 202 | export 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 | |
| 219 | export 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 | } |