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", () => {
6969 toStableSavedRemoteConnection(managedConnection),
7070 ]);
7171 });
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 });
72103});
apps/mobile/src/lib/storage.ts+84-11
......@@ -1,5 +1,6 @@
11import * as Arr from "effect/Array";
22import { pipe } from "effect/Function";
3import * as Schema from "effect/Schema";
34import * as SecureStore from "expo-secure-store";
45import { EnvironmentId } from "@t3tools/contracts";
56
......@@ -12,21 +13,72 @@ import {
1213const CONNECTIONS_KEY = "t3code.connections";
1314const PREFERENCES_KEY = "t3code.preferences";
1415const 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
1660export interface Preferences {
1761 readonly liveActivitiesEnabled?: boolean;
1862 readonly terminalFontSize?: number;
1963}
2064
21async function readStorageItem(key: string): Promise<string | null> {
22 return await SecureStore.getItemAsync(key);
65async function readStorageItem(key: MobileStorageKeyValue): Promise<string | null> {
66 try {
67 return await SecureStore.getItemAsync(key);
68 } catch (cause) {
69 throw new MobileSecureStorageError({ operation: "read", key, cause });
70 }
2371}
2472
25async function writeStorageItem(key: string, value: string): Promise<void> {
26 await SecureStore.setItemAsync(key, value);
73async function writeStorageItem(key: MobileStorageKeyValue, value: string): Promise<void> {
74 try {
75 await SecureStore.setItemAsync(key, value);
76 } catch (cause) {
77 throw new MobileSecureStorageError({ operation: "write", key, cause });
78 }
2779}
2880
29async function readJsonStorageItem<T>(key: string): Promise<T | null> {
81async function readJsonStorageItem<T>(key: MobileStorageKeyValue): Promise<T | null> {
3082 const raw = (await readStorageItem(key)) ?? "";
3183 if (!raw.trim()) {
3284 return null;
......@@ -34,11 +86,25 @@ async function readJsonStorageItem<T>(key: string): Promise<T | null> {
3486
3587 try {
3688 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 );
3894 return null;
3995 }
4096}
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
42108export async function loadSavedConnections(): Promise<ReadonlyArray<SavedRemoteConnection>> {
43109 const parsed = await readJsonStorageItem<{
44110 readonly connections?: ReadonlyArray<SavedRemoteConnection>;
......@@ -67,7 +133,7 @@ export async function saveConnection(connection: SavedRemoteConnection): Promise
67133 )
68134 : pipe(current, Arr.append(stableConnection));
69135
70 await writeStorageItem(CONNECTIONS_KEY, JSON.stringify({ connections: next }));
136 await writeJsonStorageItem(CONNECTIONS_KEY, { connections: next });
71137}
72138
73139export async function clearSavedConnection(environmentId: EnvironmentId): Promise<void> {
......@@ -76,7 +142,7 @@ export async function clearSavedConnection(environmentId: EnvironmentId): Promis
76142 current,
77143 Arr.filter((entry) => entry.environmentId !== environmentId),
78144 );
79 await writeStorageItem(CONNECTIONS_KEY, JSON.stringify({ connections: next }));
145 await writeJsonStorageItem(CONNECTIONS_KEY, { connections: next });
80146}
81147
82148export async function loadPreferences(): Promise<Preferences> {
......@@ -106,7 +172,7 @@ export async function savePreferencesPatch(patch: Partial<Preferences>): Promise
106172 ...current,
107173 ...patch,
108174 };
109 await writeStorageItem(PREFERENCES_KEY, JSON.stringify(next));
175 await writeJsonStorageItem(PREFERENCES_KEY, next);
110176 return next;
111177}
112178
......@@ -116,8 +182,15 @@ export async function loadOrCreateAgentAwarenessDeviceId(): Promise<string> {
116182 return existing;
117183 }
118184
119 const { uuidv4 } = await import("./uuid");
120 const deviceId = uuidv4();
185 const deviceId = await import("./uuid")
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 });
121194 await writeStorageItem(AGENT_AWARENESS_DEVICE_ID_KEY, deviceId);
122195 return deviceId;
123196}