fix(app): validate persisted client state (#3289)
* fix(app): validate persisted client state Persisted replica data could satisfy TypeScript through an assertion while missing fields required by renderers. Treat every AsyncStorage value as untrusted input, clear values that fail strict schemas, and version the replica DTO so incompatible caches cannot hydrate. * fix(app): preserve validated legacy state * fix(app): preserve validated preferences Preserve supported legacy location fields and every shipped theme while keeping unknown persisted fields rejected. Align browser test seeds with the current preference shape so strict validation exercises the intended provider. * fix(app): migrate legacy agent preferences Released clients stored host/location fields and per-provider thinkingOptionId together. Migrate those exact strict bytes into current preferences without admitting unknown fields. * fix(app): preserve drafts with legacy reviews Accept the exact strict shape released for workspace review attachments so migration can discard that obsolete attachment without clearing every persisted draft.
This commit is contained in:
@@ -32,7 +32,7 @@ describe("workspace service route preferences", () => {
|
||||
expect(restored.getState().byServerId).toEqual({ desktop: "direct", devbox: "public" });
|
||||
});
|
||||
|
||||
it("drops invalid route kinds from persisted storage", async () => {
|
||||
it("clears the complete persisted value when one route kind is invalid", async () => {
|
||||
const storage = createMemoryStorage({
|
||||
"workspace-service-route-preferences": JSON.stringify({
|
||||
state: { byServerId: { desktop: "direct", broken: "unknown" } },
|
||||
@@ -42,6 +42,7 @@ describe("workspace service route preferences", () => {
|
||||
const store = createWorkspaceServiceRoutePreferencesStore(storage);
|
||||
await store.persist.rehydrate();
|
||||
|
||||
expect(store.getState().byServerId).toEqual({ desktop: "direct" });
|
||||
expect(store.getState().byServerId).toEqual({});
|
||||
expect(storage.values.has("workspace-service-route-preferences")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist, type StateStorage } from "zustand/middleware";
|
||||
import { persist, type StateStorage } from "zustand/middleware";
|
||||
import { z } from "zod";
|
||||
import { createValidatedPersistStorage } from "@/storage/validated-persist-storage";
|
||||
import type { WorkspaceScriptLinkKind } from "@/utils/workspace-script-links";
|
||||
|
||||
interface WorkspaceServiceRoutePreferencesState {
|
||||
@@ -8,25 +10,19 @@ interface WorkspaceServiceRoutePreferencesState {
|
||||
setPreferredRoute: (serverId: string, kind: WorkspaceScriptLinkKind) => void;
|
||||
}
|
||||
|
||||
function isWorkspaceScriptLinkKind(value: unknown): value is WorkspaceScriptLinkKind {
|
||||
return value === "public" || value === "paseo" || value === "direct";
|
||||
}
|
||||
|
||||
function sanitizePreferences(value: unknown): Record<string, WorkspaceScriptLinkKind> {
|
||||
if (!value || typeof value !== "object") return {};
|
||||
const byServerId = (value as { byServerId?: unknown }).byServerId;
|
||||
if (!byServerId || typeof byServerId !== "object") return {};
|
||||
|
||||
const result: Record<string, WorkspaceScriptLinkKind> = {};
|
||||
for (const [serverId, kind] of Object.entries(byServerId)) {
|
||||
if (isWorkspaceScriptLinkKind(kind)) result[serverId] = kind;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
const WorkspaceScriptLinkKindSchema = z.enum(["public", "paseo", "direct"]);
|
||||
const WorkspaceServiceRoutePreferencesSchema = z.strictObject({
|
||||
byServerId: z.record(z.string(), WorkspaceScriptLinkKindSchema),
|
||||
});
|
||||
|
||||
export function createWorkspaceServiceRoutePreferencesStore(storage: StateStorage) {
|
||||
return create<WorkspaceServiceRoutePreferencesState>()(
|
||||
persist(
|
||||
persist<
|
||||
WorkspaceServiceRoutePreferencesState,
|
||||
[],
|
||||
[],
|
||||
z.infer<typeof WorkspaceServiceRoutePreferencesSchema>
|
||||
>(
|
||||
(set) => ({
|
||||
byServerId: {},
|
||||
setPreferredRoute: (serverId, kind) =>
|
||||
@@ -35,12 +31,15 @@ export function createWorkspaceServiceRoutePreferencesStore(storage: StateStorag
|
||||
{
|
||||
name: "workspace-service-route-preferences",
|
||||
version: 1,
|
||||
storage: createJSONStorage(() => storage),
|
||||
storage: createValidatedPersistStorage(storage, WorkspaceServiceRoutePreferencesSchema),
|
||||
partialize: (state) => ({ byServerId: state.byServerId }),
|
||||
merge: (persistedState, currentState) => ({
|
||||
...currentState,
|
||||
byServerId: sanitizePreferences(persistedState),
|
||||
}),
|
||||
merge: (persistedState, currentState) => {
|
||||
const result = WorkspaceServiceRoutePreferencesSchema.safeParse(persistedState);
|
||||
return {
|
||||
...currentState,
|
||||
byServerId: result.success ? result.data.byServerId : {},
|
||||
};
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user