@farcaster/snap 1.15.4 → 1.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/dist/constants.d.ts +8 -0
  2. package/dist/constants.js +9 -1
  3. package/dist/index.d.ts +1 -1
  4. package/dist/index.js +1 -1
  5. package/dist/react/components/badge.js +2 -3
  6. package/dist/react/index.d.ts +9 -4
  7. package/dist/react/index.js +9 -228
  8. package/dist/react/snap-view-core.d.ts +11 -0
  9. package/dist/react/snap-view-core.js +224 -0
  10. package/dist/react/v1/snap-view.d.ts +14 -0
  11. package/dist/react/v1/snap-view.js +9 -0
  12. package/dist/react/v2/snap-view.d.ts +21 -0
  13. package/dist/react/v2/snap-view.js +76 -0
  14. package/dist/react-native/components/snap-badge.js +3 -3
  15. package/dist/react-native/index.d.ts +15 -45
  16. package/dist/react-native/index.js +10 -166
  17. package/dist/react-native/snap-view-core.d.ts +11 -0
  18. package/dist/react-native/snap-view-core.js +153 -0
  19. package/dist/react-native/types.d.ts +41 -0
  20. package/dist/react-native/types.js +1 -0
  21. package/dist/react-native/v1/snap-view.d.ts +22 -0
  22. package/dist/react-native/v1/snap-view.js +31 -0
  23. package/dist/react-native/v2/snap-view.d.ts +31 -0
  24. package/dist/react-native/v2/snap-view.js +101 -0
  25. package/dist/schemas.d.ts +15 -9
  26. package/dist/schemas.js +7 -8
  27. package/dist/server/parseRequest.d.ts +7 -0
  28. package/dist/server/parseRequest.js +22 -0
  29. package/dist/ui/schema.js +1 -1
  30. package/dist/validator.d.ts +3 -2
  31. package/dist/validator.js +193 -2
  32. package/llms.txt +9 -0
  33. package/package.json +1 -1
  34. package/src/constants.ts +11 -1
  35. package/src/index.ts +8 -0
  36. package/src/react/accent-context.tsx +1 -1
  37. package/src/react/components/badge.tsx +2 -3
  38. package/src/react/index.tsx +37 -330
  39. package/src/react/snap-view-core.tsx +340 -0
  40. package/src/react/v1/snap-view.tsx +50 -0
  41. package/src/react/v2/snap-view.tsx +168 -0
  42. package/src/react-native/components/snap-badge.tsx +3 -3
  43. package/src/react-native/index.tsx +47 -267
  44. package/src/react-native/snap-view-core.tsx +209 -0
  45. package/src/react-native/types.ts +37 -0
  46. package/src/react-native/v1/snap-view.tsx +108 -0
  47. package/src/react-native/v2/snap-view.tsx +239 -0
  48. package/src/schemas.ts +9 -10
  49. package/src/server/parseRequest.ts +34 -0
  50. package/src/ui/schema.ts +1 -1
  51. package/src/validator.ts +240 -2
