authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 12:28:14-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 12:28:14-07:00
logc1d8a22f073e36768a8af5b0ef6472d802cbfa4d
treef04ccd13c94d7efe1cdd49043a37ea44c7b32f2c
parent7b791895ad043e180c1d56595a08866e16d0766e
signature Signed by PGP key B5690EEEBB952194

[codex] Structure desktop persisted credential errors (#3239)

Co-authored-by: codex <codex@users.noreply.github.com>

4 files changed, 291 insertions(+), 93 deletions(-)

apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts+15-1
...@@ -393,7 +393,21 @@ describe("DesktopConnectionCatalogStore", () => {...@@ -393,7 +393,21 @@ describe("DesktopConnectionCatalogStore", () => {
393 assert.isTrue(yield* store.set('{"schemaVersion":1,"targets":[]}'));393 assert.isTrue(yield* store.set('{"schemaVersion":1,"targets":[]}'));
394 yield* Ref.set(failDecrypt, true);394 yield* Ref.set(failDecrypt, true);
395 const error = yield* store.get.pipe(Effect.flip);395 const error = yield* store.get.pipe(Effect.flip);
396 assert.instanceOf(error, ElectronSafeStorage.ElectronSafeStorageDecryptError);396 assert.instanceOf(
397 error,
398 DesktopConnectionCatalogStore.DesktopConnectionCatalogStoreProtectionError,
399 );
400 assert.equal(error.operation, "decrypt-catalog");
401 assert.equal(error.catalogPath, `${baseDir}/userdata/connection-catalog.json`);
402 assert.instanceOf(error.cause, ElectronSafeStorage.ElectronSafeStorageDecryptError);
403 const decryptError = error.cause as ElectronSafeStorage.ElectronSafeStorageDecryptError;
404 assert.instanceOf(decryptError.cause, Error);
405 assert.equal(decryptError.cause.message, "invalid encrypted catalog");
406 assert.equal(
407 error.message,
408 `Desktop connection catalog protection failed during decrypt-catalog at ${baseDir}/userdata/connection-catalog.json.`,
409 );
410 assert.notEqual(error.message, decryptError.message);
397 yield* Ref.set(failDecrypt, false);411 yield* Ref.set(failDecrypt, false);
398 assert.deepStrictEqual(yield* store.get, Option.some('{"schemaVersion":1,"targets":[]}'));412 assert.deepStrictEqual(yield* store.get, Option.some('{"schemaVersion":1,"targets":[]}'));
399 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),413 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
apps/desktop/src/app/DesktopConnectionCatalogStore.ts+138-57
...@@ -53,8 +53,6 @@ const DesktopConnectionCatalogStoreWriteOperation = Schema.Literals([...@@ -53,8 +53,6 @@ const DesktopConnectionCatalogStoreWriteOperation = Schema.Literals([
53 "write-temporary-file",53 "write-temporary-file",
54 "replace-catalog-file",54 "replace-catalog-file",
55]);55]);
56type DesktopConnectionCatalogStoreWriteOperation =
57 typeof DesktopConnectionCatalogStoreWriteOperation.Type;
5856
59const DesktopConnectionCatalogStoreMigrationOperation = Schema.Literals([57const DesktopConnectionCatalogStoreMigrationOperation = Schema.Literals([
60 "read-legacy-registry",58 "read-legacy-registry",
...@@ -62,8 +60,12 @@ const DesktopConnectionCatalogStoreMigrationOperation = Schema.Literals([...@@ -62,8 +60,12 @@ const DesktopConnectionCatalogStoreMigrationOperation = Schema.Literals([
62 "encode-catalog",60 "encode-catalog",
63 "persist-catalog",61 "persist-catalog",
64]);62]);
65type DesktopConnectionCatalogStoreMigrationOperation =63
66 typeof DesktopConnectionCatalogStoreMigrationOperation.Type;64const DesktopConnectionCatalogStoreProtectionOperation = Schema.Literals([
65 "check-encryption-availability",
66 "encrypt-catalog",
67 "decrypt-catalog",
68]);
6769
68export class DesktopConnectionCatalogStoreWriteError extends Schema.TaggedErrorClass<DesktopConnectionCatalogStoreWriteError>()(70export class DesktopConnectionCatalogStoreWriteError extends Schema.TaggedErrorClass<DesktopConnectionCatalogStoreWriteError>()(
69 "DesktopConnectionCatalogStoreWriteError",71 "DesktopConnectionCatalogStoreWriteError",
...@@ -78,17 +80,6 @@ export class DesktopConnectionCatalogStoreWriteError extends Schema.TaggedErrorC...@@ -78,17 +80,6 @@ export class DesktopConnectionCatalogStoreWriteError extends Schema.TaggedErrorC
78 }80 }
79}81}
8082
81const writeError = (
82 operation: DesktopConnectionCatalogStoreWriteOperation,
83 path: string,
84 cause: unknown,
85): DesktopConnectionCatalogStoreWriteError =>
86 new DesktopConnectionCatalogStoreWriteError({
87 operation,
88 path,
89 cause,
90 });
91
92export class DesktopConnectionCatalogStoreDecodeError extends Schema.TaggedErrorClass<DesktopConnectionCatalogStoreDecodeError>()(83export class DesktopConnectionCatalogStoreDecodeError extends Schema.TaggedErrorClass<DesktopConnectionCatalogStoreDecodeError>()(
93 "DesktopConnectionCatalogStoreDecodeError",84 "DesktopConnectionCatalogStoreDecodeError",
94 {85 {
...@@ -142,18 +133,18 @@ export class DesktopConnectionCatalogStoreMigrationError extends Schema.TaggedEr...@@ -142,18 +133,18 @@ export class DesktopConnectionCatalogStoreMigrationError extends Schema.TaggedEr
142 }133 }
143}134}
144135
145const migrationError = (136export class DesktopConnectionCatalogStoreProtectionError extends Schema.TaggedErrorClass<DesktopConnectionCatalogStoreProtectionError>()(
146 operation: DesktopConnectionCatalogStoreMigrationOperation,137 "DesktopConnectionCatalogStoreProtectionError",
147 catalogPath: string,138 {
148 cause: unknown,139 operation: DesktopConnectionCatalogStoreProtectionOperation,
149 environmentId?: string,140 catalogPath: Schema.String,
150): DesktopConnectionCatalogStoreMigrationError =>141 cause: Schema.Defect(),
151 new DesktopConnectionCatalogStoreMigrationError({142 },
152 operation,143) {
153 catalogPath,144 override get message(): string {
154 ...(environmentId === undefined ? {} : { environmentId }),145 return `Desktop connection catalog protection failed during ${this.operation} at ${this.catalogPath}.`;
155 cause,146 }
156 });147}
157148
158export class DesktopConnectionCatalogStore extends Context.Service<149export class DesktopConnectionCatalogStore extends Context.Service<
159 DesktopConnectionCatalogStore,150 DesktopConnectionCatalogStore,
...@@ -164,13 +155,13 @@ export class DesktopConnectionCatalogStore extends Context.Service<...@@ -164,13 +155,13 @@ export class DesktopConnectionCatalogStore extends Context.Service<
164 | DesktopConnectionCatalogStoreDocumentDecodeError155 | DesktopConnectionCatalogStoreDocumentDecodeError
165 | DesktopConnectionCatalogStoreDecodeError156 | DesktopConnectionCatalogStoreDecodeError
166 | DesktopConnectionCatalogStoreMigrationError157 | DesktopConnectionCatalogStoreMigrationError
167 | ElectronSafeStorage.ElectronSafeStorageError158 | DesktopConnectionCatalogStoreProtectionError
168 >;159 >;
169 readonly set: (160 readonly set: (
170 catalog: string,161 catalog: string,
171 ) => Effect.Effect<162 ) => Effect.Effect<
172 boolean,163 boolean,
173 DesktopConnectionCatalogStoreWriteError | ElectronSafeStorage.ElectronSafeStorageError164 DesktopConnectionCatalogStoreWriteError | DesktopConnectionCatalogStoreProtectionError
174 >;165 >;
175 readonly clear: Effect.Effect<void>;166 readonly clear: Effect.Effect<void>;
176 }167 }
...@@ -236,20 +227,46 @@ const writeDocument = Effect.fn("desktop.connectionCatalogStore.writeDocument")(...@@ -236,20 +227,46 @@ const writeDocument = Effect.fn("desktop.connectionCatalogStore.writeDocument")(
236 const directory = input.path.dirname(input.catalogPath);227 const directory = input.path.dirname(input.catalogPath);
237 const tempPath = `${input.catalogPath}.${process.pid}.${input.suffix}.tmp`;228 const tempPath = `${input.catalogPath}.${process.pid}.${input.suffix}.tmp`;
238 const encoded = yield* encodeEncryptedConnectionCatalogDocumentJson(input.document).pipe(229 const encoded = yield* encodeEncryptedConnectionCatalogDocumentJson(input.document).pipe(
239 Effect.mapError((cause) => writeError("encode-document", input.catalogPath, cause)),230 Effect.mapError(
231 (cause) =>
232 new DesktopConnectionCatalogStoreWriteError({
233 operation: "encode-document",
234 path: input.catalogPath,
235 cause,
236 }),
237 ),
238 );
239 yield* input.fileSystem.makeDirectory(directory, { recursive: true }).pipe(
240 Effect.mapError(
241 (cause) =>
242 new DesktopConnectionCatalogStoreWriteError({
243 operation: "create-directory",
244 path: directory,
245 cause,
246 }),
247 ),
240 );248 );
241 yield* input.fileSystem
242 .makeDirectory(directory, { recursive: true })
243 .pipe(Effect.mapError((cause) => writeError("create-directory", directory, cause)));
244 yield* Effect.gen(function* () {249 yield* Effect.gen(function* () {
245 yield* input.fileSystem250 yield* input.fileSystem.writeFileString(tempPath, `${encoded}\n`).pipe(
246 .writeFileString(tempPath, `${encoded}\n`)251 Effect.mapError(
247 .pipe(Effect.mapError((cause) => writeError("write-temporary-file", tempPath, cause)));252 (cause) =>
248 yield* input.fileSystem253 new DesktopConnectionCatalogStoreWriteError({
249 .rename(tempPath, input.catalogPath)254 operation: "write-temporary-file",
250 .pipe(255 path: tempPath,
251 Effect.mapError((cause) => writeError("replace-catalog-file", input.catalogPath, cause)),256 cause,
252 );257 }),
258 ),
259 );
260 yield* input.fileSystem.rename(tempPath, input.catalogPath).pipe(
261 Effect.mapError(
262 (cause) =>
263 new DesktopConnectionCatalogStoreWriteError({
264 operation: "replace-catalog-file",
265 path: input.catalogPath,
266 cause,
267 }),
268 ),
269 );
253 }).pipe(270 }).pipe(
254 Effect.ensuring(271 Effect.ensuring(
255 input.fileSystem.remove(tempPath, { force: true }).pipe(272 input.fileSystem.remove(tempPath, { force: true }).pipe(
...@@ -330,13 +347,17 @@ const migrateSavedEnvironmentRecords = Effect.fn(...@@ -330,13 +347,17 @@ const migrateSavedEnvironmentRecords = Effect.fn(
330 wsBaseUrl: record.wsBaseUrl,347 wsBaseUrl: record.wsBaseUrl,
331 }),348 }),
332 );349 );
333 const token = yield* savedEnvironments350 const token = yield* savedEnvironments.getSecret(record.environmentId).pipe(
334 .getSecret(record.environmentId)351 Effect.mapError(
335 .pipe(352 (cause) =>
336 Effect.mapError((cause) =>353 new DesktopConnectionCatalogStoreMigrationError({
337 migrationError("read-legacy-secret", catalogPath, cause, record.environmentId),354 operation: "read-legacy-secret",
338 ),355 catalogPath,
339 );356 environmentId: record.environmentId,
357 cause,
358 }),
359 ),
360 );
340 if (Option.isSome(token)) {361 if (Option.isSome(token)) {
341 credentials.push({362 credentials.push({
342 connectionId: id,363 connectionId: id,
...@@ -362,13 +383,41 @@ export const make = Effect.gen(function* () {...@@ -362,13 +383,41 @@ export const make = Effect.gen(function* () {
362 const crypto = yield* Crypto.Crypto;383 const crypto = yield* Crypto.Crypto;
363 const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments;384 const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments;
364 const catalogPath = path.join(environment.stateDir, "connection-catalog.json");385 const catalogPath = path.join(environment.stateDir, "connection-catalog.json");
386 const encryptionAvailable = safeStorage.isEncryptionAvailable.pipe(
387 Effect.mapError(
388 (cause) =>
389 new DesktopConnectionCatalogStoreProtectionError({
390 operation: "check-encryption-availability",
391 catalogPath,
392 cause,
393 }),
394 ),
395 );
365396
366 const writeCatalog = Effect.fn("desktop.connectionCatalogStore.writeCatalog")(function* (397 const writeCatalog = Effect.fn("desktop.connectionCatalogStore.writeCatalog")(function* (
367 catalog: string,398 catalog: string,
368 ) {399 ) {
369 const encryptedCatalog = Encoding.encodeBase64(yield* safeStorage.encryptString(catalog));400 const encryptedCatalog = Encoding.encodeBase64(
401 yield* safeStorage.encryptString(catalog).pipe(
402 Effect.mapError(
403 (cause) =>
404 new DesktopConnectionCatalogStoreProtectionError({
405 operation: "encrypt-catalog",
406 catalogPath,
407 cause,
408 }),
409 ),
410 ),
411 );
370 const suffix = (yield* crypto.randomUUIDv4.pipe(412 const suffix = (yield* crypto.randomUUIDv4.pipe(
371 Effect.mapError((cause) => writeError("create-temporary-file-name", catalogPath, cause)),413 Effect.mapError(
414 (cause) =>
415 new DesktopConnectionCatalogStoreWriteError({
416 operation: "create-temporary-file-name",
417 path: catalogPath,
418 cause,
419 }),
420 ),
372 )).replace(/-/g, "");421 )).replace(/-/g, "");
373 yield* writeDocument({422 yield* writeDocument({
374 fileSystem,423 fileSystem,
...@@ -380,21 +429,42 @@ export const make = Effect.gen(function* () {...@@ -380,21 +429,42 @@ export const make = Effect.gen(function* () {
380 });429 });
381430
382 const migrateLegacyCatalog = Effect.gen(function* () {431 const migrateLegacyCatalog = Effect.gen(function* () {
383 if (!(yield* safeStorage.isEncryptionAvailable)) {432 if (!(yield* encryptionAvailable)) {
384 return Option.none<string>();433 return Option.none<string>();
385 }434 }
386 const records = yield* savedEnvironments.getRegistry.pipe(435 const records = yield* savedEnvironments.getRegistry.pipe(
387 Effect.mapError((cause) => migrationError("read-legacy-registry", catalogPath, cause)),436 Effect.mapError(
437 (cause) =>
438 new DesktopConnectionCatalogStoreMigrationError({
439 operation: "read-legacy-registry",
440 catalogPath,
441 cause,
442 }),
443 ),
388 );444 );
389 if (records.length === 0) {445 if (records.length === 0) {
390 return Option.none<string>();446 return Option.none<string>();
391 }447 }
392 const catalog = yield* migrateSavedEnvironmentRecords(records, savedEnvironments, catalogPath);448 const catalog = yield* migrateSavedEnvironmentRecords(records, savedEnvironments, catalogPath);
393 const encoded = yield* encodeRuntimeConnectionCatalogDocumentJson(catalog).pipe(449 const encoded = yield* encodeRuntimeConnectionCatalogDocumentJson(catalog).pipe(
394 Effect.mapError((cause) => migrationError("encode-catalog", catalogPath, cause)),450 Effect.mapError(
451 (cause) =>
452 new DesktopConnectionCatalogStoreMigrationError({
453 operation: "encode-catalog",
454 catalogPath,
455 cause,
456 }),
457 ),
395 );458 );
396 yield* writeCatalog(encoded).pipe(459 yield* writeCatalog(encoded).pipe(
397 Effect.mapError((cause) => migrationError("persist-catalog", catalogPath, cause)),460 Effect.mapError(
461 (cause) =>
462 new DesktopConnectionCatalogStoreMigrationError({
463 operation: "persist-catalog",
464 catalogPath,
465 cause,
466 }),
467 ),
398 );468 );
399 return Option.some(encoded);469 return Option.some(encoded);
400 });470 });
...@@ -405,16 +475,27 @@ export const make = Effect.gen(function* () {...@@ -405,16 +475,27 @@ export const make = Effect.gen(function* () {
405 if (Option.isNone(document)) {475 if (Option.isNone(document)) {
406 return yield* migrateLegacyCatalog;476 return yield* migrateLegacyCatalog;
407 }477 }
408 if (!(yield* safeStorage.isEncryptionAvailable)) {478 if (!(yield* encryptionAvailable)) {
409 return Option.none<string>();479 return Option.none<string>();
410 }480 }
411 const decrypted = yield* decodeSecretBytes(catalogPath, document.value.encryptedCatalog).pipe(481 const decrypted = yield* decodeSecretBytes(catalogPath, document.value.encryptedCatalog).pipe(
412 Effect.flatMap(safeStorage.decryptString),482 Effect.flatMap((encryptedCatalog) =>
483 safeStorage.decryptString(encryptedCatalog).pipe(
484 Effect.mapError(
485 (cause) =>
486 new DesktopConnectionCatalogStoreProtectionError({
487 operation: "decrypt-catalog",
488 catalogPath,
489 cause,
490 }),
491 ),
492 ),
493 ),
413 );494 );
414 return Option.some(decrypted);495 return Option.some(decrypted);
415 }).pipe(Effect.withSpan("desktop.connectionCatalogStore.get")),496 }).pipe(Effect.withSpan("desktop.connectionCatalogStore.get")),
416 set: Effect.fn("desktop.connectionCatalogStore.set")(function* (catalog) {497 set: Effect.fn("desktop.connectionCatalogStore.set")(function* (catalog) {
417 if (!(yield* safeStorage.isEncryptionAvailable)) {498 if (!(yield* encryptionAvailable)) {
418 return false;499 return false;
419 }500 }
420 yield* writeCatalog(catalog);501 yield* writeCatalog(catalog);
apps/desktop/src/settings/DesktopSavedEnvironments.test.ts+18-3
...@@ -271,10 +271,11 @@ describe("DesktopSavedEnvironments", () => {...@@ -271,10 +271,11 @@ describe("DesktopSavedEnvironments", () => {
271 ),271 ),
272 );272 );
273273
274 it.effect("surfaces typed safe storage availability failures", () => {274 it.effect("adds saved-environment context to safe storage availability failures", () => {
275 const cause = new Error("safe storage unavailable");275 const cause = new Error("safe storage unavailable");
276 return withSavedEnvironments(276 return withSavedEnvironments(
277 Effect.gen(function* () {277 Effect.gen(function* () {
278 const environment = yield* DesktopEnvironment.DesktopEnvironment;
278 const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments;279 const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments;
279 yield* savedEnvironments.setRegistry([savedRegistryRecord]);280 yield* savedEnvironments.setRegistry([savedRegistryRecord]);
280281
...@@ -285,8 +286,22 @@ describe("DesktopSavedEnvironments", () => {...@@ -285,8 +286,22 @@ describe("DesktopSavedEnvironments", () => {
285 })286 })
286 .pipe(Effect.flip);287 .pipe(Effect.flip);
287288
288 assert.instanceOf(error, ElectronSafeStorage.ElectronSafeStorageAvailabilityError);289 assert.instanceOf(
289 assert.equal(error.cause, cause);290 error,
291 DesktopSavedEnvironments.DesktopSavedEnvironmentSecretProtectionError,
292 );
293 assert.equal(error.operation, "check-encryption-availability");
294 assert.equal(error.environmentId, savedRegistryRecord.environmentId);
295 assert.equal(error.registryPath, environment.savedEnvironmentRegistryPath);
296 assert.instanceOf(error.cause, ElectronSafeStorage.ElectronSafeStorageAvailabilityError);
297 const availabilityError =
298 error.cause as ElectronSafeStorage.ElectronSafeStorageAvailabilityError;
299 assert.strictEqual(availabilityError.cause, cause);
300 assert.equal(
301 error.message,
302 `Desktop saved-environment secret protection failed during check-encryption-availability for environment ${savedRegistryRecord.environmentId} at ${environment.savedEnvironmentRegistryPath}.`,
303 );
304 assert.notEqual(error.message, availabilityError.message);
290 }),305 }),
291 { availabilityError: cause },306 { availabilityError: cause },
292 );307 );
apps/desktop/src/settings/DesktopSavedEnvironments.ts+120-32
...@@ -77,7 +77,12 @@ const DesktopSavedEnvironmentsWriteOperation = Schema.Literals([...@@ -77,7 +77,12 @@ const DesktopSavedEnvironmentsWriteOperation = Schema.Literals([
77 "write-temporary-file",77 "write-temporary-file",
78 "replace-registry-file",78 "replace-registry-file",
79]);79]);
80type DesktopSavedEnvironmentsWriteOperation = typeof DesktopSavedEnvironmentsWriteOperation.Type;80
81const DesktopSavedEnvironmentSecretProtectionOperation = Schema.Literals([
82 "check-encryption-availability",
83 "encrypt-secret",
84 "decrypt-secret",
85]);
8186
82export class DesktopSavedEnvironmentsWriteError extends Schema.TaggedErrorClass<DesktopSavedEnvironmentsWriteError>()(87export class DesktopSavedEnvironmentsWriteError extends Schema.TaggedErrorClass<DesktopSavedEnvironmentsWriteError>()(
83 "DesktopSavedEnvironmentsWriteError",88 "DesktopSavedEnvironmentsWriteError",
...@@ -92,17 +97,6 @@ export class DesktopSavedEnvironmentsWriteError extends Schema.TaggedErrorClass<...@@ -92,17 +97,6 @@ export class DesktopSavedEnvironmentsWriteError extends Schema.TaggedErrorClass<
92 }97 }
93}98}
9499
95const writeError = (
96 operation: DesktopSavedEnvironmentsWriteOperation,
97 path: string,
98 cause: unknown,
99): DesktopSavedEnvironmentsWriteError =>
100 new DesktopSavedEnvironmentsWriteError({
101 operation,
102 path,
103 cause,
104 });
105
106export class DesktopSavedEnvironmentsReadError extends Schema.TaggedErrorClass<DesktopSavedEnvironmentsReadError>()(100export class DesktopSavedEnvironmentsReadError extends Schema.TaggedErrorClass<DesktopSavedEnvironmentsReadError>()(
107 "DesktopSavedEnvironmentsReadError",101 "DesktopSavedEnvironmentsReadError",
108 {102 {
...@@ -141,6 +135,20 @@ export class DesktopSavedEnvironmentSecretDecodeError extends Schema.TaggedError...@@ -141,6 +135,20 @@ export class DesktopSavedEnvironmentSecretDecodeError extends Schema.TaggedError
141 }135 }
142}136}
143137
138export class DesktopSavedEnvironmentSecretProtectionError extends Schema.TaggedErrorClass<DesktopSavedEnvironmentSecretProtectionError>()(
139 "DesktopSavedEnvironmentSecretProtectionError",
140 {
141 operation: DesktopSavedEnvironmentSecretProtectionOperation,
142 environmentId: Schema.String,
143 registryPath: Schema.String,
144 cause: Schema.Defect(),
145 },
146) {
147 override get message(): string {
148 return `Desktop saved-environment secret protection failed during ${this.operation} for environment ${this.environmentId} at ${this.registryPath}.`;
149 }
150}
151
144export type DesktopSavedEnvironmentsReadRegistryError =152export type DesktopSavedEnvironmentsReadRegistryError =
145 | DesktopSavedEnvironmentsReadError153 | DesktopSavedEnvironmentsReadError
146 | DesktopSavedEnvironmentsDocumentDecodeError;154 | DesktopSavedEnvironmentsDocumentDecodeError;
...@@ -152,11 +160,11 @@ export type DesktopSavedEnvironmentsMutationError =...@@ -152,11 +160,11 @@ export type DesktopSavedEnvironmentsMutationError =
152export type DesktopSavedEnvironmentsGetSecretError =160export type DesktopSavedEnvironmentsGetSecretError =
153 | DesktopSavedEnvironmentsReadRegistryError161 | DesktopSavedEnvironmentsReadRegistryError
154 | DesktopSavedEnvironmentSecretDecodeError162 | DesktopSavedEnvironmentSecretDecodeError
155 | ElectronSafeStorage.ElectronSafeStorageError;163 | DesktopSavedEnvironmentSecretProtectionError;
156164
157export type DesktopSavedEnvironmentsSetSecretError =165export type DesktopSavedEnvironmentsSetSecretError =
158 | DesktopSavedEnvironmentsMutationError166 | DesktopSavedEnvironmentsMutationError
159 | ElectronSafeStorage.ElectronSafeStorageError;167 | DesktopSavedEnvironmentSecretProtectionError;
160168
161export class DesktopSavedEnvironments extends Context.Service<169export class DesktopSavedEnvironments extends Context.Service<
162 DesktopSavedEnvironments,170 DesktopSavedEnvironments,
...@@ -276,19 +284,45 @@ const writeRegistryDocument = Effect.fn("desktop.savedEnvironments.writeRegistry...@@ -276,19 +284,45 @@ const writeRegistryDocument = Effect.fn("desktop.savedEnvironments.writeRegistry
276 const directory = input.path.dirname(input.registryPath);284 const directory = input.path.dirname(input.registryPath);
277 const tempPath = `${input.registryPath}.${process.pid}.${input.suffix}.tmp`;285 const tempPath = `${input.registryPath}.${process.pid}.${input.suffix}.tmp`;
278 const encoded = yield* encodeSavedEnvironmentRegistryDocumentJson(input.document).pipe(286 const encoded = yield* encodeSavedEnvironmentRegistryDocumentJson(input.document).pipe(
279 Effect.mapError((cause) => writeError("encode-registry", input.registryPath, cause)),287 Effect.mapError(
288 (cause) =>
289 new DesktopSavedEnvironmentsWriteError({
290 operation: "encode-registry",
291 path: input.registryPath,
292 cause,
293 }),
294 ),
295 );
296 yield* input.fileSystem.makeDirectory(directory, { recursive: true }).pipe(
297 Effect.mapError(
298 (cause) =>
299 new DesktopSavedEnvironmentsWriteError({
300 operation: "create-directory",
301 path: directory,
302 cause,
303 }),
304 ),
305 );
306 yield* input.fileSystem.writeFileString(tempPath, `${encoded}\n`).pipe(
307 Effect.mapError(
308 (cause) =>
309 new DesktopSavedEnvironmentsWriteError({
310 operation: "write-temporary-file",
311 path: tempPath,
312 cause,
313 }),
314 ),
315 );
316 yield* input.fileSystem.rename(tempPath, input.registryPath).pipe(
317 Effect.mapError(
318 (cause) =>
319 new DesktopSavedEnvironmentsWriteError({
320 operation: "replace-registry-file",
321 path: input.registryPath,
322 cause,
323 }),
324 ),
280 );325 );
281 yield* input.fileSystem
282 .makeDirectory(directory, { recursive: true })
283 .pipe(Effect.mapError((cause) => writeError("create-directory", directory, cause)));
284 yield* input.fileSystem
285 .writeFileString(tempPath, `${encoded}\n`)
286 .pipe(Effect.mapError((cause) => writeError("write-temporary-file", tempPath, cause)));
287 yield* input.fileSystem
288 .rename(tempPath, input.registryPath)
289 .pipe(
290 Effect.mapError((cause) => writeError("replace-registry-file", input.registryPath, cause)),
291 );
292 },326 },
293);327);
294328
...@@ -341,8 +375,13 @@ export const make = Effect.gen(function* () {...@@ -341,8 +375,13 @@ export const make = Effect.gen(function* () {
341 const writeDocument = (document: SavedEnvironmentRegistryDocument) =>375 const writeDocument = (document: SavedEnvironmentRegistryDocument) =>
342 crypto.randomUUIDv4.pipe(376 crypto.randomUUIDv4.pipe(
343 Effect.map((uuid) => uuid.replace(/-/g, "")),377 Effect.map((uuid) => uuid.replace(/-/g, "")),
344 Effect.mapError((cause) =>378 Effect.mapError(
345 writeError("create-temporary-file-name", environment.savedEnvironmentRegistryPath, cause),379 (cause) =>
380 new DesktopSavedEnvironmentsWriteError({
381 operation: "create-temporary-file-name",
382 path: environment.savedEnvironmentRegistryPath,
383 cause,
384 }),
346 ),385 ),
347 Effect.flatMap((suffix) =>386 Effect.flatMap((suffix) =>
348 writeRegistryDocument({387 writeRegistryDocument({
...@@ -396,7 +435,21 @@ export const make = Effect.gen(function* () {...@@ -396,7 +435,21 @@ export const make = Effect.gen(function* () {
396 document.records.find((record) => record.environmentId === environmentId)435 document.records.find((record) => record.environmentId === environmentId)
397 ?.encryptedBearerToken,436 ?.encryptedBearerToken,
398 );437 );
399 if (Option.isNone(encoded) || !(yield* safeStorage.isEncryptionAvailable)) {438 if (Option.isNone(encoded)) {
439 return Option.none<string>();
440 }
441 const encryptionAvailable = yield* safeStorage.isEncryptionAvailable.pipe(
442 Effect.mapError(
443 (cause) =>
444 new DesktopSavedEnvironmentSecretProtectionError({
445 operation: "check-encryption-availability",
446 environmentId,
447 registryPath: environment.savedEnvironmentRegistryPath,
448 cause,
449 }),
450 ),
451 );
452 if (!encryptionAvailable) {
400 return Option.none<string>();453 return Option.none<string>();
401 }454 }
402455
...@@ -405,7 +458,19 @@ export const make = Effect.gen(function* () {...@@ -405,7 +458,19 @@ export const make = Effect.gen(function* () {
405 environment.savedEnvironmentRegistryPath,458 environment.savedEnvironmentRegistryPath,
406 encoded.value,459 encoded.value,
407 );460 );
408 return Option.some(yield* safeStorage.decryptString(secretBytes));461 return Option.some(
462 yield* safeStorage.decryptString(secretBytes).pipe(
463 Effect.mapError(
464 (cause) =>
465 new DesktopSavedEnvironmentSecretProtectionError({
466 operation: "decrypt-secret",
467 environmentId,
468 registryPath: environment.savedEnvironmentRegistryPath,
469 cause,
470 }),
471 ),
472 ),
473 );
409 }),474 }),
410 setSecret: Effect.fn("desktop.savedEnvironments.setSecret")(function* (input) {475 setSecret: Effect.fn("desktop.savedEnvironments.setSecret")(function* (input) {
411 const { environmentId, secret } = input;476 const { environmentId, secret } = input;
...@@ -415,11 +480,34 @@ export const make = Effect.gen(function* () {...@@ -415,11 +480,34 @@ export const make = Effect.gen(function* () {
415 environment.savedEnvironmentRegistryPath,480 environment.savedEnvironmentRegistryPath,
416 );481 );
417482
418 if (!(yield* safeStorage.isEncryptionAvailable)) {483 const encryptionAvailable = yield* safeStorage.isEncryptionAvailable.pipe(
484 Effect.mapError(
485 (cause) =>
486 new DesktopSavedEnvironmentSecretProtectionError({
487 operation: "check-encryption-availability",
488 environmentId,
489 registryPath: environment.savedEnvironmentRegistryPath,
490 cause,
491 }),
492 ),
493 );
494 if (!encryptionAvailable) {
419 return false;495 return false;
420 }496 }
421497
422 const encryptedBearerToken = Encoding.encodeBase64(yield* safeStorage.encryptString(secret));498 const encryptedBearerToken = Encoding.encodeBase64(
499 yield* safeStorage.encryptString(secret).pipe(
500 Effect.mapError(
501 (cause) =>
502 new DesktopSavedEnvironmentSecretProtectionError({
503 operation: "encrypt-secret",
504 environmentId,
505 registryPath: environment.savedEnvironmentRegistryPath,
506 cause,
507 }),
508 ),
509 ),
510 );
423 let found = false;511 let found = false;
424 const nextDocument: SavedEnvironmentRegistryDocument = {512 const nextDocument: SavedEnvironmentRegistryDocument = {
425 version: document.version,513 version: document.version,