1/**
2 * Strict structural validator for `PickedElementPayload` messages received
3 * from the in-page picker preload (`apps/desktop/src/preview/PickPreload.ts`)
4 * via `wc.ipc`. Lives in its own electron-free module so the validator is
5 * trivially unit-testable.
6 *
7 * Validation must be tight: downstream `normalizeElementContextSelection`
8 * calls `.trim()` on incoming strings, so a malformed payload (preload bug,
9 * future schema mismatch, malicious page that intercepts the preload's IPC
10 * channel via prototype pollution) would otherwise throw deep in the
11 * renderer and the chip silently never appears.
12 */
13import type { PickedElementPayload, PreviewAnnotationPayload } from "@t3tools/contracts";
14
15function isStringOrNull(value: unknown): value is string | null {
16 return value === null || typeof value === "string";
17}
18
19function isFiniteNumberOrNull(value: unknown): value is number | null {
20 return value === null || (typeof value === "number" && Number.isFinite(value));
21}
22
23function isPickedStackFrame(value: unknown): boolean {
24 if (typeof value !== "object" || value === null) return false;
25 const frame = value as Record<string, unknown>;
26 return (
27 isStringOrNull(frame["functionName"]) &&
28 isStringOrNull(frame["fileName"]) &&
29 isFiniteNumberOrNull(frame["lineNumber"]) &&
30 isFiniteNumberOrNull(frame["columnNumber"])
31 );
32}
33
34export function isPickedElementPayload(value: unknown): value is PickedElementPayload {
35 if (typeof value !== "object" || value === null) return false;
36 const c = value as Record<string, unknown>;
37 if (typeof c["pageUrl"] !== "string") return false;
38 if (typeof c["tagName"] !== "string") return false;
39 if (typeof c["htmlPreview"] !== "string") return false;
40 if (typeof c["styles"] !== "string") return false;
41 if (typeof c["pickedAt"] !== "string") return false;
42 if (!isStringOrNull(c["pageTitle"])) return false;
43 if (!isStringOrNull(c["selector"])) return false;
44 if (!isStringOrNull(c["componentName"])) return false;
45 if (c["source"] !== null && !isPickedStackFrame(c["source"])) return false;
46 if (!Array.isArray(c["stack"])) return false;
47 if (!c["stack"].every(isPickedStackFrame)) return false;
48 return true;
49}
50
51function isRect(value: unknown): boolean {
52 if (typeof value !== "object" || value === null) return false;
53 const rect = value as Record<string, unknown>;
54 return ["x", "y", "width", "height"].every(
55 (key) => typeof rect[key] === "number" && Number.isFinite(rect[key]),
56 );
57}
58
59function isPoint(value: unknown): boolean {
60 if (typeof value !== "object" || value === null) return false;
61 const point = value as Record<string, unknown>;
62 return (
63 typeof point["x"] === "number" &&
64 Number.isFinite(point["x"]) &&
65 typeof point["y"] === "number" &&
66 Number.isFinite(point["y"])
67 );
68}
69
70export function isPreviewAnnotationPayload(value: unknown): value is PreviewAnnotationPayload {
71 if (typeof value !== "object" || value === null) return false;
72 const annotation = value as Record<string, unknown>;
73 if (typeof annotation["id"] !== "string") return false;
74 if (typeof annotation["pageUrl"] !== "string") return false;
75 if (!isStringOrNull(annotation["pageTitle"])) return false;
76 if (typeof annotation["comment"] !== "string") return false;
77 if (typeof annotation["createdAt"] !== "string") return false;
78 if (annotation["screenshot"] !== null) return false;
79
80 const elements = annotation["elements"];
81 if (!Array.isArray(elements)) return false;
82 if (
83 !elements.every((entry) => {
84 if (typeof entry !== "object" || entry === null) return false;
85 const target = entry as Record<string, unknown>;
86 return (
87 typeof target["id"] === "string" &&
88 isPickedElementPayload(target["element"]) &&
89 isRect(target["rect"])
90 );
91 })
92 ) {
93 return false;
94 }
95
96 const regions = annotation["regions"];
97 if (!Array.isArray(regions)) return false;
98 if (
99 !regions.every((entry) => {
100 if (typeof entry !== "object" || entry === null) return false;
101 const target = entry as Record<string, unknown>;
102 return typeof target["id"] === "string" && isRect(target["rect"]);
103 })
104 ) {
105 return false;
106 }
107
108 const strokes = annotation["strokes"];
109 if (!Array.isArray(strokes)) return false;
110 if (
111 !strokes.every((entry) => {
112 if (typeof entry !== "object" || entry === null) return false;
113 const target = entry as Record<string, unknown>;
114 return (
115 typeof target["id"] === "string" &&
116 typeof target["color"] === "string" &&
117 typeof target["width"] === "number" &&
118 Number.isFinite(target["width"]) &&
119 Array.isArray(target["points"]) &&
120 target["points"].every(isPoint) &&
121 isRect(target["bounds"])
122 );
123 })
124 ) {
125 return false;
126 }
127
128 const styleChanges = annotation["styleChanges"];
129 if (!Array.isArray(styleChanges)) return false;
130 if (
131 !styleChanges.every((entry) => {
132 if (typeof entry !== "object" || entry === null) return false;
133 const change = entry as Record<string, unknown>;
134 return (
135 typeof change["targetId"] === "string" &&
136 isStringOrNull(change["selector"]) &&
137 typeof change["property"] === "string" &&
138 typeof change["previousValue"] === "string" &&
139 typeof change["value"] === "string"
140 );
141 })
142 ) {
143 return false;
144 }
145 return true;
146}