diff --git a/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts index c2bd8776e67..26c0c8f8943 100644 --- a/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts +++ b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts @@ -21,7 +21,6 @@ const textEncoder = new TextEncoder(); const decodeConnectionCatalog = Schema.decodeEffect( Schema.fromJsonString(ConnectionCatalogDocument), ); - function makeSafeStorageLayer(available: boolean, failDecrypt: Ref.Ref | null = null) { return Layer.succeed(ElectronSafeStorage.ElectronSafeStorage, { isEncryptionAvailable: Effect.succeed(available), @@ -40,7 +39,7 @@ function makeSafeStorageLayer(available: boolean, failDecrypt: Ref.Ref return decoded.slice("encrypted:".length); }); }, - } satisfies ElectronSafeStorage.ElectronSafeStorageShape); + } satisfies ElectronSafeStorage.ElectronSafeStorage["Service"]); } function makeLayer( @@ -236,8 +235,11 @@ describe("DesktopConnectionCatalogStore", () => { const error = yield* store.get.pipe(Effect.flip); assert.instanceOf( error, - DesktopConnectionCatalogStore.DesktopConnectionCatalogStoreReadError, + DesktopConnectionCatalogStore.DesktopConnectionCatalogStoreDocumentDecodeError, ); + assert.equal(error.operation, "decode-catalog-document"); + assert.equal(error.catalogPath, catalogPath); + assert.exists(error.cause); assert.equal(yield* fileSystem.readFileString(catalogPath), "{not-json"); }), ), @@ -253,7 +255,7 @@ describe("DesktopConnectionCatalogStore", () => { _tag: "PermissionDenied", module: "FileSystem", method: "readFileString", - pathOrDescriptor: `${baseDir}/connection-catalog.json`, + pathOrDescriptor: `${baseDir}/userdata/connection-catalog.json`, }); const fileSystemLayer = Layer.succeed( FileSystem.FileSystem, @@ -270,10 +272,115 @@ describe("DesktopConnectionCatalogStore", () => { error, DesktopConnectionCatalogStore.DesktopConnectionCatalogStoreReadError, ); - assert.equal(error.cause, permissionError); + assert.equal(error.operation, "read-catalog"); + assert.equal(error.catalogPath, `${baseDir}/userdata/connection-catalog.json`); + assert.strictEqual(error.cause, permissionError); + assert.equal( + error.message, + `Failed to read the desktop connection catalog at ${baseDir}/userdata/connection-catalog.json.`, + ); + assert.notEqual(error.message, permissionError.message); }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), ); + it.effect("reports the failed catalog write operation and path", () => + Effect.gen(function* () { + const baseFileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* baseFileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-connection-catalog-test-", + }); + const permissionError = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "makeDirectory", + pathOrDescriptor: `${baseDir}/userdata`, + }); + const fileSystemLayer = Layer.succeed( + FileSystem.FileSystem, + FileSystem.makeNoop({ + makeDirectory: () => Effect.fail(permissionError), + }), + ); + const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore.pipe( + Effect.provide(makeLayer(baseDir, true, null, fileSystemLayer)), + ); + + const error = yield* store.set("{}").pipe(Effect.flip); + assert.instanceOf( + error, + DesktopConnectionCatalogStore.DesktopConnectionCatalogStoreWriteError, + ); + assert.equal(error.operation, "create-directory"); + assert.equal(error.path, `${baseDir}/userdata`); + assert.strictEqual(error.cause, permissionError); + assert.equal( + error.message, + `Desktop connection catalog write failed during create-directory at ${baseDir}/userdata.`, + ); + assert.notEqual(error.message, permissionError.message); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + + it.effect("reports the legacy migration stage", () => + withStore( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore; + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); + yield* fileSystem.writeFileString(environment.savedEnvironmentRegistryPath, "{not-json"); + + const error = yield* store.get.pipe(Effect.flip); + assert.instanceOf( + error, + DesktopConnectionCatalogStore.DesktopConnectionCatalogStoreMigrationError, + ); + assert.equal(error.operation, "read-legacy-registry"); + assert.equal(error.catalogPath, `${environment.stateDir}/connection-catalog.json`); + assert.instanceOf( + error.cause, + DesktopSavedEnvironments.DesktopSavedEnvironmentsDocumentDecodeError, + ); + const registryError = + error.cause as DesktopSavedEnvironments.DesktopSavedEnvironmentsDocumentDecodeError; + assert.exists(registryError.cause); + assert.equal( + error.message, + `Legacy desktop saved-environment migration failed during read-legacy-registry into ${environment.stateDir}/connection-catalog.json.`, + ); + assert.notEqual(error.message, registryError.message); + }), + ), + ); + + it.effect("reports invalid encrypted catalog data without exposing it", () => + withStore( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore; + const catalogPath = `${environment.stateDir}/connection-catalog.json`; + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); + yield* fileSystem.writeFileString(catalogPath, '{"version":1,"encryptedCatalog":"%%%"}\n'); + + const error = yield* store.get.pipe(Effect.flip); + assert.instanceOf( + error, + DesktopConnectionCatalogStore.DesktopConnectionCatalogStoreDecodeError, + ); + assert.equal(error.operation, "decode-encrypted-catalog"); + assert.equal(error.resource, "encryptedCatalog"); + assert.equal(error.catalogPath, catalogPath); + assert.exists(error.cause); + assert.equal( + error.message, + `Failed to decode encryptedCatalog for the desktop connection catalog at ${catalogPath}.`, + ); + assert.notInclude(error.message, "%%%"); + }), + ), + ); + it.effect("surfaces a catalog that can no longer be decrypted without deleting it", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/desktop/src/app/DesktopConnectionCatalogStore.ts b/apps/desktop/src/app/DesktopConnectionCatalogStore.ts index 0b382bb163c..7eaf3ec7cf6 100644 --- a/apps/desktop/src/app/DesktopConnectionCatalogStore.ts +++ b/apps/desktop/src/app/DesktopConnectionCatalogStore.ts @@ -14,14 +14,12 @@ import type { PersistedSavedEnvironmentRecord } from "@t3tools/contracts"; import { fromLenientJson } from "@t3tools/shared/schemaJson"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; -import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; -import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts"; @@ -48,76 +46,156 @@ const encodeRuntimeConnectionCatalogDocumentJson = Schema.encodeEffect( RuntimeConnectionCatalogDocumentJson, ); -export class DesktopConnectionCatalogStoreWriteError extends Data.TaggedError( +const DesktopConnectionCatalogStoreWriteOperation = Schema.Literals([ + "create-temporary-file-name", + "encode-document", + "create-directory", + "write-temporary-file", + "replace-catalog-file", +]); +type DesktopConnectionCatalogStoreWriteOperation = + typeof DesktopConnectionCatalogStoreWriteOperation.Type; + +const DesktopConnectionCatalogStoreMigrationOperation = Schema.Literals([ + "read-legacy-registry", + "read-legacy-secret", + "encode-catalog", + "persist-catalog", +]); +type DesktopConnectionCatalogStoreMigrationOperation = + typeof DesktopConnectionCatalogStoreMigrationOperation.Type; + +export class DesktopConnectionCatalogStoreWriteError extends Schema.TaggedErrorClass()( "DesktopConnectionCatalogStoreWriteError", -)<{ - readonly cause: PlatformError.PlatformError | Schema.SchemaError; -}> { - override get message() { - return `Failed to write desktop connection catalog: ${this.cause.message}`; + { + operation: DesktopConnectionCatalogStoreWriteOperation, + path: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Desktop connection catalog write failed during ${this.operation} at ${this.path}.`; } } -export class DesktopConnectionCatalogStoreDecodeError extends Data.TaggedError( +const writeError = ( + operation: DesktopConnectionCatalogStoreWriteOperation, + path: string, + cause: unknown, +): DesktopConnectionCatalogStoreWriteError => + new DesktopConnectionCatalogStoreWriteError({ + operation, + path, + cause, + }); + +export class DesktopConnectionCatalogStoreDecodeError extends Schema.TaggedErrorClass()( "DesktopConnectionCatalogStoreDecodeError", -)<{ - readonly cause: Encoding.EncodingError; -}> { - override get message() { - return "Failed to decode the desktop connection catalog."; + { + operation: Schema.Literal("decode-encrypted-catalog"), + resource: Schema.Literal("encryptedCatalog"), + catalogPath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to decode ${this.resource} for the desktop connection catalog at ${this.catalogPath}.`; } } -export class DesktopConnectionCatalogStoreReadError extends Data.TaggedError( +export class DesktopConnectionCatalogStoreReadError extends Schema.TaggedErrorClass()( "DesktopConnectionCatalogStoreReadError", -)<{ - readonly cause: PlatformError.PlatformError | Schema.SchemaError; -}> { - override get message() { - return `Failed to read desktop connection catalog: ${this.cause.message}`; + { + operation: Schema.Literal("read-catalog"), + catalogPath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to read the desktop connection catalog at ${this.catalogPath}.`; } } -export class DesktopConnectionCatalogStoreMigrationError extends Data.TaggedError( - "DesktopConnectionCatalogStoreMigrationError", -)<{ - readonly cause: unknown; -}> { - override get message() { - return "Failed to migrate legacy desktop saved environments."; +export class DesktopConnectionCatalogStoreDocumentDecodeError extends Schema.TaggedErrorClass()( + "DesktopConnectionCatalogStoreDocumentDecodeError", + { + operation: Schema.Literal("decode-catalog-document"), + catalogPath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to decode the desktop connection catalog document at ${this.catalogPath}.`; } } -export interface DesktopConnectionCatalogStoreShape { - readonly get: Effect.Effect< - Option.Option, - | DesktopConnectionCatalogStoreReadError - | DesktopConnectionCatalogStoreDecodeError - | DesktopConnectionCatalogStoreMigrationError - | ElectronSafeStorage.ElectronSafeStorageAvailabilityError - | ElectronSafeStorage.ElectronSafeStorageDecryptError - >; - readonly set: ( - catalog: string, - ) => Effect.Effect< - boolean, - | DesktopConnectionCatalogStoreWriteError - | ElectronSafeStorage.ElectronSafeStorageAvailabilityError - | ElectronSafeStorage.ElectronSafeStorageEncryptError - >; - readonly clear: Effect.Effect; +export class DesktopConnectionCatalogStoreMigrationError extends Schema.TaggedErrorClass()( + "DesktopConnectionCatalogStoreMigrationError", + { + operation: DesktopConnectionCatalogStoreMigrationOperation, + catalogPath: Schema.String, + environmentId: Schema.optionalKey(Schema.String), + cause: Schema.Defect(), + }, +) { + override get message(): string { + const environment = + this.environmentId === undefined ? "" : ` for environment ${this.environmentId}`; + return `Legacy desktop saved-environment migration failed during ${this.operation}${environment} into ${this.catalogPath}.`; + } } +const migrationError = ( + operation: DesktopConnectionCatalogStoreMigrationOperation, + catalogPath: string, + cause: unknown, + environmentId?: string, +): DesktopConnectionCatalogStoreMigrationError => + new DesktopConnectionCatalogStoreMigrationError({ + operation, + catalogPath, + ...(environmentId === undefined ? {} : { environmentId }), + cause, + }); + export class DesktopConnectionCatalogStore extends Context.Service< DesktopConnectionCatalogStore, - DesktopConnectionCatalogStoreShape + { + readonly get: Effect.Effect< + Option.Option, + | DesktopConnectionCatalogStoreReadError + | DesktopConnectionCatalogStoreDocumentDecodeError + | DesktopConnectionCatalogStoreDecodeError + | DesktopConnectionCatalogStoreMigrationError + | ElectronSafeStorage.ElectronSafeStorageAvailabilityError + | ElectronSafeStorage.ElectronSafeStorageDecryptError + >; + readonly set: ( + catalog: string, + ) => Effect.Effect< + boolean, + | DesktopConnectionCatalogStoreWriteError + | ElectronSafeStorage.ElectronSafeStorageAvailabilityError + | ElectronSafeStorage.ElectronSafeStorageEncryptError + >; + readonly clear: Effect.Effect; + } >()("@t3tools/desktop/app/DesktopConnectionCatalogStore") {} function decodeSecretBytes( + catalogPath: string, encoded: string, ): Effect.Effect { return Effect.fromResult(Encoding.decodeBase64(encoded)).pipe( - Effect.mapError((cause) => new DesktopConnectionCatalogStoreDecodeError({ cause })), + Effect.mapError( + (cause) => + new DesktopConnectionCatalogStoreDecodeError({ + operation: "decode-encrypted-catalog", + resource: "encryptedCatalog", + catalogPath, + cause, + }), + ), ); } @@ -126,16 +204,34 @@ const readDocument = ( catalogPath: string, ): Effect.Effect< Option.Option, - PlatformError.PlatformError | Schema.SchemaError + DesktopConnectionCatalogStoreReadError | DesktopConnectionCatalogStoreDocumentDecodeError > => fileSystem.readFileString(catalogPath).pipe( Effect.catch((error) => - error.reason._tag === "NotFound" ? Effect.succeed(null) : Effect.fail(error), + error.reason._tag === "NotFound" + ? Effect.succeed(null) + : Effect.fail( + new DesktopConnectionCatalogStoreReadError({ + operation: "read-catalog", + catalogPath, + cause: error, + }), + ), ), Effect.flatMap((raw) => raw === null ? Effect.succeed(Option.none()) - : decodeEncryptedConnectionCatalogDocumentJson(raw).pipe(Effect.map(Option.some)), + : decodeEncryptedConnectionCatalogDocumentJson(raw).pipe( + Effect.map(Option.some), + Effect.mapError( + (cause) => + new DesktopConnectionCatalogStoreDocumentDecodeError({ + operation: "decode-catalog-document", + catalogPath, + cause, + }), + ), + ), ), ); @@ -145,14 +241,24 @@ const writeDocument = Effect.fn("desktop.connectionCatalogStore.writeDocument")( readonly catalogPath: string; readonly document: EncryptedConnectionCatalogDocument; readonly suffix: string; -}): Effect.fn.Return { +}): Effect.fn.Return { const directory = input.path.dirname(input.catalogPath); const tempPath = `${input.catalogPath}.${process.pid}.${input.suffix}.tmp`; - const encoded = yield* encodeEncryptedConnectionCatalogDocumentJson(input.document); - yield* input.fileSystem.makeDirectory(directory, { recursive: true }); + const encoded = yield* encodeEncryptedConnectionCatalogDocumentJson(input.document).pipe( + Effect.mapError((cause) => writeError("encode-document", input.catalogPath, cause)), + ); + yield* input.fileSystem + .makeDirectory(directory, { recursive: true }) + .pipe(Effect.mapError((cause) => writeError("create-directory", directory, cause))); yield* Effect.gen(function* () { - yield* input.fileSystem.writeFileString(tempPath, `${encoded}\n`); - yield* input.fileSystem.rename(tempPath, input.catalogPath); + yield* input.fileSystem + .writeFileString(tempPath, `${encoded}\n`) + .pipe(Effect.mapError((cause) => writeError("write-temporary-file", tempPath, cause))); + yield* input.fileSystem + .rename(tempPath, input.catalogPath) + .pipe( + Effect.mapError((cause) => writeError("replace-catalog-file", input.catalogPath, cause)), + ); }).pipe( Effect.ensuring( input.fileSystem.remove(tempPath, { force: true }).pipe( @@ -175,10 +281,11 @@ const migrateSavedEnvironmentRecords = Effect.fn( "desktop.connectionCatalogStore.migrateSavedEnvironmentRecords", )(function* ( records: readonly PersistedSavedEnvironmentRecord[], - savedEnvironments: DesktopSavedEnvironments.DesktopSavedEnvironmentsShape, + savedEnvironments: DesktopSavedEnvironments.DesktopSavedEnvironments["Service"], + catalogPath: string, ): Effect.fn.Return< RuntimeConnectionCatalogDocumentType, - DesktopSavedEnvironments.DesktopSavedEnvironmentsGetSecretError + DesktopConnectionCatalogStoreMigrationError > { const targets: Array = []; const profiles: Array = []; @@ -232,7 +339,13 @@ const migrateSavedEnvironmentRecords = Effect.fn( wsBaseUrl: record.wsBaseUrl, }), ); - const token = yield* savedEnvironments.getSecret(record.environmentId); + const token = yield* savedEnvironments + .getSecret(record.environmentId) + .pipe( + Effect.mapError((cause) => + migrationError("read-legacy-secret", catalogPath, cause, record.environmentId), + ), + ); if (Option.isSome(token)) { credentials.push({ connectionId: id, @@ -250,79 +363,82 @@ const migrateSavedEnvironmentRecords = Effect.fn( }; }); -export const layer = Layer.effect( - DesktopConnectionCatalogStore, - Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const safeStorage = yield* ElectronSafeStorage.ElectronSafeStorage; - const crypto = yield* Crypto.Crypto; - const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; - const catalogPath = path.join(environment.stateDir, "connection-catalog.json"); +export const make = Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const safeStorage = yield* ElectronSafeStorage.ElectronSafeStorage; + const crypto = yield* Crypto.Crypto; + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; + const catalogPath = path.join(environment.stateDir, "connection-catalog.json"); - const writeCatalog = Effect.fn("desktop.connectionCatalogStore.writeCatalog")(function* ( - catalog: string, - ) { - const encryptedCatalog = Encoding.encodeBase64(yield* safeStorage.encryptString(catalog)); - const suffix = (yield* crypto.randomUUIDv4.pipe( - Effect.mapError((cause) => new DesktopConnectionCatalogStoreWriteError({ cause })), - )).replace(/-/g, ""); - yield* writeDocument({ - fileSystem, - path, - catalogPath, - document: { version: 1, encryptedCatalog }, - suffix, - }).pipe(Effect.mapError((cause) => new DesktopConnectionCatalogStoreWriteError({ cause }))); + const writeCatalog = Effect.fn("desktop.connectionCatalogStore.writeCatalog")(function* ( + catalog: string, + ) { + const encryptedCatalog = Encoding.encodeBase64(yield* safeStorage.encryptString(catalog)); + const suffix = (yield* crypto.randomUUIDv4.pipe( + Effect.mapError((cause) => writeError("create-temporary-file-name", catalogPath, cause)), + )).replace(/-/g, ""); + yield* writeDocument({ + fileSystem, + path, + catalogPath, + document: { version: 1, encryptedCatalog }, + suffix, }); + }); - const migrateLegacyCatalog = Effect.gen(function* () { + const migrateLegacyCatalog = Effect.gen(function* () { + if (!(yield* safeStorage.isEncryptionAvailable)) { + return Option.none(); + } + const records = yield* savedEnvironments.getRegistry.pipe( + Effect.mapError((cause) => migrationError("read-legacy-registry", catalogPath, cause)), + ); + if (records.length === 0) { + return Option.none(); + } + const catalog = yield* migrateSavedEnvironmentRecords(records, savedEnvironments, catalogPath); + const encoded = yield* encodeRuntimeConnectionCatalogDocumentJson(catalog).pipe( + Effect.mapError((cause) => migrationError("encode-catalog", catalogPath, cause)), + ); + yield* writeCatalog(encoded).pipe( + Effect.mapError((cause) => migrationError("persist-catalog", catalogPath, cause)), + ); + return Option.some(encoded); + }); + + return DesktopConnectionCatalogStore.of({ + get: Effect.gen(function* () { + const document = yield* readDocument(fileSystem, catalogPath); + if (Option.isNone(document)) { + return yield* migrateLegacyCatalog; + } if (!(yield* safeStorage.isEncryptionAvailable)) { return Option.none(); } - const records = yield* savedEnvironments.getRegistry; - if (records.length === 0) { - return Option.none(); + const decrypted = yield* decodeSecretBytes(catalogPath, document.value.encryptedCatalog).pipe( + Effect.flatMap(safeStorage.decryptString), + ); + return Option.some(decrypted); + }).pipe(Effect.withSpan("desktop.connectionCatalogStore.get")), + set: Effect.fn("desktop.connectionCatalogStore.set")(function* (catalog) { + if (!(yield* safeStorage.isEncryptionAvailable)) { + return false; } - const catalog = yield* migrateSavedEnvironmentRecords(records, savedEnvironments); - const encoded = yield* encodeRuntimeConnectionCatalogDocumentJson(catalog); - yield* writeCatalog(encoded); - return Option.some(encoded); - }).pipe(Effect.mapError((cause) => new DesktopConnectionCatalogStoreMigrationError({ cause }))); - - return DesktopConnectionCatalogStore.of({ - get: Effect.gen(function* () { - const document = yield* readDocument(fileSystem, catalogPath).pipe( - Effect.mapError((cause) => new DesktopConnectionCatalogStoreReadError({ cause })), - ); - if (Option.isNone(document)) { - return yield* migrateLegacyCatalog; - } - if (!(yield* safeStorage.isEncryptionAvailable)) { - return Option.none(); - } - const decrypted = yield* decodeSecretBytes(document.value.encryptedCatalog).pipe( - Effect.flatMap(safeStorage.decryptString), - ); - return Option.some(decrypted); - }).pipe(Effect.withSpan("desktop.connectionCatalogStore.get")), - set: Effect.fn("desktop.connectionCatalogStore.set")(function* (catalog) { - if (!(yield* safeStorage.isEncryptionAvailable)) { - return false; - } - yield* writeCatalog(catalog); - return true; - }), - clear: fileSystem.remove(catalogPath, { force: true }).pipe( - Effect.catch((error) => - Effect.logWarning("Could not clear the desktop connection catalog.", { - catalogPath, - error, - }), - ), - Effect.withSpan("desktop.connectionCatalogStore.clear"), + yield* writeCatalog(catalog); + return true; + }), + clear: fileSystem.remove(catalogPath, { force: true }).pipe( + Effect.catch((error) => + Effect.logWarning("Could not clear the desktop connection catalog.", { + catalogPath, + error, + }), ), - }); - }), -); + Effect.withSpan("desktop.connectionCatalogStore.clear"), + ), + }); +}); + +export const layer = Layer.effect(DesktopConnectionCatalogStore, make); diff --git a/apps/desktop/src/settings/DesktopAppSettings.test.ts b/apps/desktop/src/settings/DesktopAppSettings.test.ts index db6194cf8f7..c76ffa8bbda 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.test.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.test.ts @@ -8,11 +8,6 @@ import * as Schema from "effect/Schema"; import * as DesktopConfig from "../app/DesktopConfig.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; -import { - DEFAULT_DESKTOP_SETTINGS, - resolveDefaultDesktopSettings, - type DesktopSettings as DesktopSettingsValue, -} from "./DesktopAppSettings.ts"; import * as DesktopAppSettings from "./DesktopAppSettings.ts"; const DesktopSettingsPatch = Schema.Struct({ @@ -82,20 +77,23 @@ describe("DesktopSettings", () => { withSettings( Effect.gen(function* () { const settings = yield* DesktopAppSettings.DesktopAppSettings; - assert.deepEqual(yield* settings.load, DEFAULT_DESKTOP_SETTINGS); - assert.deepEqual(yield* settings.get, DEFAULT_DESKTOP_SETTINGS); + assert.deepEqual(yield* settings.load, DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS); + assert.deepEqual(yield* settings.get, DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS); }), ), ); it("defaults packaged nightly builds to the nightly update channel", () => { - assert.deepEqual(resolveDefaultDesktopSettings("0.0.17-nightly.20260415.1"), { - serverExposureMode: "local-only", - tailscaleServeEnabled: false, - tailscaleServePort: 443, - updateChannel: "nightly", - updateChannelConfiguredByUser: false, - } satisfies DesktopSettingsValue); + assert.deepEqual( + DesktopAppSettings.resolveDefaultDesktopSettings("0.0.17-nightly.20260415.1"), + { + serverExposureMode: "local-only", + tailscaleServeEnabled: false, + tailscaleServePort: 443, + updateChannel: "nightly", + updateChannelConfiguredByUser: false, + } satisfies DesktopAppSettings.DesktopSettings, + ); }); it.effect("loads persisted settings and applies semantic updates", () => @@ -116,7 +114,7 @@ describe("DesktopSettings", () => { tailscaleServePort: 8443, updateChannel: "latest", updateChannelConfiguredByUser: true, - } satisfies DesktopSettingsValue); + } satisfies DesktopAppSettings.DesktopSettings); const exposure = yield* settings.setServerExposureMode("local-only"); assert.isTrue(exposure.changed); @@ -137,6 +135,27 @@ describe("DesktopSettings", () => { ), ); + it.effect("reports the failed desktop settings write operation and path", () => + withSettings( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* fileSystem.makeDirectory(environment.desktopSettingsPath, { recursive: true }); + + const error = yield* settings.setServerExposureMode("network-accessible").pipe(Effect.flip); + assert.instanceOf(error, DesktopAppSettings.DesktopSettingsWriteError); + assert.equal(error.operation, "replace-settings-file"); + assert.equal(error.path, environment.desktopSettingsPath); + assert.exists(error.cause); + assert.equal( + error.message, + `Desktop settings write failed during replace-settings-file at ${environment.desktopSettingsPath}.`, + ); + }), + ), + ); + it.effect("does not persist no-op semantic updates", () => withSettings( Effect.gen(function* () { @@ -167,7 +186,7 @@ describe("DesktopSettings", () => { yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); yield* fileSystem.writeFileString(environment.desktopSettingsPath, "{not-json"); - assert.deepEqual(yield* settings.load, DEFAULT_DESKTOP_SETTINGS); + assert.deepEqual(yield* settings.load, DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS); }), ), ); @@ -195,7 +214,7 @@ describe("DesktopSettings", () => { tailscaleServePort: 8443, updateChannel: "latest", updateChannelConfiguredByUser: false, - } satisfies DesktopSettingsValue); + } satisfies DesktopAppSettings.DesktopSettings); }), ), ); @@ -234,7 +253,7 @@ describe("DesktopSettings", () => { tailscaleServePort: 443, updateChannel: "nightly", updateChannelConfiguredByUser: false, - } satisfies DesktopSettingsValue); + } satisfies DesktopAppSettings.DesktopSettings); }), { appVersion: "0.0.17-nightly.20260415.1" }, ), @@ -256,7 +275,7 @@ describe("DesktopSettings", () => { tailscaleServePort: 443, updateChannel: "latest", updateChannelConfiguredByUser: true, - } satisfies DesktopSettingsValue); + } satisfies DesktopAppSettings.DesktopSettings); }), { appVersion: "0.0.17-nightly.20260415.1" }, ), @@ -277,7 +296,7 @@ describe("DesktopSettings", () => { tailscaleServePort: 443, updateChannel: "latest", updateChannelConfiguredByUser: false, - } satisfies DesktopSettingsValue); + } satisfies DesktopAppSettings.DesktopSettings); }), ), ); diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts index a54f22fec5b..e072d80f03e 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -7,13 +7,11 @@ import { import { fromLenientJson } from "@t3tools/shared/schemaJson"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; -import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; -import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as SynchronizedRef from "effect/SynchronizedRef"; @@ -63,32 +61,50 @@ const settingsChange = (settings: DesktopSettings, changed: boolean): DesktopSet changed, }); -export class DesktopSettingsWriteError extends Data.TaggedError("DesktopSettingsWriteError")<{ - readonly cause: PlatformError.PlatformError | Schema.SchemaError; -}> { - override get message() { - return `Failed to write desktop settings: ${this.cause.message}`; +const DesktopSettingsWriteOperation = Schema.Literals([ + "create-temporary-file-name", + "encode-document", + "create-directory", + "write-temporary-file", + "replace-settings-file", +]); +type DesktopSettingsWriteOperation = typeof DesktopSettingsWriteOperation.Type; + +export class DesktopSettingsWriteError extends Schema.TaggedErrorClass()( + "DesktopSettingsWriteError", + { + operation: DesktopSettingsWriteOperation, + path: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Desktop settings write failed during ${this.operation} at ${this.path}.`; } } -export interface DesktopAppSettingsShape { - readonly load: Effect.Effect; - readonly get: Effect.Effect; - readonly setServerExposureMode: ( - mode: DesktopServerExposureMode, - ) => Effect.Effect; - readonly setTailscaleServe: (input: { - readonly enabled: boolean; - readonly port: Option.Option; - }) => Effect.Effect; - readonly setUpdateChannel: ( - channel: DesktopUpdateChannel, - ) => Effect.Effect; -} +const writeError = ( + operation: DesktopSettingsWriteOperation, + path: string, + cause: unknown, +): DesktopSettingsWriteError => new DesktopSettingsWriteError({ operation, path, cause }); export class DesktopAppSettings extends Context.Service< DesktopAppSettings, - DesktopAppSettingsShape + { + readonly load: Effect.Effect; + readonly get: Effect.Effect; + readonly setServerExposureMode: ( + mode: DesktopServerExposureMode, + ) => Effect.Effect; + readonly setTailscaleServe: (input: { + readonly enabled: boolean; + readonly port: Option.Option; + }) => Effect.Effect; + readonly setUpdateChannel: ( + channel: DesktopUpdateChannel, + ) => Effect.Effect; + } >()("@t3tools/desktop/settings/DesktopAppSettings") {} export function resolveDefaultDesktopSettings(appVersion: string): DesktopSettings { @@ -223,77 +239,86 @@ const writeSettings = Effect.fn("desktop.settings.writeSettings")(function* (inp readonly settings: DesktopSettings; readonly defaultSettings: DesktopSettings; readonly suffix: string; -}): Effect.fn.Return { +}): Effect.fn.Return { const directory = input.path.dirname(input.settingsPath); const tempPath = `${input.settingsPath}.${process.pid}.${input.suffix}.tmp`; const encoded = yield* encodeDesktopSettingsJson( toDesktopSettingsDocument(input.settings, input.defaultSettings), - ); - yield* input.fileSystem.makeDirectory(directory, { recursive: true }); - yield* input.fileSystem.writeFileString(tempPath, `${encoded}\n`); - yield* input.fileSystem.rename(tempPath, input.settingsPath); + ).pipe(Effect.mapError((cause) => writeError("encode-document", input.settingsPath, cause))); + yield* input.fileSystem + .makeDirectory(directory, { recursive: true }) + .pipe(Effect.mapError((cause) => writeError("create-directory", directory, cause))); + yield* input.fileSystem + .writeFileString(tempPath, `${encoded}\n`) + .pipe(Effect.mapError((cause) => writeError("write-temporary-file", tempPath, cause))); + yield* input.fileSystem + .rename(tempPath, input.settingsPath) + .pipe( + Effect.mapError((cause) => writeError("replace-settings-file", input.settingsPath, cause)), + ); }); -export const layer = Layer.effect( - DesktopAppSettings, - Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const crypto = yield* Crypto.Crypto; - const settingsRef = yield* SynchronizedRef.make(environment.defaultDesktopSettings); - - const persist = ( - update: (settings: DesktopSettings) => DesktopSettings, - ): Effect.Effect => - SynchronizedRef.modifyEffect(settingsRef, (settings) => { - const nextSettings = update(settings); - if (nextSettings === settings) { - return Effect.succeed([settingsChange(settings, false), settings] as const); - } - - return crypto.randomUUIDv4.pipe( - Effect.map((uuid) => uuid.replace(/-/g, "")), - Effect.flatMap((suffix) => - writeSettings({ - fileSystem, - path, - settingsPath: environment.desktopSettingsPath, - settings: nextSettings, - defaultSettings: environment.defaultDesktopSettings, - suffix, - }), - ), - Effect.mapError((cause) => new DesktopSettingsWriteError({ cause })), - Effect.as([settingsChange(nextSettings, true), nextSettings] as const), - ); - }); +export const make = Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + const settingsRef = yield* SynchronizedRef.make(environment.defaultDesktopSettings); - return DesktopAppSettings.of({ - get: SynchronizedRef.get(settingsRef), - load: Effect.gen(function* () { - const settings = yield* readSettings( - fileSystem, - environment.desktopSettingsPath, - environment.appVersion, - ); - return yield* SynchronizedRef.setAndGet(settingsRef, settings); - }).pipe(Effect.withSpan("desktop.settings.load")), - setServerExposureMode: (mode) => - persist((settings) => setServerExposureMode(settings, mode)).pipe( - Effect.withSpan("desktop.settings.setServerExposureMode", { attributes: { mode } }), - ), - setTailscaleServe: (input) => - persist((settings) => setTailscaleServe(settings, input)).pipe( - Effect.withSpan("desktop.settings.setTailscaleServe", { attributes: input }), + const persist = ( + update: (settings: DesktopSettings) => DesktopSettings, + ): Effect.Effect => + SynchronizedRef.modifyEffect(settingsRef, (settings) => { + const nextSettings = update(settings); + if (nextSettings === settings) { + return Effect.succeed([settingsChange(settings, false), settings] as const); + } + + return crypto.randomUUIDv4.pipe( + Effect.map((uuid) => uuid.replace(/-/g, "")), + Effect.mapError((cause) => + writeError("create-temporary-file-name", environment.desktopSettingsPath, cause), ), - setUpdateChannel: (channel) => - persist((settings) => setUpdateChannel(settings, channel)).pipe( - Effect.withSpan("desktop.settings.setUpdateChannel", { attributes: { channel } }), + Effect.flatMap((suffix) => + writeSettings({ + fileSystem, + path, + settingsPath: environment.desktopSettingsPath, + settings: nextSettings, + defaultSettings: environment.defaultDesktopSettings, + suffix, + }), ), + Effect.as([settingsChange(nextSettings, true), nextSettings] as const), + ); }); - }), -); + + return DesktopAppSettings.of({ + get: SynchronizedRef.get(settingsRef), + load: Effect.gen(function* () { + const settings = yield* readSettings( + fileSystem, + environment.desktopSettingsPath, + environment.appVersion, + ); + return yield* SynchronizedRef.setAndGet(settingsRef, settings); + }).pipe(Effect.withSpan("desktop.settings.load")), + setServerExposureMode: (mode) => + persist((settings) => setServerExposureMode(settings, mode)).pipe( + Effect.withSpan("desktop.settings.setServerExposureMode", { attributes: { mode } }), + ), + setTailscaleServe: (input) => + persist((settings) => setTailscaleServe(settings, input)).pipe( + Effect.withSpan("desktop.settings.setTailscaleServe", { attributes: input }), + ), + setUpdateChannel: (channel) => + persist((settings) => setUpdateChannel(settings, channel)).pipe( + Effect.withSpan("desktop.settings.setUpdateChannel", { attributes: { channel } }), + ), + }); +}); + +export const layer = Layer.effect(DesktopAppSettings, make); export const layerTest = (initialSettings: DesktopSettings = DEFAULT_DESKTOP_SETTINGS) => Layer.effect( diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index f666e692860..2d1d7fc547d 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -34,7 +34,6 @@ const decodeClientSettingsJson = Schema.decodeEffect(Schema.fromJsonString(Clien const decodeRecordJson = Schema.decodeEffect( Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)), ); - function makeLayer(baseDir: string) { const environmentLayer = DesktopEnvironment.layer({ dirname: "/repo/apps/desktop/src", @@ -106,6 +105,27 @@ describe("DesktopClientSettings", () => { ), ); + it.effect("reports the failed client settings write operation and path", () => + withClientSettings( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const settings = yield* DesktopClientSettings.DesktopClientSettings; + yield* fileSystem.makeDirectory(environment.clientSettingsPath, { recursive: true }); + + const error = yield* settings.set(clientSettings).pipe(Effect.flip); + assert.instanceOf(error, DesktopClientSettings.DesktopClientSettingsWriteError); + assert.equal(error.operation, "replace-settings-file"); + assert.equal(error.path, environment.clientSettingsPath); + assert.exists(error.cause); + assert.equal( + error.message, + `Desktop client settings write failed during replace-settings-file at ${environment.clientSettingsPath}.`, + ); + }), + ), + ); + it.effect("loads lenient direct client settings documents", () => withClientSettings( Effect.gen(function* () { diff --git a/apps/desktop/src/settings/DesktopClientSettings.ts b/apps/desktop/src/settings/DesktopClientSettings.ts index 68d3fdc904a..585397d7502 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.ts @@ -2,13 +2,11 @@ import { ClientSettingsSchema, type ClientSettings } from "@t3tools/contracts"; import { fromLenientJson } from "@t3tools/shared/schemaJson"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; -import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; -import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as Ref from "effect/Ref"; @@ -31,24 +29,43 @@ const decodeClientSettingsJson = (raw: string): Effect.Effect()( "DesktopClientSettingsWriteError", -)<{ - readonly cause: PlatformError.PlatformError | Schema.SchemaError; -}> { - override get message() { - return `Failed to write desktop client settings: ${this.cause.message}`; + { + operation: DesktopClientSettingsWriteOperation, + path: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Desktop client settings write failed during ${this.operation} at ${this.path}.`; } } -export interface DesktopClientSettingsShape { - readonly get: Effect.Effect>; - readonly set: (settings: ClientSettings) => Effect.Effect; -} +const writeError = ( + operation: DesktopClientSettingsWriteOperation, + path: string, + cause: unknown, +): DesktopClientSettingsWriteError => + new DesktopClientSettingsWriteError({ operation, path, cause }); export class DesktopClientSettings extends Context.Service< DesktopClientSettings, - DesktopClientSettingsShape + { + readonly get: Effect.Effect>; + readonly set: ( + settings: ClientSettings, + ) => Effect.Effect; + } >()("@t3tools/desktop/settings/DesktopClientSettings") {} const readClientSettings = ( @@ -75,45 +92,56 @@ const writeClientSettings = Effect.fnUntraced(function* (input: { readonly settingsPath: string; readonly settings: ClientSettings; readonly suffix: string; -}): Effect.fn.Return { +}): Effect.fn.Return { const directory = input.path.dirname(input.settingsPath); const tempPath = `${input.settingsPath}.${process.pid}.${input.suffix}.tmp`; - const encoded = yield* encodeClientSettingsJson(input.settings); - yield* input.fileSystem.makeDirectory(directory, { recursive: true }); - yield* input.fileSystem.writeFileString(tempPath, `${encoded}\n`); - yield* input.fileSystem.rename(tempPath, input.settingsPath); + const encoded = yield* encodeClientSettingsJson(input.settings).pipe( + Effect.mapError((cause) => writeError("encode-document", input.settingsPath, cause)), + ); + yield* input.fileSystem + .makeDirectory(directory, { recursive: true }) + .pipe(Effect.mapError((cause) => writeError("create-directory", directory, cause))); + yield* input.fileSystem + .writeFileString(tempPath, `${encoded}\n`) + .pipe(Effect.mapError((cause) => writeError("write-temporary-file", tempPath, cause))); + yield* input.fileSystem + .rename(tempPath, input.settingsPath) + .pipe( + Effect.mapError((cause) => writeError("replace-settings-file", input.settingsPath, cause)), + ); }); -export const layer = Layer.effect( - DesktopClientSettings, - Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const crypto = yield* Crypto.Crypto; +export const make = Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; - return DesktopClientSettings.of({ - get: readClientSettings(fileSystem, environment.clientSettingsPath).pipe( - Effect.withSpan("desktop.clientSettings.get"), - ), - set: (settings) => - crypto.randomUUIDv4.pipe( - Effect.map((uuid) => uuid.replace(/-/g, "")), - Effect.flatMap((suffix) => - writeClientSettings({ - fileSystem, - path, - settingsPath: environment.clientSettingsPath, - settings, - suffix, - }), - ), - Effect.mapError((cause) => new DesktopClientSettingsWriteError({ cause })), - Effect.withSpan("desktop.clientSettings.set"), + return DesktopClientSettings.of({ + get: readClientSettings(fileSystem, environment.clientSettingsPath).pipe( + Effect.withSpan("desktop.clientSettings.get"), + ), + set: (settings) => + crypto.randomUUIDv4.pipe( + Effect.map((uuid) => uuid.replace(/-/g, "")), + Effect.mapError((cause) => + writeError("create-temporary-file-name", environment.clientSettingsPath, cause), ), - }); - }), -); + Effect.flatMap((suffix) => + writeClientSettings({ + fileSystem, + path, + settingsPath: environment.clientSettingsPath, + settings, + suffix, + }), + ), + Effect.withSpan("desktop.clientSettings.set"), + ), + }); +}); + +export const layer = Layer.effect(DesktopClientSettings, make); export const layerTest = (initialSettings: Option.Option = Option.none()) => Layer.effect( diff --git a/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts b/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts index abf8394cdb4..4e3c8d8ba1d 100644 --- a/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts +++ b/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts @@ -5,6 +5,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as DesktopConfig from "../app/DesktopConfig.ts"; @@ -34,10 +35,15 @@ const SavedEnvironmentRegistryDocumentProbe = Schema.Struct({ version: Schema.Number, records: Schema.Array(Schema.Unknown), }); +const SavedEnvironmentRegistryDocumentProbeJson = Schema.fromJsonString( + SavedEnvironmentRegistryDocumentProbe, +); const decodeSavedEnvironmentRegistryDocumentProbe = Schema.decodeEffect( - Schema.fromJsonString(SavedEnvironmentRegistryDocumentProbe), + SavedEnvironmentRegistryDocumentProbeJson, +); +const encodeSavedEnvironmentRegistryDocumentProbe = Schema.encodeEffect( + SavedEnvironmentRegistryDocumentProbeJson, ); - function makeSafeStorageLayer(input: { readonly available: boolean; readonly availabilityError?: unknown; @@ -80,7 +86,7 @@ function makeSafeStorageLayer(input: { } return Effect.succeed(decoded.slice("enc:".length)); }, - } satisfies ElectronSafeStorage.ElectronSafeStorageShape); + } satisfies ElectronSafeStorage.ElectronSafeStorage["Service"]); } function makeLayer( @@ -91,6 +97,7 @@ function makeLayer( readonly encryptError?: unknown; readonly decryptError?: unknown; }, + fileSystemLayer: Layer.Layer = NodeServices.layer, ) { const environmentLayer = DesktopEnvironment.layer({ dirname: "/repo/apps/desktop/src", @@ -108,18 +115,20 @@ function makeLayer( ), ); - return DesktopSavedEnvironments.layer.pipe( - Layer.provideMerge(environmentLayer), - Layer.provideMerge( - makeSafeStorageLayer({ - available: options?.availableSecretStorage ?? true, - availabilityError: options?.availabilityError, - encryptError: options?.encryptError, - decryptError: options?.decryptError, - }), - ), - Layer.provideMerge(NodeServices.layer), + const safeStorageLayer = makeSafeStorageLayer({ + available: options?.availableSecretStorage ?? true, + availabilityError: options?.availabilityError, + encryptError: options?.encryptError, + decryptError: options?.decryptError, + }); + const dependencies = Layer.mergeAll( + environmentLayer, + safeStorageLayer, + NodeServices.layer, + fileSystemLayer, ); + + return DesktopSavedEnvironments.layer.pipe(Layer.provideMerge(dependencies)); } const withSavedEnvironments = ( @@ -215,6 +224,37 @@ describe("DesktopSavedEnvironments", () => { ), ); + it.effect("reports invalid saved secret encoding without exposing the secret", () => + withSavedEnvironments( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); + const encoded = yield* encodeSavedEnvironmentRegistryDocumentProbe({ + version: 1, + records: [{ ...savedRegistryRecord, encryptedBearerToken: "%%%" }], + }); + yield* fileSystem.writeFileString(environment.savedEnvironmentRegistryPath, `${encoded}\n`); + + const error = yield* savedEnvironments + .getSecret(savedRegistryRecord.environmentId) + .pipe(Effect.flip); + assert.instanceOf(error, DesktopSavedEnvironments.DesktopSavedEnvironmentSecretDecodeError); + assert.equal(error.operation, "decode-secret"); + assert.equal(error.environmentId, savedRegistryRecord.environmentId); + assert.equal(error.registryPath, environment.savedEnvironmentRegistryPath); + assert.equal(error.field, "encryptedBearerToken"); + assert.exists(error.cause); + assert.equal( + error.message, + `Failed to decode encryptedBearerToken for environment ${savedRegistryRecord.environmentId} at ${environment.savedEnvironmentRegistryPath}.`, + ); + assert.notInclude(error.message, "%%%"); + }), + ), + ); + it.effect("returns false when writing secrets while encryption is unavailable", () => withSavedEnvironments( Effect.gen(function* () { @@ -321,16 +361,98 @@ describe("DesktopSavedEnvironments", () => { const registryError = yield* savedEnvironments.getRegistry.pipe(Effect.flip); assert.instanceOf( registryError, - DesktopSavedEnvironments.DesktopSavedEnvironmentsReadError, + DesktopSavedEnvironments.DesktopSavedEnvironmentsDocumentDecodeError, ); + assert.equal(registryError.operation, "decode-registry"); + assert.equal(registryError.registryPath, environment.savedEnvironmentRegistryPath); + assert.exists(registryError.cause); const secretError = yield* savedEnvironments .getSecret(savedRegistryRecord.environmentId) .pipe(Effect.flip); - assert.instanceOf(secretError, DesktopSavedEnvironments.DesktopSavedEnvironmentsReadError); + assert.instanceOf( + secretError, + DesktopSavedEnvironments.DesktopSavedEnvironmentsDocumentDecodeError, + ); + const mutationError = yield* savedEnvironments + .setRegistry([savedRegistryRecord]) + .pipe(Effect.flip); + assert.instanceOf( + mutationError, + DesktopSavedEnvironments.DesktopSavedEnvironmentsDocumentDecodeError, + ); }), ), ); + it.effect("reports saved environment filesystem reads separately from document decoding", () => + Effect.gen(function* () { + const baseFileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* baseFileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-saved-environments-test-", + }); + const registryPath = `${baseDir}/userdata/saved-environments.json`; + const permissionError = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "readFileString", + pathOrDescriptor: registryPath, + }); + const fileSystemLayer = Layer.succeed( + FileSystem.FileSystem, + FileSystem.makeNoop({ + readFileString: () => Effect.fail(permissionError), + }), + ); + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments.pipe( + Effect.provide(makeLayer(baseDir, undefined, fileSystemLayer)), + ); + + const error = yield* savedEnvironments.getRegistry.pipe(Effect.flip); + assert.instanceOf(error, DesktopSavedEnvironments.DesktopSavedEnvironmentsReadError); + assert.equal(error.operation, "read-registry"); + assert.equal(error.registryPath, registryPath); + assert.strictEqual(error.cause, permissionError); + assert.equal(error.message, `Failed to read desktop saved environments at ${registryPath}.`); + assert.notEqual(error.message, permissionError.message); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + + it.effect("reports the failed saved environment write operation and path", () => + Effect.gen(function* () { + const baseFileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* baseFileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-saved-environments-test-", + }); + const permissionError = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "makeDirectory", + pathOrDescriptor: `${baseDir}/userdata`, + }); + const fileSystemLayer = Layer.succeed( + FileSystem.FileSystem, + FileSystem.makeNoop({ + readFileString: baseFileSystem.readFileString, + makeDirectory: () => Effect.fail(permissionError), + }), + ); + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments.pipe( + Effect.provide(makeLayer(baseDir, undefined, fileSystemLayer)), + ); + + const error = yield* savedEnvironments.setRegistry([savedRegistryRecord]).pipe(Effect.flip); + assert.instanceOf(error, DesktopSavedEnvironments.DesktopSavedEnvironmentsWriteError); + assert.equal(error.operation, "create-directory"); + assert.equal(error.path, `${baseDir}/userdata`); + assert.strictEqual(error.cause, permissionError); + assert.equal( + error.message, + `Desktop saved-environment write failed during create-directory at ${baseDir}/userdata.`, + ); + assert.notEqual(error.message, permissionError.message); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + it.effect("returns false when writing a secret without metadata", () => withSavedEnvironments( Effect.gen(function* () { diff --git a/apps/desktop/src/settings/DesktopSavedEnvironments.ts b/apps/desktop/src/settings/DesktopSavedEnvironments.ts index 195992f0472..490777e9e84 100644 --- a/apps/desktop/src/settings/DesktopSavedEnvironments.ts +++ b/apps/desktop/src/settings/DesktopSavedEnvironments.ts @@ -2,14 +2,12 @@ import { EnvironmentId, type PersistedSavedEnvironmentRecord } from "@t3tools/co import { fromLenientJson } from "@t3tools/shared/schemaJson"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; -import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; -import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as Ref from "effect/Ref"; @@ -72,73 +70,123 @@ const encodeSavedEnvironmentRegistryDocumentJson = Schema.encodeEffect( SavedEnvironmentRegistryDocumentJson, ); -export class DesktopSavedEnvironmentsWriteError extends Data.TaggedError( +const DesktopSavedEnvironmentsWriteOperation = Schema.Literals([ + "create-temporary-file-name", + "encode-registry", + "create-directory", + "write-temporary-file", + "replace-registry-file", +]); +type DesktopSavedEnvironmentsWriteOperation = typeof DesktopSavedEnvironmentsWriteOperation.Type; + +export class DesktopSavedEnvironmentsWriteError extends Schema.TaggedErrorClass()( "DesktopSavedEnvironmentsWriteError", -)<{ - readonly cause: PlatformError.PlatformError | Schema.SchemaError; -}> { - override get message() { - return `Failed to write desktop saved environments: ${this.cause.message}`; + { + operation: DesktopSavedEnvironmentsWriteOperation, + path: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Desktop saved-environment write failed during ${this.operation} at ${this.path}.`; } } -export class DesktopSavedEnvironmentsReadError extends Data.TaggedError( +const writeError = ( + operation: DesktopSavedEnvironmentsWriteOperation, + path: string, + cause: unknown, +): DesktopSavedEnvironmentsWriteError => + new DesktopSavedEnvironmentsWriteError({ + operation, + path, + cause, + }); + +export class DesktopSavedEnvironmentsReadError extends Schema.TaggedErrorClass()( "DesktopSavedEnvironmentsReadError", -)<{ - readonly cause: PlatformError.PlatformError | Schema.SchemaError; -}> { - override get message() { - return `Failed to read desktop saved environments: ${this.cause.message}`; + { + operation: Schema.Literal("read-registry"), + registryPath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to read desktop saved environments at ${this.registryPath}.`; } } -export class DesktopSavedEnvironmentSecretDecodeError extends Data.TaggedError( +export class DesktopSavedEnvironmentsDocumentDecodeError extends Schema.TaggedErrorClass()( + "DesktopSavedEnvironmentsDocumentDecodeError", + { + operation: Schema.Literal("decode-registry"), + registryPath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to decode desktop saved environments at ${this.registryPath}.`; + } +} + +export class DesktopSavedEnvironmentSecretDecodeError extends Schema.TaggedErrorClass()( "DesktopSavedEnvironmentSecretDecodeError", -)<{ - readonly cause: Encoding.EncodingError; -}> { - override get message() { - return "Failed to decode desktop saved environment secret."; + { + operation: Schema.Literal("decode-secret"), + environmentId: Schema.String, + registryPath: Schema.String, + field: Schema.Literal("encryptedBearerToken"), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to decode ${this.field} for environment ${this.environmentId} at ${this.registryPath}.`; } } -export type DesktopSavedEnvironmentsGetSecretError = +export type DesktopSavedEnvironmentsReadRegistryError = | DesktopSavedEnvironmentsReadError + | DesktopSavedEnvironmentsDocumentDecodeError; + +export type DesktopSavedEnvironmentsMutationError = + | DesktopSavedEnvironmentsReadRegistryError + | DesktopSavedEnvironmentsWriteError; + +export type DesktopSavedEnvironmentsGetSecretError = + | DesktopSavedEnvironmentsReadRegistryError | DesktopSavedEnvironmentSecretDecodeError | ElectronSafeStorage.ElectronSafeStorageAvailabilityError | ElectronSafeStorage.ElectronSafeStorageDecryptError; export type DesktopSavedEnvironmentsSetSecretError = - | DesktopSavedEnvironmentsWriteError + | DesktopSavedEnvironmentsMutationError | ElectronSafeStorage.ElectronSafeStorageAvailabilityError | ElectronSafeStorage.ElectronSafeStorageEncryptError; -export interface DesktopSavedEnvironmentsShape { - readonly getRegistry: Effect.Effect< - readonly PersistedSavedEnvironmentRecord[], - DesktopSavedEnvironmentsReadError - >; - readonly setRegistry: ( - records: readonly PersistedSavedEnvironmentRecord[], - ) => Effect.Effect; - readonly removeEnvironment: ( - environmentId: string, - ) => Effect.Effect; - readonly getSecret: ( - environmentId: string, - ) => Effect.Effect, DesktopSavedEnvironmentsGetSecretError>; - readonly setSecret: (input: { - readonly environmentId: string; - readonly secret: string; - }) => Effect.Effect; - readonly removeSecret: ( - environmentId: string, - ) => Effect.Effect; -} - export class DesktopSavedEnvironments extends Context.Service< DesktopSavedEnvironments, - DesktopSavedEnvironmentsShape + { + readonly getRegistry: Effect.Effect< + readonly PersistedSavedEnvironmentRecord[], + DesktopSavedEnvironmentsReadRegistryError + >; + readonly setRegistry: ( + records: readonly PersistedSavedEnvironmentRecord[], + ) => Effect.Effect; + readonly removeEnvironment: ( + environmentId: string, + ) => Effect.Effect; + readonly getSecret: ( + environmentId: string, + ) => Effect.Effect, DesktopSavedEnvironmentsGetSecretError>; + readonly setSecret: (input: { + readonly environmentId: string; + readonly secret: string; + }) => Effect.Effect; + readonly removeSecret: ( + environmentId: string, + ) => Effect.Effect; + } >()("@t3tools/desktop/settings/DesktopSavedEnvironments") {} function toPersistedSavedEnvironmentRecord( @@ -193,19 +241,32 @@ function normalizeSavedEnvironmentRegistryDocument( function readRegistryDocument( fileSystem: FileSystem.FileSystem, registryPath: string, -): Effect.Effect< - SavedEnvironmentRegistryDocument, - PlatformError.PlatformError | Schema.SchemaError -> { +): Effect.Effect { return fileSystem.readFileString(registryPath).pipe( Effect.catch((error) => - error.reason._tag === "NotFound" ? Effect.succeed(null) : Effect.fail(error), + error.reason._tag === "NotFound" + ? Effect.succeed(null) + : Effect.fail( + new DesktopSavedEnvironmentsReadError({ + operation: "read-registry", + registryPath, + cause: error, + }), + ), ), Effect.flatMap((raw) => raw === null ? Effect.succeed({ version: 1, records: [] }) : decodeSavedEnvironmentRegistryDocumentJson(raw).pipe( Effect.map(normalizeSavedEnvironmentRegistryDocument), + Effect.mapError( + (cause) => + new DesktopSavedEnvironmentsDocumentDecodeError({ + operation: "decode-registry", + registryPath, + cause, + }), + ), ), ), ); @@ -218,13 +279,23 @@ const writeRegistryDocument = Effect.fn("desktop.savedEnvironments.writeRegistry readonly registryPath: string; readonly document: SavedEnvironmentRegistryDocument; readonly suffix: string; - }): Effect.fn.Return { + }): Effect.fn.Return { const directory = input.path.dirname(input.registryPath); const tempPath = `${input.registryPath}.${process.pid}.${input.suffix}.tmp`; - const encoded = yield* encodeSavedEnvironmentRegistryDocumentJson(input.document); - yield* input.fileSystem.makeDirectory(directory, { recursive: true }); - yield* input.fileSystem.writeFileString(tempPath, `${encoded}\n`); - yield* input.fileSystem.rename(tempPath, input.registryPath); + const encoded = yield* encodeSavedEnvironmentRegistryDocumentJson(input.document).pipe( + Effect.mapError((cause) => writeError("encode-registry", input.registryPath, cause)), + ); + yield* input.fileSystem + .makeDirectory(directory, { recursive: true }) + .pipe(Effect.mapError((cause) => writeError("create-directory", directory, cause))); + yield* input.fileSystem + .writeFileString(tempPath, `${encoded}\n`) + .pipe(Effect.mapError((cause) => writeError("write-temporary-file", tempPath, cause))); + yield* input.fileSystem + .rename(tempPath, input.registryPath) + .pipe( + Effect.mapError((cause) => writeError("replace-registry-file", input.registryPath, cause)), + ); }, ); @@ -250,147 +321,160 @@ function preserveExistingSecrets( } function decodeSecretBytes( + environmentId: string, + registryPath: string, encoded: string, ): Effect.Effect { return Effect.fromResult(Encoding.decodeBase64(encoded)).pipe( - Effect.mapError((cause) => new DesktopSavedEnvironmentSecretDecodeError({ cause })), + Effect.mapError( + (cause) => + new DesktopSavedEnvironmentSecretDecodeError({ + operation: "decode-secret", + environmentId, + registryPath, + field: "encryptedBearerToken", + cause, + }), + ), ); } -export const layer = Layer.effect( - DesktopSavedEnvironments, - Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const safeStorage = yield* ElectronSafeStorage.ElectronSafeStorage; - const crypto = yield* Crypto.Crypto; - - const writeDocument = (document: SavedEnvironmentRegistryDocument) => - crypto.randomUUIDv4.pipe( - Effect.map((uuid) => uuid.replace(/-/g, "")), - Effect.flatMap((suffix) => - writeRegistryDocument({ - fileSystem, - path, - registryPath: environment.savedEnvironmentRegistryPath, - document, - suffix, - }), - ), - Effect.mapError((cause) => new DesktopSavedEnvironmentsWriteError({ cause })), - ); - - return DesktopSavedEnvironments.of({ - getRegistry: readRegistryDocument(fileSystem, environment.savedEnvironmentRegistryPath).pipe( - Effect.map((document) => - document.records.map((record) => toPersistedSavedEnvironmentRecord(record)), - ), - Effect.mapError((cause) => new DesktopSavedEnvironmentsReadError({ cause })), - Effect.withSpan("desktop.savedEnvironments.getRegistry"), +export const make = Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const safeStorage = yield* ElectronSafeStorage.ElectronSafeStorage; + const crypto = yield* Crypto.Crypto; + + const writeDocument = (document: SavedEnvironmentRegistryDocument) => + crypto.randomUUIDv4.pipe( + Effect.map((uuid) => uuid.replace(/-/g, "")), + Effect.mapError((cause) => + writeError("create-temporary-file-name", environment.savedEnvironmentRegistryPath, cause), ), - setRegistry: Effect.fn("desktop.savedEnvironments.setRegistry")(function* (records) { - const currentDocument = yield* readRegistryDocument( + Effect.flatMap((suffix) => + writeRegistryDocument({ fileSystem, - environment.savedEnvironmentRegistryPath, - ).pipe(Effect.mapError((cause) => new DesktopSavedEnvironmentsWriteError({ cause }))); - yield* writeDocument(preserveExistingSecrets(currentDocument, records)); - }), - removeEnvironment: Effect.fn("desktop.savedEnvironments.removeEnvironment")( - function* (environmentId) { - yield* Effect.annotateCurrentSpan({ environmentId }); - const document = yield* readRegistryDocument( - fileSystem, - environment.savedEnvironmentRegistryPath, - ).pipe(Effect.mapError((cause) => new DesktopSavedEnvironmentsWriteError({ cause }))); - if (!document.records.some((record) => record.environmentId === environmentId)) { - return; - } - - yield* writeDocument({ - version: document.version, - records: document.records.filter((record) => record.environmentId !== environmentId), - }); - }, + path, + registryPath: environment.savedEnvironmentRegistryPath, + document, + suffix, + }), ), - getSecret: Effect.fn("desktop.savedEnvironments.getSecret")(function* (environmentId) { - yield* Effect.annotateCurrentSpan({ environmentId }); - const document = yield* readRegistryDocument( - fileSystem, - environment.savedEnvironmentRegistryPath, - ).pipe(Effect.mapError((cause) => new DesktopSavedEnvironmentsReadError({ cause }))); - const encoded = Option.fromNullishOr( - document.records.find((record) => record.environmentId === environmentId) - ?.encryptedBearerToken, - ); - if (Option.isNone(encoded) || !(yield* safeStorage.isEncryptionAvailable)) { - return Option.none(); - } + ); - const secretBytes = yield* decodeSecretBytes(encoded.value); - return Option.some(yield* safeStorage.decryptString(secretBytes)); - }), - setSecret: Effect.fn("desktop.savedEnvironments.setSecret")(function* (input) { - const { environmentId, secret } = input; + return DesktopSavedEnvironments.of({ + getRegistry: readRegistryDocument(fileSystem, environment.savedEnvironmentRegistryPath).pipe( + Effect.map((document) => + document.records.map((record) => toPersistedSavedEnvironmentRecord(record)), + ), + Effect.withSpan("desktop.savedEnvironments.getRegistry"), + ), + setRegistry: Effect.fn("desktop.savedEnvironments.setRegistry")(function* (records) { + const currentDocument = yield* readRegistryDocument( + fileSystem, + environment.savedEnvironmentRegistryPath, + ); + yield* writeDocument(preserveExistingSecrets(currentDocument, records)); + }), + removeEnvironment: Effect.fn("desktop.savedEnvironments.removeEnvironment")( + function* (environmentId) { yield* Effect.annotateCurrentSpan({ environmentId }); const document = yield* readRegistryDocument( fileSystem, environment.savedEnvironmentRegistryPath, - ).pipe(Effect.mapError((cause) => new DesktopSavedEnvironmentsWriteError({ cause }))); - - if (!(yield* safeStorage.isEncryptionAvailable)) { - return false; - } - - const encryptedBearerToken = Encoding.encodeBase64( - yield* safeStorage.encryptString(secret), ); - let found = false; - const nextDocument: SavedEnvironmentRegistryDocument = { - version: document.version, - records: document.records.map((record) => { - if (record.environmentId !== environmentId) { - return record; - } - - found = true; - return toSavedEnvironmentStorageRecord(record, Option.some(encryptedBearerToken)); - }), - }; - - if (found) { - yield* writeDocument(nextDocument); - } - return found; - }), - removeSecret: Effect.fn("desktop.savedEnvironments.removeSecret")(function* (environmentId) { - yield* Effect.annotateCurrentSpan({ environmentId }); - const document = yield* readRegistryDocument( - fileSystem, - environment.savedEnvironmentRegistryPath, - ).pipe(Effect.mapError((cause) => new DesktopSavedEnvironmentsWriteError({ cause }))); - if ( - !document.records.some( - (record) => - record.environmentId === environmentId && record.encryptedBearerToken !== undefined, - ) - ) { + if (!document.records.some((record) => record.environmentId === environmentId)) { return; } yield* writeDocument({ version: document.version, - records: document.records.map((record) => { - if (record.environmentId !== environmentId) { - return record; - } - return toPersistedSavedEnvironmentRecord(record); - }), + records: document.records.filter((record) => record.environmentId !== environmentId), }); - }), - }); - }), -); + }, + ), + getSecret: Effect.fn("desktop.savedEnvironments.getSecret")(function* (environmentId) { + yield* Effect.annotateCurrentSpan({ environmentId }); + const document = yield* readRegistryDocument( + fileSystem, + environment.savedEnvironmentRegistryPath, + ); + const encoded = Option.fromNullishOr( + document.records.find((record) => record.environmentId === environmentId) + ?.encryptedBearerToken, + ); + if (Option.isNone(encoded) || !(yield* safeStorage.isEncryptionAvailable)) { + return Option.none(); + } + + const secretBytes = yield* decodeSecretBytes( + environmentId, + environment.savedEnvironmentRegistryPath, + encoded.value, + ); + return Option.some(yield* safeStorage.decryptString(secretBytes)); + }), + setSecret: Effect.fn("desktop.savedEnvironments.setSecret")(function* (input) { + const { environmentId, secret } = input; + yield* Effect.annotateCurrentSpan({ environmentId }); + const document = yield* readRegistryDocument( + fileSystem, + environment.savedEnvironmentRegistryPath, + ); + + if (!(yield* safeStorage.isEncryptionAvailable)) { + return false; + } + + const encryptedBearerToken = Encoding.encodeBase64(yield* safeStorage.encryptString(secret)); + let found = false; + const nextDocument: SavedEnvironmentRegistryDocument = { + version: document.version, + records: document.records.map((record) => { + if (record.environmentId !== environmentId) { + return record; + } + + found = true; + return toSavedEnvironmentStorageRecord(record, Option.some(encryptedBearerToken)); + }), + }; + + if (found) { + yield* writeDocument(nextDocument); + } + return found; + }), + removeSecret: Effect.fn("desktop.savedEnvironments.removeSecret")(function* (environmentId) { + yield* Effect.annotateCurrentSpan({ environmentId }); + const document = yield* readRegistryDocument( + fileSystem, + environment.savedEnvironmentRegistryPath, + ); + if ( + !document.records.some( + (record) => + record.environmentId === environmentId && record.encryptedBearerToken !== undefined, + ) + ) { + return; + } + + yield* writeDocument({ + version: document.version, + records: document.records.map((record) => { + if (record.environmentId !== environmentId) { + return record; + } + return toPersistedSavedEnvironmentRecord(record); + }), + }); + }), + }); +}); + +export const layer = Layer.effect(DesktopSavedEnvironments, make); export const layerTest = (input?: { readonly records?: readonly PersistedSavedEnvironmentRecord[];