From 8b39c05561c080d7489e4aea13161d5a820ce7dd Mon Sep 17 00:00:00 2001 From: Neonforge <48338160+Neonforge98@users.noreply.github.com> Date: Wed, 5 Aug 2026 21:25:02 -0700 Subject: [PATCH] fix(cloud): shed corrupt store entries instead of resetting whole stores MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createZodJsonStorage answers a failed whole-store parse with the initial value. For the cloud push planes' record-shaped stores that turned one corrupted entry into a fleet-scale event at load: - accessSettings reset drops every explicit per-session override; the next pass resolves previously shared sessions effective-off and RETRACTS their cloud rows — one corrupted byte silently unshares the user's work. - sessionOrgTags had no validation at all (default JSON storage), and a reset makes the ownership gate retract every explicitly moved session's row. - repoScopes/syncEnabled/pushedMetadata/collabStateCursors resets re-enable disabled orgs, orphan metadata rows, or widen deltas. A shared tolerantRecordSchema now parses record entries independently and drops only invalid ones (nested for accessSettings, so a corrupt session entry costs that session, not its org). The push-cursor store's inline transform from the incremental-replay work migrates to the same helper. A non-record root still falls back to the initial value — that is unrecoverable garbage, not a partial loss. Pre-commit hook ran. Total eslint: 58, total circular: 0 --- .../Org2Cloud/org2CloudAccessSettings.test.ts | 38 ++++++++++++ .../Org2Cloud/org2CloudAccessSettings.ts | 30 +++++++--- src/features/Org2Cloud/org2CloudSyncAtoms.ts | 58 +++++++++---------- .../TeamCollaboration/sessionOrgTagsAtom.ts | 21 ++++++- src/util/core/storage/zodStorage.test.ts | 42 +++++++++++++- src/util/core/storage/zodStorage.ts | 31 +++++++++- 6 files changed, 179 insertions(+), 41 deletions(-) diff --git a/src/features/Org2Cloud/org2CloudAccessSettings.test.ts b/src/features/Org2Cloud/org2CloudAccessSettings.test.ts index f07a8cb4c..a520e3daa 100644 --- a/src/features/Org2Cloud/org2CloudAccessSettings.test.ts +++ b/src/features/Org2Cloud/org2CloudAccessSettings.test.ts @@ -169,3 +169,41 @@ describe("immutable update helpers", () => { expect(byOrg[ORG].sessionVisibility).toEqual({}); }); }); + +describe("store resilience", () => { + it("sheds only corrupt entries at every record level", async () => { + const { vi } = await import("vitest"); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const { CloudAccessSettingsByOrgSchema } = + await import("./org2CloudAccessSettings"); + const parsed = CloudAccessSettingsByOrgSchema.parse({ + "org-healthy": { + sessionModes: { + "session-shared": "full_replay", + "session-corrupt-mode": "not-a-mode", + }, + sessionVisibility: {}, + }, + "org-corrupt": "garbage", + }); + + // The corrupt org entry is shed; the healthy org keeps its overrides — + // a whole-store reset would make every previously shared session + // resolve effective-off and retract its cloud row on the next pass. + expect(Object.keys(parsed)).toEqual(["org-healthy"]); + expect( + getEffectiveCloudAccessMode( + getCloudOrgAccessSettings(parsed, "org-healthy"), + "session-shared" + ) + ).toBe("full_replay"); + // Inside the healthy org only the corrupt session entry is gone. + expect( + getEffectiveCloudAccessMode( + getCloudOrgAccessSettings(parsed, "org-healthy"), + "session-corrupt-mode" + ) + ).toBe("off"); + warn.mockRestore(); + }); +}); diff --git a/src/features/Org2Cloud/org2CloudAccessSettings.ts b/src/features/Org2Cloud/org2CloudAccessSettings.ts index bed3d1089..59257e5af 100644 --- a/src/features/Org2Cloud/org2CloudAccessSettings.ts +++ b/src/features/Org2Cloud/org2CloudAccessSettings.ts @@ -40,7 +40,10 @@ import type { CollabSessionAccessMode, CollabSessionVisibility, } from "@src/store/collaboration/types"; -import { createZodJsonStorage } from "@src/util/core/storage/zodStorage"; +import { + createZodJsonStorage, + tolerantRecordSchema, +} from "@src/util/core/storage/zodStorage"; const CloudAccessModeSchema = z.enum([ COLLAB_SESSION_ACCESS_MODE.OFF, @@ -53,17 +56,30 @@ const CloudVisibilitySchema = z.enum([ COLLAB_SESSION_VISIBILITY.RESTRICTED, ]) satisfies z.ZodType; +/** + * Tolerant at every record level. This store is the privacy ratchet: a + * whole-store reset drops every explicit per-session override, and on the + * next pass previously shared sessions resolve effective-off and get their + * cloud rows RETRACTED — one corrupted byte silently unsharing the user's + * work. A corrupted entry must cost exactly that entry, never the store. + */ const CloudOrgAccessSettingsSchema = z.object({ - sessionModes: z.record(z.string(), CloudAccessModeSchema), - sessionVisibility: z.record(z.string(), CloudVisibilitySchema), + sessionModes: tolerantRecordSchema( + "session access mode", + CloudAccessModeSchema + ), + sessionVisibility: tolerantRecordSchema( + "session visibility", + CloudVisibilitySchema + ), }); export type CloudOrgAccessSettings = z.output< typeof CloudOrgAccessSettingsSchema >; -const CloudAccessSettingsByOrgSchema = z.record( - z.string(), +export const CloudAccessSettingsByOrgSchema = tolerantRecordSchema( + "access-settings org", CloudOrgAccessSettingsSchema ); @@ -85,8 +101,8 @@ org2CloudAccessSettingsAtom.debugLabel = "org2CloudAccessSettingsAtom"; // Org sharing FLOOR (admin policy mirror, 0002) // ============================================================================ -const CloudSharingFloorByOrgSchema = z.record( - z.string(), +const CloudSharingFloorByOrgSchema = tolerantRecordSchema( + "sharing floor", CloudAccessModeSchema ); diff --git a/src/features/Org2Cloud/org2CloudSyncAtoms.ts b/src/features/Org2Cloud/org2CloudSyncAtoms.ts index bb847f2b0..22f40091d 100644 --- a/src/features/Org2Cloud/org2CloudSyncAtoms.ts +++ b/src/features/Org2Cloud/org2CloudSyncAtoms.ts @@ -21,7 +21,10 @@ import { atomWithStorage } from "jotai/utils"; import { z } from "zod/v4"; -import { createZodJsonStorage } from "@src/util/core/storage/zodStorage"; +import { + createZodJsonStorage, + tolerantRecordSchema, +} from "@src/util/core/storage/zodStorage"; import { MERKLE_FRONTIER_MAX_HEIGHT } from "./org2CloudMerkleFrontier"; @@ -79,7 +82,10 @@ export interface ImportedReplayCheckpoint { frozenHashFrontier: Array; } -const RepoScopesSchema = z.record(z.string(), z.array(z.string())); +const RepoScopesSchema = tolerantRecordSchema( + "repo scope", + z.array(z.string()) +); /** Cloud orgId → locally-known repo scopes (normalized remote keys). */ export const org2CloudRepoScopesAtom = atomWithStorage< @@ -89,7 +95,10 @@ export const org2CloudRepoScopesAtom = atomWithStorage< }); org2CloudRepoScopesAtom.debugLabel = "org2CloudRepoScopesAtom"; -const SyncEnabledSchema = z.record(z.string(), z.boolean()); +const SyncEnabledSchema = tolerantRecordSchema( + "sync-enabled flag", + z.boolean() +); /** Cloud orgId → sync toggle; missing key = enabled (default ON). */ export const org2CloudSyncEnabledAtom = atomWithStorage< @@ -125,33 +134,14 @@ const CloudPushCursorSchema = z.object({ }) satisfies z.ZodType; /** - * Per-entry tolerant store parse. `createZodJsonStorage` answers a failed - * whole-store parse with the initial value — for this store that would reset - * EVERY push cursor, and a full reset re-anchors every previously pushed - * session through an epoch rewrite on its next pass (fleet-wide churn in the - * #608 shape). One malformed entry (disk corruption, or a future checkpoint - * version rolled back to this build) must instead cost exactly one cursor: - * losing one is the designed recovery — that session alone re-anchors - * through the server OCC check. + * Per-entry tolerant: a whole-store reset would re-anchor every pushed + * session through an epoch rewrite (fleet-wide churn in the #608 shape); + * dropping one cursor re-anchors one session, the designed recovery. */ -export const CloudPushCursorsSchema = z - .record(z.string(), z.unknown()) - .transform((entries) => { - const cursors: Record = {}; - for (const [key, value] of Object.entries(entries)) { - const parsed = CloudPushCursorSchema.safeParse(value); - if (parsed.success) { - cursors[key] = parsed.data; - } else { - // Rate limiting is unnecessary: this runs once per storage load. - console.warn( - `[org2CloudSyncAtoms] dropped invalid push cursor "${key}"; ` + - "its session re-anchors on the next pass" - ); - } - } - return cursors; - }); +export const CloudPushCursorsSchema = tolerantRecordSchema( + "push cursor", + CloudPushCursorSchema +); /** Keyed by `${orgId}:${sessionId}` (cloud org ids, no collision risk). */ export const org2CloudPushCursorsAtom = atomWithStorage< @@ -164,7 +154,10 @@ export const org2CloudPushCursorsAtom = atomWithStorage< ); org2CloudPushCursorsAtom.debugLabel = "org2CloudPushCursorsAtom"; -const PushedMetadataSchema = z.record(z.string(), z.literal(true)); +const PushedMetadataSchema = tolerantRecordSchema( + "pushed-metadata marker", + z.literal(true) +); /** * Persisted "we put a live metadata row on the server" marker, keyed @@ -185,7 +178,10 @@ export const org2CloudPushedMetadataAtom = atomWithStorage< ); org2CloudPushedMetadataAtom.debugLabel = "org2CloudPushedMetadataAtom"; -const CollabStateCursorsSchema = z.record(z.string(), z.string()); +const CollabStateCursorsSchema = tolerantRecordSchema( + "collab state cursor", + z.string() +); /** * Cloud orgId → ISO delta cursor for `cloud_list_org_collab_state` diff --git a/src/features/TeamCollaboration/sessionOrgTagsAtom.ts b/src/features/TeamCollaboration/sessionOrgTagsAtom.ts index ff304a788..62f437928 100644 --- a/src/features/TeamCollaboration/sessionOrgTagsAtom.ts +++ b/src/features/TeamCollaboration/sessionOrgTagsAtom.ts @@ -24,18 +24,37 @@ * source adapter at push time). */ import { atomWithStorage } from "jotai/utils"; +import { z } from "zod/v4"; import { buildCloudOrgSelectorValue, parseCloudOrgSelectorValue, } from "@src/features/Org2Cloud/org2CloudOrgsAtom"; +import { + createZodJsonStorage, + tolerantRecordSchema, +} from "@src/util/core/storage/zodStorage"; /** sessionId → list of org tokens the session is explicitly tagged to. */ export type SessionOrgTags = Record; +/** + * Per-entry tolerant, and validated at all — this store previously used the + * default JSON storage, where any parse failure resets EVERY tag. Losing a + * tag is not cosmetic: the push engine's ownership gate retracts the cloud + * row of a pushed session whose tag disappeared, so a whole-store reset + * silently unshares every explicitly moved session. A corrupted entry now + * costs exactly that session's tags. + */ +export const SessionOrgTagsSchema = tolerantRecordSchema( + "session org tag", + z.array(z.string()) +); + export const sessionOrgTagsAtom = atomWithStorage( "orgii:session-org-tags-v1", - {} + {}, + createZodJsonStorage(SessionOrgTagsSchema) ); sessionOrgTagsAtom.debugLabel = "sessionOrgTagsAtom"; diff --git a/src/util/core/storage/zodStorage.test.ts b/src/util/core/storage/zodStorage.test.ts index 47aaf8291..0997f68e1 100644 --- a/src/util/core/storage/zodStorage.test.ts +++ b/src/util/core/storage/zodStorage.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { z } from "zod/v4"; -import { createZodJsonStorage } from "./zodStorage"; +import { createZodJsonStorage, tolerantRecordSchema } from "./zodStorage"; const ListSchema = z.array(z.string()); @@ -62,3 +62,43 @@ describe("createZodJsonStorage", () => { removeSpy.mockRestore(); }); }); + +describe("tolerantRecordSchema", () => { + it("drops only invalid entries and keeps the rest", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const schema = tolerantRecordSchema("thing", z.object({ n: z.number() })); + expect( + schema.parse({ + good: { n: 1 }, + wrongShape: { n: "nope" }, + notAnObject: "garbage", + alsoGood: { n: 2 }, + }) + ).toEqual({ good: { n: 1 }, alsoGood: { n: 2 } }); + expect(warn).toHaveBeenCalledTimes(2); + warn.mockRestore(); + }); + + it("parses an empty record and still fails non-record roots", () => { + const schema = tolerantRecordSchema("thing", z.string()); + expect(schema.parse({})).toEqual({}); + // A non-record root is unrecoverable garbage: the whole-store initial + // value fallback in createZodJsonStorage is the right behavior there. + expect(schema.safeParse("not-a-record").success).toBe(false); + expect(schema.safeParse(null).success).toBe(false); + }); + + it("composes through createZodJsonStorage so one bad entry never resets the store", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const storage = createZodJsonStorage( + tolerantRecordSchema("token list", z.array(z.string())) + ); + localStorage.setItem( + "tags", + JSON.stringify({ keep: ["cloud:org-1"], drop: 42 }) + ); + expect(storage.getItem("tags", {})).toEqual({ keep: ["cloud:org-1"] }); + expect(warn).toHaveBeenCalledTimes(1); + warn.mockRestore(); + }); +}); diff --git a/src/util/core/storage/zodStorage.ts b/src/util/core/storage/zodStorage.ts index 487009e5e..389014590 100644 --- a/src/util/core/storage/zodStorage.ts +++ b/src/util/core/storage/zodStorage.ts @@ -1,10 +1,39 @@ -import type { z } from "zod/v4"; +import { z } from "zod/v4"; import { removeBrowserStorageItemSafely, setBrowserStorageItemWithRecovery, } from "./quotaRecovery"; +/** + * Record schema that parses every entry independently and DROPS invalid + * entries instead of failing the whole record. `createZodJsonStorage` + * answers a failed whole-store parse with the initial value, so for a + * record-shaped store one corrupted entry (disk damage, or a future + * entry shape rolled back onto this build) would otherwise reset EVERY + * entry at load — for cloud push state that scale of loss converts into + * fleet-wide re-anchors or retracts. Losing one entry is the designed, + * self-healing recovery; losing the store is an incident. + */ +export function tolerantRecordSchema( + label: string, + valueSchema: z.ZodType +) { + return z.record(z.string(), z.unknown()).transform((entries) => { + const valid: Record = {}; + for (const [key, value] of Object.entries(entries)) { + const parsed = valueSchema.safeParse(value); + if (parsed.success) { + valid[key] = parsed.data; + } else { + // Once per storage load per bad entry — no rate limit needed. + console.warn(`[zodStorage] dropped invalid ${label} entry "${key}"`); + } + } + return valid; + }); +} + export interface ZodSyncStorage { getItem: (key: string, initialValue: T) => T; setItem: (key: string, value: T) => void;