authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 11:23:58-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 11:23:58-07:00
log49c23221d80c68114fe4d88e92b05fa1129de0d3
tree791924af81036df10f681468f5d58f3171954ee0
parent5ca7676661325b13ee2a557b28f0d0d3ce8ab695
signature Signed by PGP key B5690EEEBB952194

[codex] Structure mobile secure storage failures (#3345)

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

2 files changed, 115 insertions(+), 11 deletions(-)

apps/mobile/src/lib/storage.test.ts+31
...@@ -69,4 +69,35 @@ describe("mobile connection storage", () => {...@@ -69,4 +69,35 @@ describe("mobile connection storage", () => {
69 toStableSavedRemoteConnection(managedConnection),69 toStableSavedRemoteConnection(managedConnection),
70 ]);70 ]);
71 });71 });
72
73 it("preserves secure-storage read failures with operation and key context", async () => {
74 const cause = new Error("keychain unavailable");
75 mocks.getItemAsync.mockRejectedValueOnce(cause);
76
77 await expect(loadSavedConnections()).rejects.toMatchObject({
78 _tag: "MobileSecureStorageError",
79 operation: "read",
80 key: "t3code.connections",
81 cause,
82 message: "Mobile secure storage operation read failed for key t3code.connections.",
83 });
84 });
85
86 it("logs structured decode failures before using the empty fallback", async () => {
87 await mocks.setItemAsync("t3code.connections", "{");
88 const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
89
90 await expect(loadSavedConnections()).resolves.toEqual([]);
91 expect(warn).toHaveBeenCalledWith(
92 "[mobile-storage] ignored invalid JSON",
93 expect.objectContaining({
94 _tag: "MobileStorageDecodeError",
95 key: "t3code.connections",
96 cause: expect.any(SyntaxError),
97 message: "Failed to decode mobile storage value for key t3code.connections.",
98 }),
99 );
100
101 warn.mockRestore();
102 });
72});103});
apps/mobile/src/lib/storage.ts+84-11
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import * as Arr from "effect/Array";1import * as Arr from "effect/Array";
2import { pipe } from "effect/Function";2import { pipe } from "effect/Function";
3import * as Schema from "effect/Schema";
3import * as SecureStore from "expo-secure-store";4import * as SecureStore from "expo-secure-store";
4import { EnvironmentId } from "@t3tools/contracts";5import { EnvironmentId } from "@t3tools/contracts";
56
...@@ -12,21 +13,72 @@ import {...@@ -12,21 +13,72 @@ import {
12const CONNECTIONS_KEY = "t3code.connections";13const CONNECTIONS_KEY = "t3code.connections";
13const PREFERENCES_KEY = "t3code.preferences";14const PREFERENCES_KEY = "t3code.preferences";
14const AGENT_AWARENESS_DEVICE_ID_KEY = "t3code.agent-awareness.device-id";15const AGENT_AWARENESS_DEVICE_ID_KEY = "t3code.agent-awareness.device-id";
16const MobileStorageKey = Schema.Literals([
17 CONNECTIONS_KEY,
18 PREFERENCES_KEY,
19 AGENT_AWARENESS_DEVICE_ID_KEY,
20]);
21type MobileStorageKeyValue = typeof MobileStorageKey.Type;
22
23export class MobileSecureStorageError extends Schema.TaggedErrorClass<MobileSecureStorageError>()(
24 "MobileSecureStorageError",
25 {
26 operation: Schema.Literals(["read", "write", "generate-device-id"]),
27 key: MobileStorageKey,
28 cause: Schema.Defect(),
29 },
30) {
31 override get message(): string {
32 return `Mobile secure storage operation ${this.operation} failed for key ${this.key}.`;
33 }
34}
35
36export class MobileStorageDecodeError extends Schema.TaggedErrorClass<MobileStorageDecodeError>()(
37 "MobileStorageDecodeError",
38 {
39 key: MobileStorageKey,
40 cause: Schema.Defect(),
41 },
42) {
43 override get message(): string {
44 return `Failed to decode mobile storage value for key ${this.key}.`;
45 }
46}
47
48export class MobileStorageEncodeError extends Schema.TaggedErrorClass<MobileStorageEncodeError>()(
49 "MobileStorageEncodeError",
50 {
51 key: MobileStorageKey,
52 cause: Schema.Defect(),
53 },
54) {
55 override get message(): string {
56 return `Failed to encode mobile storage value for key ${this.key}.`;
57 }
58}
1559
16export interface Preferences {60export interface Preferences {
17 readonly liveActivitiesEnabled?: boolean;61 readonly liveActivitiesEnabled?: boolean;
18 readonly terminalFontSize?: number;62 readonly terminalFontSize?: number;
19}63}
2064
21async function readStorageItem(key: string): Promise<string | null> {65async function readStorageItem(key: MobileStorageKeyValue): Promise<string | null> {
22 return await SecureStore.getItemAsync(key);66 try {
67 return await SecureStore.getItemAsync(key);
68 } catch (cause) {
69 throw new MobileSecureStorageError({ operation: "read", key, cause });
70 }
23}71}
2472
25async function writeStorageItem(key: string, value: string): Promise<void> {73async function writeStorageItem(key: MobileStorageKeyValue, value: string): Promise<void> {
26 await SecureStore.setItemAsync(key, value);74 try {
75 await SecureStore.setItemAsync(key, value);
76 } catch (cause) {
77 throw new MobileSecureStorageError({ operation: "write", key, cause });
78 }
27}79}
2880
29async function readJsonStorageItem<T>(key: string): Promise<T | null> {81async function readJsonStorageItem<T>(key: MobileStorageKeyValue): Promise<T | null> {
30 const raw = (await readStorageItem(key)) ?? "";82 const raw = (await readStorageItem(key)) ?? "";
31 if (!raw.trim()) {83 if (!raw.trim()) {
32 return null;84 return null;
...@@ -34,11 +86,25 @@ async function readJsonStorageItem<T>(key: string): Promise<T | null> {...@@ -34,11 +86,25 @@ async function readJsonStorageItem<T>(key: string): Promise<T | null> {
3486
35 try {87 try {
36 return JSON.parse(raw) as T;88 return JSON.parse(raw) as T;
37 } catch {89 } catch (cause) {
90 console.warn(
91 "[mobile-storage] ignored invalid JSON",
92 new MobileStorageDecodeError({ key, cause }),
93 );
38 return null;94 return null;
39 }95 }
40}96}
4197
98async function writeJsonStorageItem(key: MobileStorageKeyValue, value: unknown) {
99 let encoded: string;
100 try {
101 encoded = JSON.stringify(value);
102 } catch (cause) {
103 throw new MobileStorageEncodeError({ key, cause });
104 }
105 await writeStorageItem(key, encoded);
106}
107
42export async function loadSavedConnections(): Promise<ReadonlyArray<SavedRemoteConnection>> {108export async function loadSavedConnections(): Promise<ReadonlyArray<SavedRemoteConnection>> {
43 const parsed = await readJsonStorageItem<{109 const parsed = await readJsonStorageItem<{
44 readonly connections?: ReadonlyArray<SavedRemoteConnection>;110 readonly connections?: ReadonlyArray<SavedRemoteConnection>;
...@@ -67,7 +133,7 @@ export async function saveConnection(connection: SavedRemoteConnection): Promise...@@ -67,7 +133,7 @@ export async function saveConnection(connection: SavedRemoteConnection): Promise
67 )133 )
68 : pipe(current, Arr.append(stableConnection));134 : pipe(current, Arr.append(stableConnection));
69135
70 await writeStorageItem(CONNECTIONS_KEY, JSON.stringify({ connections: next }));136 await writeJsonStorageItem(CONNECTIONS_KEY, { connections: next });
71}137}
72138
73export async function clearSavedConnection(environmentId: EnvironmentId): Promise<void> {139export async function clearSavedConnection(environmentId: EnvironmentId): Promise<void> {
...@@ -76,7 +142,7 @@ export async function clearSavedConnection(environmentId: EnvironmentId): Promis...@@ -76,7 +142,7 @@ export async function clearSavedConnection(environmentId: EnvironmentId): Promis
76 current,142 current,
77 Arr.filter((entry) => entry.environmentId !== environmentId),143 Arr.filter((entry) => entry.environmentId !== environmentId),
78 );144 );
79 await writeStorageItem(CONNECTIONS_KEY, JSON.stringify({ connections: next }));145 await writeJsonStorageItem(CONNECTIONS_KEY, { connections: next });
80}146}
81147
82export async function loadPreferences(): Promise<Preferences> {148export async function loadPreferences(): Promise<Preferences> {
...@@ -106,7 +172,7 @@ export async function savePreferencesPatch(patch: Partial<Preferences>): Promise...@@ -106,7 +172,7 @@ export async function savePreferencesPatch(patch: Partial<Preferences>): Promise
106 ...current,172 ...current,
107 ...patch,173 ...patch,
108 };174 };
109 await writeStorageItem(PREFERENCES_KEY, JSON.stringify(next));175 await writeJsonStorageItem(PREFERENCES_KEY, next);
110 return next;176 return next;
111}177}
112178
...@@ -116,8 +182,15 @@ export async function loadOrCreateAgentAwarenessDeviceId(): Promise<string> {...@@ -116,8 +182,15 @@ export async function loadOrCreateAgentAwarenessDeviceId(): Promise<string> {
116 return existing;182 return existing;
117 }183 }
118184
119 const { uuidv4 } = await import("./uuid");185 const deviceId = await import("./uuid")
120 const deviceId = uuidv4();186 .then(({ uuidv4 }) => uuidv4())
187 .catch((cause) => {
188 throw new MobileSecureStorageError({
189 operation: "generate-device-id",
190 key: AGENT_AWARENESS_DEVICE_ID_KEY,
191 cause,
192 });
193 });
121 await writeStorageItem(AGENT_AWARENESS_DEVICE_ID_KEY, deviceId);194 await writeStorageItem(AGENT_AWARENESS_DEVICE_ID_KEY, deviceId);
122 return deviceId;195 return deviceId;
123}196}