From 49c23221d80c68114fe4d88e92b05fa1129de0d3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:23:58 -0700 Subject: [PATCH] [codex] Structure mobile secure storage failures (#3345) Co-authored-by: codex --- apps/mobile/src/lib/storage.test.ts | 31 ++++++++++ apps/mobile/src/lib/storage.ts | 95 +++++++++++++++++++++++++---- 2 files changed, 115 insertions(+), 11 deletions(-) diff --git a/apps/mobile/src/lib/storage.test.ts b/apps/mobile/src/lib/storage.test.ts index c3dd28ac3a1bb37f5f25d0c8fc7f14ea5c157d4f..084f9430d0847d0a1bc97924d2bb90471592ac88 100644 --- a/apps/mobile/src/lib/storage.test.ts +++ b/apps/mobile/src/lib/storage.test.ts @@ -69,4 +69,35 @@ describe("mobile connection storage", () => { toStableSavedRemoteConnection(managedConnection), ]); }); + + it("preserves secure-storage read failures with operation and key context", async () => { + const cause = new Error("keychain unavailable"); + mocks.getItemAsync.mockRejectedValueOnce(cause); + + await expect(loadSavedConnections()).rejects.toMatchObject({ + _tag: "MobileSecureStorageError", + operation: "read", + key: "t3code.connections", + cause, + message: "Mobile secure storage operation read failed for key t3code.connections.", + }); + }); + + it("logs structured decode failures before using the empty fallback", async () => { + await mocks.setItemAsync("t3code.connections", "{"); + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + + await expect(loadSavedConnections()).resolves.toEqual([]); + expect(warn).toHaveBeenCalledWith( + "[mobile-storage] ignored invalid JSON", + expect.objectContaining({ + _tag: "MobileStorageDecodeError", + key: "t3code.connections", + cause: expect.any(SyntaxError), + message: "Failed to decode mobile storage value for key t3code.connections.", + }), + ); + + warn.mockRestore(); + }); }); diff --git a/apps/mobile/src/lib/storage.ts b/apps/mobile/src/lib/storage.ts index da54f92949bd72fb37103f8e22df23e01abd1090..114648277b92ca591824f4ccb36592c6a629406a 100644 --- a/apps/mobile/src/lib/storage.ts +++ b/apps/mobile/src/lib/storage.ts @@ -1,5 +1,6 @@ import * as Arr from "effect/Array"; import { pipe } from "effect/Function"; +import * as Schema from "effect/Schema"; import * as SecureStore from "expo-secure-store"; import { EnvironmentId } from "@t3tools/contracts"; @@ -12,21 +13,72 @@ import { const CONNECTIONS_KEY = "t3code.connections"; const PREFERENCES_KEY = "t3code.preferences"; const AGENT_AWARENESS_DEVICE_ID_KEY = "t3code.agent-awareness.device-id"; +const MobileStorageKey = Schema.Literals([ + CONNECTIONS_KEY, + PREFERENCES_KEY, + AGENT_AWARENESS_DEVICE_ID_KEY, +]); +type MobileStorageKeyValue = typeof MobileStorageKey.Type; + +export class MobileSecureStorageError extends Schema.TaggedErrorClass()( + "MobileSecureStorageError", + { + operation: Schema.Literals(["read", "write", "generate-device-id"]), + key: MobileStorageKey, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Mobile secure storage operation ${this.operation} failed for key ${this.key}.`; + } +} + +export class MobileStorageDecodeError extends Schema.TaggedErrorClass()( + "MobileStorageDecodeError", + { + key: MobileStorageKey, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to decode mobile storage value for key ${this.key}.`; + } +} + +export class MobileStorageEncodeError extends Schema.TaggedErrorClass()( + "MobileStorageEncodeError", + { + key: MobileStorageKey, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to encode mobile storage value for key ${this.key}.`; + } +} export interface Preferences { readonly liveActivitiesEnabled?: boolean; readonly terminalFontSize?: number; } -async function readStorageItem(key: string): Promise { - return await SecureStore.getItemAsync(key); +async function readStorageItem(key: MobileStorageKeyValue): Promise { + try { + return await SecureStore.getItemAsync(key); + } catch (cause) { + throw new MobileSecureStorageError({ operation: "read", key, cause }); + } } -async function writeStorageItem(key: string, value: string): Promise { - await SecureStore.setItemAsync(key, value); +async function writeStorageItem(key: MobileStorageKeyValue, value: string): Promise { + try { + await SecureStore.setItemAsync(key, value); + } catch (cause) { + throw new MobileSecureStorageError({ operation: "write", key, cause }); + } } -async function readJsonStorageItem(key: string): Promise { +async function readJsonStorageItem(key: MobileStorageKeyValue): Promise { const raw = (await readStorageItem(key)) ?? ""; if (!raw.trim()) { return null; @@ -34,11 +86,25 @@ async function readJsonStorageItem(key: string): Promise { try { return JSON.parse(raw) as T; - } catch { + } catch (cause) { + console.warn( + "[mobile-storage] ignored invalid JSON", + new MobileStorageDecodeError({ key, cause }), + ); return null; } } +async function writeJsonStorageItem(key: MobileStorageKeyValue, value: unknown) { + let encoded: string; + try { + encoded = JSON.stringify(value); + } catch (cause) { + throw new MobileStorageEncodeError({ key, cause }); + } + await writeStorageItem(key, encoded); +} + export async function loadSavedConnections(): Promise> { const parsed = await readJsonStorageItem<{ readonly connections?: ReadonlyArray; @@ -67,7 +133,7 @@ export async function saveConnection(connection: SavedRemoteConnection): Promise ) : pipe(current, Arr.append(stableConnection)); - await writeStorageItem(CONNECTIONS_KEY, JSON.stringify({ connections: next })); + await writeJsonStorageItem(CONNECTIONS_KEY, { connections: next }); } export async function clearSavedConnection(environmentId: EnvironmentId): Promise { @@ -76,7 +142,7 @@ export async function clearSavedConnection(environmentId: EnvironmentId): Promis current, Arr.filter((entry) => entry.environmentId !== environmentId), ); - await writeStorageItem(CONNECTIONS_KEY, JSON.stringify({ connections: next })); + await writeJsonStorageItem(CONNECTIONS_KEY, { connections: next }); } export async function loadPreferences(): Promise { @@ -106,7 +172,7 @@ export async function savePreferencesPatch(patch: Partial): Promise ...current, ...patch, }; - await writeStorageItem(PREFERENCES_KEY, JSON.stringify(next)); + await writeJsonStorageItem(PREFERENCES_KEY, next); return next; } @@ -116,8 +182,15 @@ export async function loadOrCreateAgentAwarenessDeviceId(): Promise { return existing; } - const { uuidv4 } = await import("./uuid"); - const deviceId = uuidv4(); + const deviceId = await import("./uuid") + .then(({ uuidv4 }) => uuidv4()) + .catch((cause) => { + throw new MobileSecureStorageError({ + operation: "generate-device-id", + key: AGENT_AWARENESS_DEVICE_ID_KEY, + cause, + }); + }); await writeStorageItem(AGENT_AWARENESS_DEVICE_ID_KEY, deviceId); return deviceId; } -- 2.54.0