@@ -0,0 +1,239 @@
1
+ import type { ReactNode } from "react";
2
+ import { useEffect, useMemo } from "react";
3
+ import { Platform, StyleSheet, Text, View } from "react-native";
4
+ import { SnapThemeProvider, useSnapTheme, type SnapNativeColors } from "../theme";
5
+ import { SnapViewCoreInner } from "../snap-view-core";
6
+ import {
7
+ validateSnapResponse,
8
+ type ValidationResult,
9
+ } from "@farcaster/snap";
10
+ import type { SnapPage, SnapActionHandlers } from "../types";
11
+
12
+ // ─── Constants ───────────────────────────────────────
13
+
14
+ const SNAP_MAX_HEIGHT = 500;
15
+ const SNAP_WARNING_HEIGHT = 700;
16
+
17
+ // ─── Validation fallback ─────────────────────────────
18
+
19
+ function SnapValidationFallback({ message }: { message?: string }) {
20
+ const { colors } = useSnapTheme();
21
+ return (
22
+ <View style={fallbackStyles.container}>
23
+ <Text style={[fallbackStyles.text, { color: colors.textSecondary }]}>
24
+ {message ? `Unable to render snap: ${message}` : "Unable to render snap"}
25
+ </Text>
26
+ </View>
27
+ );
28
+ }
29
+
30
+ const fallbackStyles = StyleSheet.create({
31
+ container: {
32
+ width: "100%",
33
+ padding: 16,
34
+ alignItems: "center",
35
+ justifyContent: "center",
36
+ },
37
+ text: {
38
+ fontSize: 14,
39
+ },
40
+ });
41
+
42
+ // ─── SnapViewV2 (with validation) ────────────────────
43
+
44
+ export function SnapViewV2Inner({
45
+ snap,
46
+ handlers,
47
+ loading = false,
48
+ onValidationError,
49
+ validationErrorFallback,
50
+ }: {
51
+ snap: SnapPage;
52
+ handlers: SnapActionHandlers;
53
+ loading?: boolean;
54
+ onValidationError?: (result: ValidationResult) => void;
55
+ validationErrorFallback?: ReactNode;
56
+ }) {
57
+ const validation = useMemo(() => validateSnapResponse(snap), [snap]);
58
+ const valid = validation.valid;
59
+ const validationMessage = validation.issues[0]?.message;
60
+
61
+ useEffect(() => {
62
+ if (!valid) {
63
+ if (onValidationError) {
64
+ onValidationError(validation);
65
+ } else {
66
+ // eslint-disable-next-line no-console
67
+ console.warn("[Snap] validation issues:", validation.issues);
68
+ }
69
+ }
70
+ }, [valid, validation, onValidationError]);
71
+
72
+ if (!valid) {
73
+ if (validationErrorFallback === null) return null;
74
+ return (
75
+ <>{validationErrorFallback ?? <SnapValidationFallback message={validationMessage} />}</>
76
+ );
77
+ }
78
+
79
+ return (
80
+ <SnapViewCoreInner snap={snap} handlers={handlers} loading={loading} />
81
+ );
82
+ }
83
+
84
+ export function SnapViewV2({
85
+ snap,
86
+ handlers,
87
+ loading = false,
88
+ appearance = "dark",
89
+ colors,
90
+ onValidationError,
91
+ validationErrorFallback,
92
+ }: {
93
+ snap: SnapPage;
94
+ handlers: SnapActionHandlers;
95
+ loading?: boolean;
96
+ appearance?: "light" | "dark";
97
+ colors?: Partial<SnapNativeColors>;
98
+ onValidationError?: (result: ValidationResult) => void;
99
+ validationErrorFallback?: ReactNode;
100
+ }) {
101
+ return (
102
+ <SnapThemeProvider appearance={appearance} colors={colors}>
103
+ <SnapViewV2Inner
104
+ snap={snap}
105
+ handlers={handlers}
106
+ loading={loading}
107
+ onValidationError={onValidationError}
108
+ validationErrorFallback={validationErrorFallback}
109
+ />
110
+ </SnapThemeProvider>
111
+ );
112
+ }
113
+
114
+ // ─── SnapCardV2 (card frame + height limits) ─────────
115
+
116
+ function SnapCardV2Inner({
117
+ snap,
118
+ handlers,
119
+ loading,
120
+ borderRadius,
121
+ showOverflowWarning,
122
+ onValidationError,
123
+ validationErrorFallback,
124
+ }: {
125
+ snap: SnapPage;
126
+ handlers: SnapActionHandlers;
127
+ loading?: boolean;
128
+ borderRadius: number;
129
+ showOverflowWarning: boolean;
130
+ onValidationError?: (result: ValidationResult) => void;
131
+ validationErrorFallback?: ReactNode;
132
+ }) {
133
+ const { colors } = useSnapTheme();
134
+ const maxHeight = showOverflowWarning ? SNAP_WARNING_HEIGHT : SNAP_MAX_HEIGHT;
135
+
136
+ return (
137
+ <View style={cardStyles.frameRing}>
138
+ <View
139
+ style={[
140
+ cardStyles.card,
141
+ {
142
+ borderRadius,
143
+ maxHeight,
144
+ borderColor: colors.border,
145
+ backgroundColor: colors.surface,
146
+ },
147
+ ]}
148
+ >
149
+ <View style={cardStyles.body}>
150
+ <SnapViewV2Inner
151
+ snap={snap}
152
+ handlers={handlers}
153
+ loading={loading}
154
+ onValidationError={onValidationError}
155
+ validationErrorFallback={validationErrorFallback}
156
+ />
157
+ </View>
158
+ {showOverflowWarning && (
159
+ <View style={cardStyles.warningOverlay}>
160
+ <View style={cardStyles.warningLine} />
161
+ <View style={cardStyles.warningLabel}>
162
+ <Text style={cardStyles.warningLabelText}>{SNAP_MAX_HEIGHT}px</Text>
163
+ </View>
164
+ </View>
165
+ )}
166
+ </View>
167
+ </View>
168
+ );
169
+ }
170
+
171
+ export function SnapCardV2({
172
+ snap,
173
+ handlers,
174
+ loading = false,
175
+ appearance = "dark",
176
+ colors,
177
+ borderRadius = 16,
178
+ showOverflowWarning = false,
179
+ onValidationError,
180
+ validationErrorFallback,
181
+ }: {
182
+ snap: SnapPage;
183
+ handlers: SnapActionHandlers;
184
+ loading?: boolean;
185
+ appearance?: "light" | "dark";
186
+ colors?: Partial<SnapNativeColors>;
187
+ borderRadius?: number;
188
+ showOverflowWarning?: boolean;
189
+ onValidationError?: (result: ValidationResult) => void;
190
+ validationErrorFallback?: ReactNode;
191
+ }) {
192
+ return (
193
+ <SnapThemeProvider appearance={appearance} colors={colors}>
194
+ <SnapCardV2Inner
195
+ snap={snap}
196
+ handlers={handlers}
197
+ loading={loading}
198
+ borderRadius={borderRadius}
199
+ showOverflowWarning={showOverflowWarning}
200
+ onValidationError={onValidationError}
201
+ validationErrorFallback={validationErrorFallback}
202
+ />
203
+ </SnapThemeProvider>
204
+ );
205
+ }
206
+
207
+ const cardStyles = StyleSheet.create({
208
+ frameRing: { alignSelf: "stretch" },
209
+ card: { overflow: "hidden", borderWidth: 1, minHeight: 120 },
210
+ body: { paddingHorizontal: 16, paddingVertical: 16 },
211
+ warningOverlay: {
212
+ position: "absolute",
213
+ top: SNAP_MAX_HEIGHT,
214
+ left: 0,
215
+ right: 0,
216
+ bottom: 0,
217
+ zIndex: 10,
218
+ },
219
+ warningLine: {
220
+ height: 1,
221
+ borderTopWidth: 1,
222
+ borderStyle: "dashed",
223
+ borderColor: "rgba(255,100,100,0.6)",
224
+ },
225
+ warningLabel: {
226
+ position: "absolute",
227
+ top: -10,
228
+ right: 4,
229
+ backgroundColor: "rgba(0,0,0,0.7)",
230
+ paddingHorizontal: 4,
231
+ paddingVertical: 1,
232
+ borderRadius: 3,
233
+ },
234
+ warningLabelText: {
235
+ fontSize: 10,
236
+ color: "rgba(255,100,100,0.7)",
237
+ fontFamily: Platform.select({ ios: "Menlo", default: "monospace" }),
238
+ },
239
+ });
package/src/schemas.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import type { Spec } from "@json-render/core";
3
- import { EFFECT_VALUES, SPEC_VERSION } from "./constants";
3
+ import { EFFECT_VALUES, SUPPORTED_SPEC_VERSIONS, type SpecVersion } from "./constants";
4
4
  import { DEFAULT_THEME_ACCENT, PALETTE_COLOR_VALUES } from "./colors";
