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:
Mohamed Boudra
2026-08-13 12:02:39 +02:00
committed by GitHub
parent c190fed27c
commit 36312352db
69 changed files with 1801 additions and 800 deletions
+26 -39
View File
@@ -1,3 +1,5 @@
import { z } from "zod";
export type ReviewDraftMode = "uncommitted" | "base";
export type ReviewDraftSide = "old" | "new";
@@ -30,8 +32,27 @@ export interface ReviewDraftStoreState {
// Only drafts are persisted; diffModeOverrides is intentionally excluded.
export interface SerializedReviewDraftState {
drafts: Record<string, ReviewDraftComment[]>;
activeModesByScope?: Record<string, ReviewDraftMode>;
}
const IsoDateTimeSchema = z.string().datetime({ offset: true });
export const ReviewDraftCommentSchema: z.ZodType<ReviewDraftComment> = z.strictObject({
id: z.string(),
filePath: z.string(),
side: z.enum(["old", "new"]),
lineNumber: z.number().int().positive(),
body: z.string(),
createdAt: IsoDateTimeSchema,
updatedAt: IsoDateTimeSchema,
});
export const SerializedReviewDraftStateSchema: z.ZodType<SerializedReviewDraftState> =
z.strictObject({
drafts: z.record(z.string(), z.array(ReviewDraftCommentSchema)),
// COMPAT(reviewDraftModes): v1 persisted this field; v2 discards it during migration.
activeModesByScope: z.record(z.string(), z.enum(["uncommitted", "base"])).optional(),
});
export function setDiffModeOverrideInState(
state: ReviewDraftStoreState,
input: { scopeKey: string; override: DiffModeOverride },
@@ -160,45 +181,11 @@ export function serializeReviewDraftState(
}
export function normalizePersistedState(state: unknown): ReviewDraftStoreState {
if (!state || typeof state !== "object") {
return { drafts: {}, diffModeOverrides: {} };
}
// activeModesByScope may be present in old persisted JSON — tolerate and ignore it.
const persisted = state as { drafts?: unknown };
const drafts = persisted.drafts;
if (!drafts || typeof drafts !== "object" || Array.isArray(drafts)) {
return { drafts: {}, diffModeOverrides: {} };
}
const normalized: Record<string, ReviewDraftComment[]> = {};
for (const [key, value] of Object.entries(drafts)) {
if (!Array.isArray(value)) {
continue;
}
normalized[key] = value.filter((comment): comment is ReviewDraftComment =>
isReviewDraftComment(comment),
);
}
return { drafts: normalized, diffModeOverrides: {} };
}
export function isReviewDraftComment(value: unknown): value is ReviewDraftComment {
if (!value || typeof value !== "object") {
return false;
}
const record = value as Record<string, unknown>;
return (
typeof record.id === "string" &&
typeof record.filePath === "string" &&
(record.side === "old" || record.side === "new") &&
typeof record.lineNumber === "number" &&
Number.isInteger(record.lineNumber) &&
record.lineNumber > 0 &&
typeof record.body === "string" &&
typeof record.createdAt === "string" &&
typeof record.updatedAt === "string"
);
const result = SerializedReviewDraftStateSchema.safeParse(state);
return {
drafts: result.success ? result.data.drafts : {},
diffModeOverrides: {},
};
}
function applyCommentUpdates(
+39 -12
View File
@@ -1,5 +1,7 @@
import { describe, expect, it } from "vitest";
import type { StateStorage } from "zustand/middleware";
import type { ParsedDiffFile } from "@/git/use-diff-query";
import { createValidatedPersistStorage } from "@/storage/validated-persist-storage";
import {
buildReviewAttachmentSnapshot,
buildReviewDraftKey,
@@ -16,6 +18,7 @@ import {
type ReviewDraftComment,
type ReviewDraftStoreState,
serializeReviewDraftState,
SerializedReviewDraftStateSchema,
setDiffModeOverrideInState,
updateCommentInState,
} from "./state";
@@ -69,6 +72,20 @@ function makeFile(): ParsedDiffFile {
};
}
function createMemoryStorage(): StateStorage & { values: Map<string, string> } {
const values = new Map<string, string>();
return {
values,
getItem: async (key) => values.get(key) ?? null,
setItem: async (key, value) => {
values.set(key, value);
},
removeItem: async (key) => {
values.delete(key);
},
};
}
describe("buildReviewDraftKey", () => {
it("scopes by server, workspace-or-cwd, diff mode, base ref, and whitespace mode", () => {
const base = buildReviewDraftKey({
@@ -122,7 +139,27 @@ describe("buildReviewDraftKey", () => {
});
describe("normalizePersistedState", () => {
it("filters invalid draft comments and ignores activeModesByScope from old persisted JSON", () => {
it("keeps v1 review comments for migration while dropping the legacy mode field", async () => {
const backing = createMemoryStorage();
const legacyState = {
drafts: { "review:key": [makeComment()] },
activeModesByScope: { "review:scope": "base" },
};
backing.values.set(
"@paseo:review-draft-store",
JSON.stringify({ state: legacyState, version: 1 }),
);
const storage = createValidatedPersistStorage(backing, SerializedReviewDraftStateSchema);
const stored = await storage.getItem("@paseo:review-draft-store");
const normalized = normalizePersistedState(stored?.state);
expect(normalized.drafts["review:key"]).toEqual([makeComment()]);
expect(normalized.diffModeOverrides).toEqual({});
expect(backing.values.has("@paseo:review-draft-store")).toBe(true);
});
it("rejects the complete payload when any draft comment or field is invalid", () => {
const normalized = normalizePersistedState({
drafts: {
"review:key": [
@@ -146,17 +183,7 @@ describe("normalizePersistedState", () => {
});
expect(normalized.diffModeOverrides).toEqual({});
expect(normalized.drafts["review:key"]).toEqual([
{
id: "comment-1",
filePath: "src/example.ts",
side: "new",
lineNumber: 41,
body: "Keep me.",
createdAt: "2026-04-21T00:00:00.000Z",
updatedAt: "2026-04-21T00:00:00.000Z",
},
]);
expect(normalized.drafts).toEqual({});
});
it("returns empty state for null, non-object, or malformed inputs", () => {
+4 -2
View File
@@ -1,7 +1,7 @@
import { useMemo } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import { persist } from "zustand/middleware";
import type { ComposerAttachment } from "@/attachments/types";
import type { ParsedDiffFile } from "@/git/use-diff-query";
import {
@@ -17,12 +17,14 @@ import {
type ReviewDraftSide,
type ReviewDraftStoreState,
serializeReviewDraftState,
SerializedReviewDraftStateSchema,
setDiffModeOverrideInState,
updateCommentInState,
} from "@/review/state";
import { generateMessageId } from "@/types/stream";
import { buildNumberedDiffHunks, type NumberedDiffLine } from "@/utils/diff-layout";
import type { AgentAttachment } from "@getpaseo/protocol/messages";
import { createValidatedPersistStorage } from "@/storage/validated-persist-storage";
export type {
DiffModeOverride,
@@ -168,7 +170,7 @@ export const useReviewDraftStore = create<ReviewDraftStore>()(
{
name: "@paseo:review-draft-store",
version: STORE_VERSION,
storage: createJSONStorage(() => AsyncStorage),
storage: createValidatedPersistStorage(AsyncStorage, SerializedReviewDraftStateSchema),
partialize: (state) => serializeReviewDraftState(state),
migrate: async (state) => normalizePersistedState(state),
},