| 1 | import { EnvironmentId } from "@t3tools/contracts"; |
| 2 | import * as Context from "effect/Context"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Option from "effect/Option"; |
| 5 | import * as Schema from "effect/Schema"; |
| 6 | import * as Semaphore from "effect/Semaphore"; |
| 7 | import * as Stream from "effect/Stream"; |
| 8 | import * as SubscriptionRef from "effect/SubscriptionRef"; |
| 9 | |
| 10 | import type { ConnectionCatalogEntry } from "./catalog.ts"; |
| 11 | import { ConnectionBlockedError, type ConnectionAttemptError } from "./model.ts"; |
| 12 | |
| 13 | export const GitHubRoutingPermission = Schema.Literals(["off", "read", "read-write"]); |
| 14 | export type GitHubRoutingPermission = typeof GitHubRoutingPermission.Type; |
| 15 | export const StoredGitHubRoutingPermission = Schema.Struct({ |
| 16 | environmentId: EnvironmentId, |
| 17 | connectionKey: Schema.String, |
| 18 | permission: GitHubRoutingPermission, |
| 19 | }); |
| 20 | export 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 | */ |
| 27 | export 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 | |
| 42 | function 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 | |
| 80 | export 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 | |
| 90 | export 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 | |
| 113 | export 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 | ); |