| 1 | import { EnvironmentId, type PersistedSavedEnvironmentRecord } from "@t3tools/contracts"; |
| 2 | import { fromLenientJson } from "@t3tools/shared/schemaJson"; |
| 3 | import * as Context from "effect/Context"; |
| 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as Base64 from "effect/encoding/Base64"; |
| 6 | import * as FileSystem from "effect/FileSystem"; |
| 7 | import * as Layer from "effect/Layer"; |
| 8 | import * as Option from "effect/Option"; |
| 9 | import * as Schema from "effect/Schema"; |
| 10 | |
| 11 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; |
| 12 | import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts"; |
| 13 | |
| 14 | type PersistedSavedEnvironmentDesktopSsh = NonNullable< |
| 15 | PersistedSavedEnvironmentRecord["desktopSsh"] |
| 16 | >; |
| 17 | |
| 18 | interface PersistedSavedEnvironmentStorageRecord extends Omit< |
| 19 | PersistedSavedEnvironmentRecord, |
| 20 | "desktopSsh" |
| 21 | > { |
| 22 | readonly desktopSsh?: PersistedSavedEnvironmentDesktopSsh; |
| 23 | readonly encryptedBearerToken?: string; |
| 24 | } |
| 25 | |
| 26 | interface SavedEnvironmentRegistryDocument { |
| 27 | readonly version: number; |
| 28 | readonly records: readonly PersistedSavedEnvironmentStorageRecord[]; |
| 29 | } |
| 30 | |
| 31 | interface SavedEnvironmentRegistryStorageDocument { |
| 32 | readonly version?: number; |
| 33 | readonly records?: readonly PersistedSavedEnvironmentStorageRecord[]; |
| 34 | } |
| 35 | |
| 36 | const DesktopSshTargetSchema = Schema.Struct({ |
| 37 | alias: Schema.String, |
| 38 | hostname: Schema.String, |
| 39 | username: Schema.NullOr(Schema.String), |
| 40 | port: Schema.NullOr(Schema.Number), |
| 41 | }); |
| 42 | |
| 43 | const PersistedSavedEnvironmentStorageRecordSchema = Schema.Struct({ |
| 44 | environmentId: EnvironmentId, |
| 45 | label: Schema.String, |
| 46 | httpBaseUrl: Schema.String, |
| 47 | wsBaseUrl: Schema.String, |
| 48 | createdAt: Schema.String, |
| 49 | lastConnectedAt: Schema.NullOr(Schema.String), |
| 50 | desktopSsh: Schema.optionalKey(DesktopSshTargetSchema), |
| 51 | relayManaged: Schema.optionalKey(Schema.Struct({ relayUrl: Schema.String })), |
| 52 | encryptedBearerToken: Schema.optionalKey(Schema.String), |
| 53 | }); |
| 54 | |
| 55 | const SavedEnvironmentRegistryDocumentSchema = Schema.Struct({ |
| 56 | version: Schema.optionalKey(Schema.Number), |
| 57 | records: Schema.optionalKey(Schema.Array(PersistedSavedEnvironmentStorageRecordSchema)), |
| 58 | }); |
| 59 | |
| 60 | const SavedEnvironmentRegistryDocumentJson = fromLenientJson( |
| 61 | SavedEnvironmentRegistryDocumentSchema, |
| 62 | ); |
| 63 | const decodeSavedEnvironmentRegistryDocumentJson = Schema.decodeEffect( |
| 64 | SavedEnvironmentRegistryDocumentJson, |
| 65 | ); |
| 66 | |
| 67 | const DesktopSavedEnvironmentSecretProtectionOperation = Schema.Literals([ |
| 68 | "check-encryption-availability", |
| 69 | "decrypt-secret", |
| 70 | ]); |
| 71 | |
| 72 | export class DesktopSavedEnvironmentsReadError extends Schema.TaggedError<DesktopSavedEnvironmentsReadError>()( |
| 73 | "DesktopSavedEnvironmentsReadError", |
| 74 | { |
| 75 | registryPath: Schema.String, |
| 76 | cause: Schema.Defect(), |
| 77 | }, |
| 78 | ) { |
| 79 | override get message(): string { |
| 80 | return `Failed to read desktop saved environments at ${this.registryPath}.`; |
| 81 | } |
| 82 | } |
| 83 | |
| 84 | export class DesktopSavedEnvironmentsDocumentDecodeError extends Schema.TaggedError<DesktopSavedEnvironmentsDocumentDecodeError>()( |
| 85 | "DesktopSavedEnvironmentsDocumentDecodeError", |
| 86 | { |
| 87 | registryPath: Schema.String, |
| 88 | cause: Schema.Defect(), |
| 89 | }, |
| 90 | ) { |
| 91 | override get message(): string { |
| 92 | return `Failed to decode desktop saved environments at ${this.registryPath}.`; |
| 93 | } |
| 94 | } |
| 95 | |
| 96 | export class DesktopSavedEnvironmentSecretDecodeError extends Schema.TaggedError<DesktopSavedEnvironmentSecretDecodeError>()( |
| 97 | "DesktopSavedEnvironmentSecretDecodeError", |
| 98 | { |
| 99 | environmentId: Schema.String, |
| 100 | registryPath: Schema.String, |
| 101 | field: Schema.Literal("encryptedBearerToken"), |
| 102 | cause: Schema.Defect(), |
| 103 | }, |
| 104 | ) { |
| 105 | override get message(): string { |
| 106 | return `Failed to decode ${this.field} for environment ${this.environmentId} at ${this.registryPath}.`; |
| 107 | } |
| 108 | } |
| 109 | |
| 110 | export class DesktopSavedEnvironmentSecretProtectionError extends Schema.TaggedError<DesktopSavedEnvironmentSecretProtectionError>()( |
| 111 | "DesktopSavedEnvironmentSecretProtectionError", |
| 112 | { |
| 113 | operation: DesktopSavedEnvironmentSecretProtectionOperation, |
| 114 | environmentId: Schema.String, |
| 115 | registryPath: Schema.String, |
| 116 | cause: Schema.Defect(), |
| 117 | }, |
| 118 | ) { |
| 119 | override get message(): string { |
| 120 | return `Desktop saved-environment secret protection failed during ${this.operation} for environment ${this.environmentId} at ${this.registryPath}.`; |
| 121 | } |
| 122 | } |
| 123 | |
| 124 | export type DesktopSavedEnvironmentsReadRegistryError = |
| 125 | | DesktopSavedEnvironmentsReadError |
| 126 | | DesktopSavedEnvironmentsDocumentDecodeError; |
| 127 | |
| 128 | export type DesktopSavedEnvironmentsGetSecretError = |
| 129 | | DesktopSavedEnvironmentsReadRegistryError |
| 130 | | DesktopSavedEnvironmentSecretDecodeError |
| 131 | | DesktopSavedEnvironmentSecretProtectionError; |
| 132 | |
| 133 | /** Reads the previous registry for connection-catalog migration. */ |
| 134 | export class DesktopSavedEnvironments extends Context.Service< |
| 135 | DesktopSavedEnvironments, |
| 136 | { |
| 137 | readonly getRegistry: Effect.Effect< |
| 138 | readonly PersistedSavedEnvironmentRecord[], |
| 139 | DesktopSavedEnvironmentsReadRegistryError |
| 140 | >; |
| 141 | |
| 142 | readonly getSecret: ( |
| 143 | environmentId: string, |
| 144 | ) => Effect.Effect<Option.Option<string>, DesktopSavedEnvironmentsGetSecretError>; |
| 145 | } |
| 146 | >()("@t3tools/desktop/settings/DesktopSavedEnvironments") {} |
| 147 | |
| 148 | function toPersistedSavedEnvironmentRecord( |
| 149 | record: PersistedSavedEnvironmentStorageRecord, |
| 150 | ): PersistedSavedEnvironmentRecord { |
| 151 | const nextRecord = { |
| 152 | environmentId: record.environmentId, |
| 153 | label: record.label, |
| 154 | httpBaseUrl: record.httpBaseUrl, |
| 155 | wsBaseUrl: record.wsBaseUrl, |
| 156 | createdAt: record.createdAt, |
| 157 | lastConnectedAt: record.lastConnectedAt, |
| 158 | }; |
| 159 | return { |
| 160 | ...nextRecord, |
| 161 | ...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}), |
| 162 | ...(record.relayManaged ? { relayManaged: record.relayManaged } : {}), |
| 163 | }; |
| 164 | } |
| 165 | |
| 166 | function normalizeSavedEnvironmentRegistryDocument( |
| 167 | document: SavedEnvironmentRegistryStorageDocument, |
| 168 | ): SavedEnvironmentRegistryDocument { |
| 169 | return { |
| 170 | version: document.version ?? 1, |
| 171 | records: document.records ?? [], |
| 172 | }; |
| 173 | } |
| 174 | |
| 175 | function readRegistryDocument( |
| 176 | fileSystem: FileSystem.FileSystem, |
| 177 | registryPath: string, |
| 178 | ): Effect.Effect<SavedEnvironmentRegistryDocument, DesktopSavedEnvironmentsReadRegistryError> { |
| 179 | return fileSystem.readFileString(registryPath).pipe( |
| 180 | Effect.catch((error) => |
| 181 | error.reason._tag === "NotFound" |
| 182 | ? Effect.succeed<string | null>(null) |
| 183 | : Effect.fail( |
| 184 | new DesktopSavedEnvironmentsReadError({ |
| 185 | registryPath, |
| 186 | cause: error, |
| 187 | }), |
| 188 | ), |
| 189 | ), |
| 190 | Effect.flatMap((raw) => |
| 191 | raw === null |
| 192 | ? Effect.succeed({ version: 1, records: [] }) |
| 193 | : decodeSavedEnvironmentRegistryDocumentJson(raw).pipe( |
| 194 | Effect.map(normalizeSavedEnvironmentRegistryDocument), |
| 195 | Effect.mapError( |
| 196 | (cause) => |
| 197 | new DesktopSavedEnvironmentsDocumentDecodeError({ |
| 198 | registryPath, |
| 199 | cause, |
| 200 | }), |
| 201 | ), |
| 202 | ), |
| 203 | ), |
| 204 | ); |
| 205 | } |
| 206 | |
| 207 | function decodeSecretBytes( |
| 208 | environmentId: string, |
| 209 | registryPath: string, |
| 210 | encoded: string, |
| 211 | ): Effect.Effect<Uint8Array, DesktopSavedEnvironmentSecretDecodeError> { |
| 212 | return Effect.fromResult(Base64.decode(encoded)).pipe( |
| 213 | Effect.mapError( |
| 214 | (cause) => |
| 215 | new DesktopSavedEnvironmentSecretDecodeError({ |
| 216 | environmentId, |
| 217 | registryPath, |
| 218 | field: "encryptedBearerToken", |
| 219 | cause, |
| 220 | }), |
| 221 | ), |
| 222 | ); |
| 223 | } |
| 224 | |
| 225 | /** @public Service construction is part of the canonical Effect module API. */ |
| 226 | export const make = Effect.gen(function* () { |
| 227 | const environment = yield* DesktopEnvironment.DesktopEnvironment; |
| 228 | const fileSystem = yield* FileSystem.FileSystem; |
| 229 | |
| 230 | const safeStorage = yield* ElectronSafeStorage.ElectronSafeStorage; |
| 231 | |
| 232 | return DesktopSavedEnvironments.of({ |
| 233 | getRegistry: readRegistryDocument(fileSystem, environment.savedEnvironmentRegistryPath).pipe( |
| 234 | Effect.map((document) => |
| 235 | document.records.map((record) => toPersistedSavedEnvironmentRecord(record)), |
| 236 | ), |
| 237 | Effect.withSpan("desktop.savedEnvironments.getRegistry"), |
| 238 | ), |
| 239 | |
| 240 | getSecret: Effect.fn("desktop.savedEnvironments.getSecret")(function* (environmentId) { |
| 241 | yield* Effect.annotateCurrentSpan({ environmentId }); |
| 242 | const document = yield* readRegistryDocument( |
| 243 | fileSystem, |
| 244 | environment.savedEnvironmentRegistryPath, |
| 245 | ); |
| 246 | const encoded = Option.fromNullishOr( |
| 247 | document.records.find((record) => record.environmentId === environmentId) |
| 248 | ?.encryptedBearerToken, |
| 249 | ); |
| 250 | if (Option.isNone(encoded)) { |
| 251 | return Option.none<string>(); |
| 252 | } |
| 253 | const encryptionAvailable = yield* safeStorage.isEncryptionAvailable.pipe( |
| 254 | Effect.mapError( |
| 255 | (cause) => |
| 256 | new DesktopSavedEnvironmentSecretProtectionError({ |
| 257 | operation: "check-encryption-availability", |
| 258 | environmentId, |
| 259 | registryPath: environment.savedEnvironmentRegistryPath, |
| 260 | cause, |
| 261 | }), |
| 262 | ), |
| 263 | ); |
| 264 | if (!encryptionAvailable) { |
| 265 | return Option.none<string>(); |
| 266 | } |
| 267 | |
| 268 | const secretBytes = yield* decodeSecretBytes( |
| 269 | environmentId, |
| 270 | environment.savedEnvironmentRegistryPath, |
| 271 | encoded.value, |
| 272 | ); |
| 273 | return Option.some( |
| 274 | yield* safeStorage.decryptString(secretBytes).pipe( |
| 275 | Effect.mapError( |
| 276 | (cause) => |
| 277 | new DesktopSavedEnvironmentSecretProtectionError({ |
| 278 | operation: "decrypt-secret", |
| 279 | environmentId, |
| 280 | registryPath: environment.savedEnvironmentRegistryPath, |
| 281 | cause, |
| 282 | }), |
| 283 | ), |
| 284 | ), |
| 285 | ); |
| 286 | }), |
| 287 | }); |
| 288 | }); |
| 289 | |
| 290 | export const layer = Layer.effect(DesktopSavedEnvironments, make); |