5
5
 
6
6
  // ─── Theme ─────────────────────────────────────────────
@@ -21,7 +21,7 @@ const themeSchema = z
21
21
 
22
22
  export const snapResponseSchema = z
23
23
  .object({
24
- version: z.literal(SPEC_VERSION),
24
+ version: z.enum(SUPPORTED_SPEC_VERSIONS),
25
25
  theme: themeSchema.optional().default({ accent: DEFAULT_THEME_ACCENT }),
26
26
  effects: z.array(z.enum(EFFECT_VALUES)).optional(),
27
27
  ui: z.custom<Spec>(
@@ -66,7 +66,7 @@ export type SnapSpecInput = {
66
66
  * without type casts. Runtime validation via the Zod schema still catches invalid shapes.
67
67
  */
68
68
  export type SnapHandlerResult = {
69
- version: typeof SPEC_VERSION;
69
+ version: SpecVersion;
70
70
  theme?: { accent?: z.input<typeof themeAccentSchema> };
71
71
  effects?: z.input<typeof snapResponseSchema>["effects"];
72
72
  ui: SnapSpecInput;
@@ -85,10 +85,11 @@ export const payloadSchema = z
85
85
  .object({
86
86
  fid: z.number().int().nonnegative(),
87
87
  inputs: z.record(z.string(), postInputValueSchema).default({}),
88
- button_index: z.number().int().nonnegative(),
89
88
  timestamp: z.number().int(),
89
+ nonce: z.string().optional(),
90
+ audience: z.string().optional(),
90
91
  })
91
- .strict();
92
+ .strip();
92
93
 
93
94
  export type SnapPayload = z.infer<typeof payloadSchema>;
94
95
 
@@ -101,11 +102,9 @@ const snapGetActionSchema = z.object({
101
102
 
102
103
  export type SnapGetAction = z.infer<typeof snapGetActionSchema>;
103
104
 
104
- const snapPostActionSchema = payloadSchema
105
- .extend({
106
- type: z.literal(ACTION_TYPE_POST),
107
- })
108
- .strict();
105
+ const snapPostActionSchema = payloadSchema.extend({
106
+ type: z.literal(ACTION_TYPE_POST),
107
+ });
109
108
 
110
109
  export type SnapPostAction = z.infer<typeof snapPostActionSchema>;
111
110
 
@@ -29,6 +29,10 @@ export type ParseRequestError =
29
29
  | {
30
30
  type: "signature";
31
31
  message: string;
32
+ }
33
+ | {
34
+ type: "origin_mismatch";
35
+ message: string;
32
36
  };
33
37
 
34
38
  export type ParseRequestOptions = {
@@ -43,6 +47,12 @@ export type ParseRequestOptions = {
43
47
  * potential replays. Defaults to 300 (5 minutes) when not provided.
44
48
  */
45
49
  maxSkewSeconds?: number;
50
+
51
+ /**
52
+ * The origin of the request. Derived from the request when not provided.
53
+ */
54
+ requestOrigin?: string;
55
+
46
56
  };
47
57
 
48
58
  export type ParseRequestResult =
@@ -137,6 +147,30 @@ export async function parseRequest(
137
147
  },
138
148
  };
139
149
  }
150
+
151
+ // Audience validation: only enforce when the client sends an audience field.
152
+ // v1 clients may not include nonce/audience yet.
153
+ if (body.audience !== undefined) {
154
+ let expectedOrigin = options.requestOrigin;
155
+ if (expectedOrigin === undefined) {
156
+ try {
157
+ expectedOrigin = new URL(request.url).origin;
158
+ } catch {
159
+ // do nothing
160
+ }
161
+ }
162
+
163
+ if (expectedOrigin !== undefined && body.audience !== expectedOrigin) {
164
+ return {
165
+ success: false,
166
+ error: {
167
+ type: "origin_mismatch",
168
+ message: `payload audience "${body.audience}" does not match expected origin "${expectedOrigin}"`,
169
+ },
170
+ };
171
+ }
172
+ }
173
+
140
174
  return {
141
175
  success: true,
142
176
  action: {
package/src/ui/schema.ts CHANGED
@@ -30,7 +30,7 @@ export const snapJsonRenderSchema = defineSchema(
30
30
  {
31
31
  defaultRules: [
32
32
  "You are generating auxiliary UI for a Farcaster Snap. Prefer components matching snap element types (Item, Badge, ButtonGroup, Input, Switch, ToggleGroup, Slider, Progress, Image, Separator).",
33
- "Snap pages use a Stack root with at most 6 body children and 1 media element (Image); keep generated trees small.",
33
+ "Snap structural limits: max 64 elements, max 7 children on root, max 6 children per non-root container, max 4 levels of nesting. Keep generated trees small.",
34
34
  "Bottom-of-card snap buttons are Button components; use actions post / link / mini_app / sdk per SPEC.md.",
35
35
  ],
36
36
  },
package/src/validator.ts CHANGED
@@ -1,15 +1,225 @@
1
1
  import { z } from "zod";
2
2
  import { snapResponseSchema } from "./schemas";
3
+ import { MAX_CHILDREN, MAX_DEPTH, MAX_ELEMENTS, MAX_ROOT_CHILDREN, SPEC_VERSION_1 } from "./constants";
3
4
 
4
5
  export type ValidationResult = {
5
6
  valid: boolean;
6
7
  issues: z.core.$ZodIssue[];
7
8
  };
8
9
 
10
+ // ─── Helpers ──────────────────────────────────────────
11
+
12
+ /** Actions whose `params.target` must be a valid URL. */
13
+ const URL_TARGET_ACTIONS = new Set(["submit", "open_url", "open_mini_app"]);
14
+
15
+ /** Image file extensions allowed in image URLs. */
16
+ const ALLOWED_IMAGE_EXTENSIONS = new Set(["jpg", "jpeg", "png", "gif", "webp"]);
17
+
18
+ /**
19
+ * Returns true if the URL is a loopback address (localhost dev exception).
20
+ */
21
+ function isLoopback(url: URL): boolean {
22
+ const host = url.hostname;
23
+ return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]";
24
+ }
25
+
26
+ /**
27
+ * Validate a URL string: must be HTTPS (or HTTP on loopback for dev).
28
+ * Returns an error message or null if valid.
29
+ */
30
+ function validateUrl(raw: string): string | null {
31
+ let url: URL;
32
+ try {
33
+ url = new URL(raw);
34
+ } catch {
35
+ return `Invalid URL: "${raw}"`;
36
+ }
37
+
38
+ if (url.protocol === "https:") return null;
39
+ if (url.protocol === "http:" && isLoopback(url)) return null;
40
+ if (url.protocol === "javascript:") return `javascript: URIs are not allowed`;
41
+
42
+ return `URL must use HTTPS (got ${url.protocol.replace(":", "")}): "${raw}"`;
43
+ }
44
+
45
+ /**
46
+ * Validate an image URL: must pass URL validation + have an allowed extension.
47
+ */
48
+ function validateImageUrl(raw: string): string | null {
49
+ const urlError = validateUrl(raw);
50
+ if (urlError) return urlError;
51
+
52
+ let url: URL;
53
+ try {
54
+ url = new URL(raw);
55
+ } catch {
56
+ return null; // already caught above
57
+ }
58
+
59
+ const pathname = url.pathname;
60
+ const lastDot = pathname.lastIndexOf(".");
61
+ if (lastDot === -1) {
62
+ return `Image URL must end with a supported extension (${[...ALLOWED_IMAGE_EXTENSIONS].join(", ")}): "${raw}"`;
63
+ }
64
+
65
+ const ext = pathname.slice(lastDot + 1).toLowerCase();
66
+ if (!ALLOWED_IMAGE_EXTENSIONS.has(ext)) {
67
+ return `Image URL has unsupported extension ".${ext}" (allowed: ${[...ALLOWED_IMAGE_EXTENSIONS].join(", ")}): "${raw}"`;
68
+ }
69
+
70
+ return null;
71
+ }
72
+
73
+ // ─── Depth measurement ────────────────────────────────
74
+
75
+ /**
76
+ * Walk the element tree from `root` and return the max depth reached.
77
+ * Avoids infinite loops by tracking visited element ids.
78
+ */
79
+ function measureDepth(
80
+ elements: Record<string, { children?: string[] }>,
81
+ id: string,
82
+ visited: Set<string> = new Set(),
83
+ ): number {
84
+ if (visited.has(id)) return 0;
85
+ visited.add(id);
86
+
87
+ const el = elements[id];
88
+ if (!el?.children?.length) return 1;
89
+
90
+ let max = 0;
91
+ for (const childId of el.children) {
92
+ max = Math.max(max, measureDepth(elements, childId, visited));
93
+ }
94
+ return 1 + max;
95
+ }
96
+
97
+ // ─── Element types for traversal ──────────────────────
98
+
99
+ type ElementShape = {
100
+ type?: string;
101
+ children?: string[];
102
+ props?: Record<string, unknown>;
103
+ on?: Record<string, { action?: string; params?: Record<string, unknown> }>;
104
+ };
105
+
106
+ // ─── Structural validation ────────────────────────────
107
+
108
+ /**
109
+ * Validate structural constraints on the snap UI tree:
110
+ * - root must reference an existing element
111
+ * - Total element count ≤ MAX_ELEMENTS
112
+ * - Children per element ≤ MAX_CHILDREN
113
+ * - Nesting depth ≤ MAX_DEPTH
114
+ */
115
+ function validateStructure(
116
+ ui: { root: string; elements: Record<string, unknown> },
117
+ ): z.core.$ZodIssue[] {
118
+ const issues: z.core.$ZodIssue[] = [];
119
+ const elements = ui.elements as Record<string, ElementShape>;
120
+
121
+ const elementCount = Object.keys(elements).length;
122
+ if (elementCount > MAX_ELEMENTS) {
123
+ issues.push({
124
+ code: "custom",
125
+ message: `Snap exceeds maximum of ${MAX_ELEMENTS} elements (found ${elementCount})`,
126
+ path: ["ui", "elements"],
127
+ });
128
+ }
129
+
130
+ // Root element has a stricter children limit
131
+ const rootEl = elements[ui.root];
132
+ if (rootEl?.children && rootEl.children.length > MAX_ROOT_CHILDREN) {
133
+ issues.push({
134
+ code: "custom",
135
+ message: `Root element "${ui.root}" exceeds maximum of ${MAX_ROOT_CHILDREN} children (found ${rootEl.children.length})`,
136
+ path: ["ui", "elements", ui.root, "children"],
137
+ });
138
+ }
139
+
140
+ for (const [id, el] of Object.entries(elements)) {
141
+ if (id === ui.root) continue; // already checked above
142
+ if (el.children && el.children.length > MAX_CHILDREN) {
143
+ issues.push({
144
+ code: "custom",
145
+ message: `Element "${id}" exceeds maximum of ${MAX_CHILDREN} children (found ${el.children.length})`,
146
+ path: ["ui", "elements", id, "children"],
147
+ });
148
+ }
149
+ }
150
+
151
+ const depth = measureDepth(
152
+ elements as Record<string, { children?: string[] }>,
153
+ ui.root,
154
+ );
155
+ if (depth > MAX_DEPTH) {
156
+ issues.push({
157
+ code: "custom",
158
+ message: `Snap exceeds maximum nesting depth of ${MAX_DEPTH} (found ${depth})`,
159
+ path: ["ui", "root"],
160
+ });
161
+ }
162
+
163
+ return issues;
164
+ }
165
+
166
+ // ─── URL validation ───────────────────────────────────
167
+
9
168
  /**
10
- * Validates a snap response against the schema.
169
+ * Validate all URLs in the snap:
170
+ * - image.url: must be HTTPS with allowed extension
171
+ * - action target URLs (submit, open_url, open_mini_app): must be HTTPS
172
+ */
173
+ function validateUrls(
174
+ elements: Record<string, unknown>,
175
+ ): z.core.$ZodIssue[] {
176
+ const issues: z.core.$ZodIssue[] = [];
177
+ const els = elements as Record<string, ElementShape>;
178
+
179
+ for (const [id, el] of Object.entries(els)) {
180
+ // Validate image URLs
181
+ if (el.type === "image" && typeof el.props?.url === "string") {
182
+ const error = validateImageUrl(el.props.url);
183
+ if (error) {
184
+ issues.push({
185
+ code: "custom",
186
+ message: error,
187
+ path: ["ui", "elements", id, "props", "url"],
188
+ });
189
+ }
190
+ }
191
+
192
+ // Validate action target URLs
193
+ if (el.on) {
194
+ for (const [event, binding] of Object.entries(el.on)) {
195
+ if (
196
+ binding &&
197
+ URL_TARGET_ACTIONS.has(binding.action ?? "") &&
198
+ typeof binding.params?.target === "string"
199
+ ) {
200
+ const error = validateUrl(binding.params.target);
201
+ if (error) {
202
+ issues.push({
203
+ code: "custom",
204
+ message: error,
205
+ path: ["ui", "elements", id, "on", event, "params", "target"],
206
+ });
207
+ }
208
+ }
209
+ }
210
+ }
211
+ }
212
+
213
+ return issues;
214
+ }
215
+
216
+ // ─── Public API ───────────────────────────────────────
217
+
218
+ /**
219
+ * Validates a snap response against the schema, structural constraints, and URL rules.
11
220
  * Element-level prop validation is handled by the json-render catalog.
12
- * This validates the snap envelope (version, theme, effects, spec shape).
221
+ * This validates the snap envelope (version, theme, effects, spec shape)
222
+ * and enforces structural limits (element count, children, depth) and URL validation.
13
223
  */
14
224
  export function validateSnapResponse(json: unknown): ValidationResult {
15
225
  const parsed = snapResponseSchema.safeParse(json);
@@ -19,5 +229,33 @@ export function validateSnapResponse(json: unknown): ValidationResult {
19
229
  issues: parsed.error.issues,
20
230
  };
21
231
  }
232
+
233
+ const ui = parsed.data.ui;
234
+
235
+ // Root reference check applies to all versions
236
+ if (!(ui.root in ui.elements)) {
237
+ return {
238
+ valid: false,
239
+ issues: [{
240
+ code: "custom",
241
+ message: `ui.root "${ui.root}" does not exist in ui.elements`,
242
+ path: ["ui", "root"],
243
+ }],
244
+ };
245
+ }
246
+
247
+ // Structural limits and URL validation only apply to v2+ snaps
248
+ if (parsed.data.version !== SPEC_VERSION_1) {
249
+ const structuralIssues = validateStructure(ui);
250
+ if (structuralIssues.length > 0) {
251
+ return { valid: false, issues: structuralIssues };
252
+ }
253
+
254
+ const urlIssues = validateUrls(ui.elements);
255
+ if (urlIssues.length > 0) {
256
+ return { valid: false, issues: urlIssues };
257
+ }
258
+ }
259
+
22
260
  return { valid: true, issues: [] };
23
261
  }