@farcaster/snap 1.15.4 → 1.16.0

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 +27 -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 +39 -0
  50. package/src/ui/schema.ts +1 -1
  51. package/src/validator.ts +240 -2
@@ -0,0 +1,101 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useMemo } from "react";
3
+ import { Platform, StyleSheet, Text, View } from "react-native";
4
+ import { SnapThemeProvider, useSnapTheme } from "../theme.js";
5
+ import { SnapViewCoreInner } from "../snap-view-core.js";
6
+ import { validateSnapResponse, } from "@farcaster/snap";
7
+ // ─── Constants ───────────────────────────────────────
8
+ const SNAP_MAX_HEIGHT = 500;
9
+ const SNAP_WARNING_HEIGHT = 700;
10
+ // ─── Validation fallback ─────────────────────────────
11
+ function SnapValidationFallback({ message }) {
12
+ const { colors } = useSnapTheme();
13
+ return (_jsx(View, { style: fallbackStyles.container, children: _jsx(Text, { style: [fallbackStyles.text, { color: colors.textSecondary }], children: message ? `Unable to render snap: ${message}` : "Unable to render snap" }) }));
14
+ }
15
+ const fallbackStyles = StyleSheet.create({
16
+ container: {
17
+ width: "100%",
18
+ padding: 16,
19
+ alignItems: "center",
20
+ justifyContent: "center",
21
+ },
22
+ text: {
23
+ fontSize: 14,
24
+ },
25
+ });
26
+ // ─── SnapViewV2 (with validation) ────────────────────
27
+ export function SnapViewV2Inner({ snap, handlers, loading = false, onValidationError, validationErrorFallback, }) {
28
+ const validation = useMemo(() => validateSnapResponse(snap), [snap]);
29
+ const valid = validation.valid;
30
+ const validationMessage = validation.issues[0]?.message;
31
+ useEffect(() => {
32
+ if (!valid) {
33
+ if (onValidationError) {
34
+ onValidationError(validation);
35
+ }
36
+ else {
37
+ // eslint-disable-next-line no-console
38
+ console.warn("[Snap] validation issues:", validation.issues);
39
+ }
40
+ }
41
+ }, [valid, validation, onValidationError]);
42
+ if (!valid) {
43
+ if (validationErrorFallback === null)
44
+ return null;
45
+ return (_jsx(_Fragment, { children: validationErrorFallback ?? _jsx(SnapValidationFallback, { message: validationMessage }) }));
46
+ }
47
+ return (_jsx(SnapViewCoreInner, { snap: snap, handlers: handlers, loading: loading }));
48
+ }
49
+ export function SnapViewV2({ snap, handlers, loading = false, appearance = "dark", colors, onValidationError, validationErrorFallback, }) {
50
+ return (_jsx(SnapThemeProvider, { appearance: appearance, colors: colors, children: _jsx(SnapViewV2Inner, { snap: snap, handlers: handlers, loading: loading, onValidationError: onValidationError, validationErrorFallback: validationErrorFallback }) }));
51
+ }
52
+ // ─── SnapCardV2 (card frame + height limits) ─────────
53
+ function SnapCardV2Inner({ snap, handlers, loading, borderRadius, showOverflowWarning, onValidationError, validationErrorFallback, }) {
54
+ const { colors } = useSnapTheme();
55
+ const maxHeight = showOverflowWarning ? SNAP_WARNING_HEIGHT : SNAP_MAX_HEIGHT;
56
+ return (_jsx(View, { style: cardStyles.frameRing, children: _jsxs(View, { style: [
57
+ cardStyles.card,
58
+ {
59
+ borderRadius,
60
+ maxHeight,
61
+ borderColor: colors.border,
62
+ backgroundColor: colors.surface,
63
+ },
64
+ ], children: [_jsx(View, { style: cardStyles.body, children: _jsx(SnapViewV2Inner, { snap: snap, handlers: handlers, loading: loading, onValidationError: onValidationError, validationErrorFallback: validationErrorFallback }) }), showOverflowWarning && (_jsxs(View, { style: cardStyles.warningOverlay, children: [_jsx(View, { style: cardStyles.warningLine }), _jsx(View, { style: cardStyles.warningLabel, children: _jsxs(Text, { style: cardStyles.warningLabelText, children: [SNAP_MAX_HEIGHT, "px"] }) })] }))] }) }));
65
+ }
66
+ export function SnapCardV2({ snap, handlers, loading = false, appearance = "dark", colors, borderRadius = 16, showOverflowWarning = false, onValidationError, validationErrorFallback, }) {
67
+ return (_jsx(SnapThemeProvider, { appearance: appearance, colors: colors, children: _jsx(SnapCardV2Inner, { snap: snap, handlers: handlers, loading: loading, borderRadius: borderRadius, showOverflowWarning: showOverflowWarning, onValidationError: onValidationError, validationErrorFallback: validationErrorFallback }) }));
68
+ }
69
+ const cardStyles = StyleSheet.create({
70
+ frameRing: { alignSelf: "stretch" },
71
+ card: { overflow: "hidden", borderWidth: 1, minHeight: 120 },
72
+ body: { paddingHorizontal: 16, paddingVertical: 16 },
73
+ warningOverlay: {
74
+ position: "absolute",
75
+ top: SNAP_MAX_HEIGHT,
76
+ left: 0,
77
+ right: 0,
78
+ bottom: 0,
79
+ zIndex: 10,
80
+ },
81
+ warningLine: {
82
+ height: 1,
83
+ borderTopWidth: 1,
84
+ borderStyle: "dashed",
85
+ borderColor: "rgba(255,100,100,0.6)",
86
+ },
87
+ warningLabel: {
88
+ position: "absolute",
89
+ top: -10,
90
+ right: 4,
91
+ backgroundColor: "rgba(0,0,0,0.7)",
92
+ paddingHorizontal: 4,
93
+ paddingVertical: 1,
94
+ borderRadius: 3,
95
+ },
96
+ warningLabelText: {
97
+ fontSize: 10,
98
+ color: "rgba(255,100,100,0.7)",
99
+ fontFamily: Platform.select({ ios: "Menlo", default: "monospace" }),
100
+ },
101
+ });
package/dist/schemas.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import type { Spec } from "@json-render/core";
3
- import { SPEC_VERSION } from "./constants.js";
3
+ import { type SpecVersion } from "./constants.js";
4
4
  declare const themeAccentSchema: z.ZodEnum<{
5
5
  gray: "gray";
6
6
  blue: "blue";
@@ -12,7 +12,10 @@ declare const themeAccentSchema: z.ZodEnum<{
12
12
  pink: "pink";
13
13
  }>;
14
14
  export declare const snapResponseSchema: z.ZodObject<{
15
- version: z.ZodLiteral<"1.0">;
15
+ version: z.ZodEnum<{
16
+ "1.0": "1.0";
17
+ "2.0": "2.0";
18
+ }>;
16
19
  theme: z.ZodDefault<z.ZodOptional<z.ZodObject<{
17
20
  accent: z.ZodDefault<z.ZodEnum<{
18
21
  gray: "gray";
@@ -58,7 +61,7 @@ export type SnapSpecInput = {
58
61
  * without type casts. Runtime validation via the Zod schema still catches invalid shapes.
59
62
  */
60
63
  export type SnapHandlerResult = {
61
- version: typeof SPEC_VERSION;
64
+ version: SpecVersion;
62
65
  theme?: {
63
66
  accent?: z.input<typeof themeAccentSchema>;
64
67
  };
@@ -68,9 +71,10 @@ export type SnapHandlerResult = {
68
71
  export declare const payloadSchema: z.ZodObject<{
69
72
  fid: z.ZodNumber;
70
73
  inputs: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean, z.ZodArray<z.ZodString>]>>>;
71
- button_index: z.ZodNumber;
72
74
  timestamp: z.ZodNumber;
73
- }, z.core.$strict>;
75
+ nonce: z.ZodString;
76
+ audience: z.ZodString;
77
+ }, z.core.$strip>;
74
78
  export type SnapPayload = z.infer<typeof payloadSchema>;
75
79
  export declare const ACTION_TYPE_GET: "get";
76
80
  export declare const ACTION_TYPE_POST: "post";
@@ -81,20 +85,22 @@ export type SnapGetAction = z.infer<typeof snapGetActionSchema>;
81
85
  declare const snapPostActionSchema: z.ZodObject<{
82
86
  fid: z.ZodNumber;
83
87
  inputs: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean, z.ZodArray<z.ZodString>]>>>;
84
- button_index: z.ZodNumber;
85
88
  timestamp: z.ZodNumber;
89
+ nonce: z.ZodString;
90
+ audience: z.ZodString;
86
91
  type: z.ZodLiteral<"post">;
87
- }, z.core.$strict>;
92
+ }, z.core.$strip>;
88
93
  export type SnapPostAction = z.infer<typeof snapPostActionSchema>;
89
94
  export declare const snapActionSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
90
95
  type: z.ZodLiteral<"get">;
91
96
  }, z.core.$strip>, z.ZodObject<{
92
97
  fid: z.ZodNumber;
93
98
  inputs: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean, z.ZodArray<z.ZodString>]>>>;
94
- button_index: z.ZodNumber;
95
99
  timestamp: z.ZodNumber;
100
+ nonce: z.ZodString;
101
+ audience: z.ZodString;
96
102
  type: z.ZodLiteral<"post">;
97
- }, z.core.$strict>], "type">;
103
+ }, z.core.$strip>], "type">;
98
104
  export type SnapAction = z.infer<typeof snapActionSchema>;
99
105
  export type SnapContext = {
100
106
  action: SnapAction;
package/dist/schemas.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { EFFECT_VALUES, SPEC_VERSION } from "./constants.js";
2
+ import { EFFECT_VALUES, SUPPORTED_SPEC_VERSIONS } from "./constants.js";
3
3
  import { DEFAULT_THEME_ACCENT, PALETTE_COLOR_VALUES } from "./colors.js";
4
4
  // ─── Theme ─────────────────────────────────────────────
5
5
  const themeAccentSchema = z.enum(PALETTE_COLOR_VALUES, {
@@ -15,7 +15,7 @@ const themeSchema = z
15
15
  // typed here via the json-render Spec type.
16
16
  export const snapResponseSchema = z
17
17
  .object({
18
- version: z.literal(SPEC_VERSION),
18
+ version: z.enum(SUPPORTED_SPEC_VERSIONS),
19
19
  theme: themeSchema.optional().default({ accent: DEFAULT_THEME_ACCENT }),
20
20
  effects: z.array(z.enum(EFFECT_VALUES)).optional(),
21
21
  ui: z.custom((val) => val != null &&
@@ -35,20 +35,19 @@ export const payloadSchema = z
35
35
  .object({
36
36
  fid: z.number().int().nonnegative(),
37
37
  inputs: z.record(z.string(), postInputValueSchema).default({}),
38
- button_index: z.number().int().nonnegative(),
39
38
  timestamp: z.number().int(),
39
+ nonce: z.string(),
40
+ audience: z.string(),
40
41
  })
41
- .strict();
42
+ .strip();
42
43
  export const ACTION_TYPE_GET = "get";
43
44
  export const ACTION_TYPE_POST = "post";
44
45
  const snapGetActionSchema = z.object({
45
46
  type: z.literal(ACTION_TYPE_GET),
46
47
  });
47
- const snapPostActionSchema = payloadSchema
48
- .extend({
48
+ const snapPostActionSchema = payloadSchema.extend({
49
49
  type: z.literal(ACTION_TYPE_POST),
50
- })
51
- .strict();
50
+ });
52
51
  export const snapActionSchema = z.discriminatedUnion("type", [
53
52
  snapGetActionSchema,
54
53
  snapPostActionSchema,
@@ -15,6 +15,9 @@ export type ParseRequestError = {
15
15
  } | {
16
16
  type: "signature";
17
17
  message: string;
18
+ } | {
19
+ type: "origin_mismatch";
20
+ message: string;
18
21
  };
19
22
  export type ParseRequestOptions = {
20
23
  /**
@@ -27,6 +30,10 @@ export type ParseRequestOptions = {
27
30
  * potential replays. Defaults to 300 (5 minutes) when not provided.
28
31
  */
29
32
  maxSkewSeconds?: number;
33
+ /**
34
+ * The origin of the request. Derived from the request when not provided.
35
+ */
36
+ requestOrigin?: string;
30
37
  };
31
38
  export type ParseRequestResult = {
32
39
  success: true;
@@ -77,6 +77,33 @@ export async function parseRequest(request, options = {}) {
77
77
  },
78
78
  };
79
79
  }
80
+ let expectedOrigin = options.requestOrigin;
81
+ if (expectedOrigin === undefined) {
82
+ try {
83
+ expectedOrigin = new URL(request.url).origin;
84
+ }
85
+ catch {
86
+ // do nothing
87
+ }
88
+ }
89
+ if (expectedOrigin === undefined) {
90
+ return {
91
+ success: false,
92
+ error: {
93
+ type: "origin_mismatch",
94
+ message: "request origin is required for validation",
95
+ },
96
+ };
97
+ }
98
+ if (body.audience !== expectedOrigin) {
99
+ return {
100
+ success: false,
101
+ error: {
102
+ type: "origin_mismatch",
103
+ message: `payload audience "${body.audience}" does not match expected origin "${expectedOrigin}"`,
104
+ },
105
+ };
106
+ }
80
107
  return {
81
108
  success: true,
82
109
  action: {
package/dist/ui/schema.js CHANGED
@@ -25,7 +25,7 @@ export const snapJsonRenderSchema = defineSchema((s) => ({
25
25
  }), {
26
26
  defaultRules: [
27
27
  "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).",
28
- "Snap pages use a Stack root with at most 6 body children and 1 media element (Image); keep generated trees small.",
28
+ "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.",
29
29
  "Bottom-of-card snap buttons are Button components; use actions post / link / mini_app / sdk per SPEC.md.",
30
30
  ],
31
31
  });
@@ -4,8 +4,9 @@ export type ValidationResult = {
4
4
  issues: z.core.$ZodIssue[];
5
5
  };
6
6
  /**
7
- * Validates a snap response against the schema.
7
+ * Validates a snap response against the schema, structural constraints, and URL rules.
8
8
  * Element-level prop validation is handled by the json-render catalog.
9
- * This validates the snap envelope (version, theme, effects, spec shape).
9
+ * This validates the snap envelope (version, theme, effects, spec shape)
10
+ * and enforces structural limits (element count, children, depth) and URL validation.
10
11
  */
11
12
  export declare function validateSnapResponse(json: unknown): ValidationResult;
package/dist/validator.js CHANGED
@@ -1,8 +1,176 @@
1
1
  import { snapResponseSchema } from "./schemas.js";
2
+ import { MAX_CHILDREN, MAX_DEPTH, MAX_ELEMENTS, MAX_ROOT_CHILDREN, SPEC_VERSION_1 } from "./constants.js";
3
+ // ─── Helpers ──────────────────────────────────────────
4
+ /** Actions whose `params.target` must be a valid URL. */
5
+ const URL_TARGET_ACTIONS = new Set(["submit", "open_url", "open_mini_app"]);
6
+ /** Image file extensions allowed in image URLs. */
7
+ const ALLOWED_IMAGE_EXTENSIONS = new Set(["jpg", "jpeg", "png", "gif", "webp"]);
2
8
  /**
3
- * Validates a snap response against the schema.
9
+ * Returns true if the URL is a loopback address (localhost dev exception).
10
+ */
11
+ function isLoopback(url) {
12
+ const host = url.hostname;
13
+ return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]";
14
+ }
15
+ /**
16
+ * Validate a URL string: must be HTTPS (or HTTP on loopback for dev).
17
+ * Returns an error message or null if valid.
18
+ */
19
+ function validateUrl(raw) {
20
+ let url;
21
+ try {
22
+ url = new URL(raw);
23
+ }
24
+ catch {
25
+ return `Invalid URL: "${raw}"`;
26
+ }
27
+ if (url.protocol === "https:")
28
+ return null;
29
+ if (url.protocol === "http:" && isLoopback(url))
30
+ return null;
31
+ if (url.protocol === "javascript:")
32
+ return `javascript: URIs are not allowed`;
33
+ return `URL must use HTTPS (got ${url.protocol.replace(":", "")}): "${raw}"`;
34
+ }
35
+ /**
36
+ * Validate an image URL: must pass URL validation + have an allowed extension.
37
+ */
38
+ function validateImageUrl(raw) {
39
+ const urlError = validateUrl(raw);
40
+ if (urlError)
41
+ return urlError;
42
+ let url;
43
+ try {
44
+ url = new URL(raw);
45
+ }
46
+ catch {
47
+ return null; // already caught above
48
+ }
49
+ const pathname = url.pathname;
50
+ const lastDot = pathname.lastIndexOf(".");
51
+ if (lastDot === -1) {
52
+ return `Image URL must end with a supported extension (${[...ALLOWED_IMAGE_EXTENSIONS].join(", ")}): "${raw}"`;
53
+ }
54
+ const ext = pathname.slice(lastDot + 1).toLowerCase();
55
+ if (!ALLOWED_IMAGE_EXTENSIONS.has(ext)) {
56
+ return `Image URL has unsupported extension ".${ext}" (allowed: ${[...ALLOWED_IMAGE_EXTENSIONS].join(", ")}): "${raw}"`;
57
+ }
58
+ return null;
59
+ }
60
+ // ─── Depth measurement ────────────────────────────────
61
+ /**
62
+ * Walk the element tree from `root` and return the max depth reached.
63
+ * Avoids infinite loops by tracking visited element ids.
64
+ */
65
+ function measureDepth(elements, id, visited = new Set()) {
66
+ if (visited.has(id))
67
+ return 0;
68
+ visited.add(id);
69
+ const el = elements[id];
70
+ if (!el?.children?.length)
71
+ return 1;
72
+ let max = 0;
73
+ for (const childId of el.children) {
74
+ max = Math.max(max, measureDepth(elements, childId, visited));
75
+ }
76
+ return 1 + max;
77
+ }
78
+ // ─── Structural validation ────────────────────────────
79
+ /**
80
+ * Validate structural constraints on the snap UI tree:
81
+ * - root must reference an existing element
82
+ * - Total element count ≤ MAX_ELEMENTS
83
+ * - Children per element ≤ MAX_CHILDREN
84
+ * - Nesting depth ≤ MAX_DEPTH
85
+ */
86
+ function validateStructure(ui) {
87
+ const issues = [];
88
+ const elements = ui.elements;
89
+ const elementCount = Object.keys(elements).length;
90
+ if (elementCount > MAX_ELEMENTS) {
91
+ issues.push({
92
+ code: "custom",
93
+ message: `Snap exceeds maximum of ${MAX_ELEMENTS} elements (found ${elementCount})`,
94
+ path: ["ui", "elements"],
95
+ });
96
+ }
97
+ // Root element has a stricter children limit
98
+ const rootEl = elements[ui.root];
99
+ if (rootEl?.children && rootEl.children.length > MAX_ROOT_CHILDREN) {
100
+ issues.push({
101
+ code: "custom",
102
+ message: `Root element "${ui.root}" exceeds maximum of ${MAX_ROOT_CHILDREN} children (found ${rootEl.children.length})`,
103
+ path: ["ui", "elements", ui.root, "children"],
104
+ });
105
+ }
106
+ for (const [id, el] of Object.entries(elements)) {
107
+ if (id === ui.root)
108
+ continue; // already checked above
109
+ if (el.children && el.children.length > MAX_CHILDREN) {
110
+ issues.push({
111
+ code: "custom",
112
+ message: `Element "${id}" exceeds maximum of ${MAX_CHILDREN} children (found ${el.children.length})`,
113
+ path: ["ui", "elements", id, "children"],
114
+ });
115
+ }
116
+ }
117
+ const depth = measureDepth(elements, ui.root);
118
+ if (depth > MAX_DEPTH) {
119
+ issues.push({
120
+ code: "custom",
121
+ message: `Snap exceeds maximum nesting depth of ${MAX_DEPTH} (found ${depth})`,
122
+ path: ["ui", "root"],
123
+ });
124
+ }
125
+ return issues;
126
+ }
127
+ // ─── URL validation ───────────────────────────────────
128
+ /**
129
+ * Validate all URLs in the snap:
130
+ * - image.url: must be HTTPS with allowed extension
131
+ * - action target URLs (submit, open_url, open_mini_app): must be HTTPS
132
+ */
133
+ function validateUrls(elements) {
134
+ const issues = [];
135
+ const els = elements;
136
+ for (const [id, el] of Object.entries(els)) {
137
+ // Validate image URLs
138
+ if (el.type === "image" && typeof el.props?.url === "string") {
139
+ const error = validateImageUrl(el.props.url);
140
+ if (error) {
141
+ issues.push({
142
+ code: "custom",
143
+ message: error,
144
+ path: ["ui", "elements", id, "props", "url"],
145
+ });
146
+ }
147
+ }
148
+ // Validate action target URLs
149
+ if (el.on) {
150
+ for (const [event, binding] of Object.entries(el.on)) {
151
+ if (binding &&
152
+ URL_TARGET_ACTIONS.has(binding.action ?? "") &&
153
+ typeof binding.params?.target === "string") {
154
+ const error = validateUrl(binding.params.target);
155
+ if (error) {
156
+ issues.push({
157
+ code: "custom",
158
+ message: error,
159
+ path: ["ui", "elements", id, "on", event, "params", "target"],
160
+ });
161
+ }
162
+ }
163
+ }
164
+ }
165
+ }
166
+ return issues;
167
+ }
168
+ // ─── Public API ───────────────────────────────────────
169
+ /**
170
+ * Validates a snap response against the schema, structural constraints, and URL rules.
4
171
  * Element-level prop validation is handled by the json-render catalog.
5
- * This validates the snap envelope (version, theme, effects, spec shape).
172
+ * This validates the snap envelope (version, theme, effects, spec shape)
173
+ * and enforces structural limits (element count, children, depth) and URL validation.
6
174
  */
7
175
  export function validateSnapResponse(json) {
8
176
  const parsed = snapResponseSchema.safeParse(json);
@@ -12,5 +180,28 @@ export function validateSnapResponse(json) {
12
180
  issues: parsed.error.issues,
13
181
  };
14
182
  }
183
+ const ui = parsed.data.ui;
184
+ // Root reference check applies to all versions
185
+ if (!(ui.root in ui.elements)) {
186
+ return {
187
+ valid: false,
188
+ issues: [{
189
+ code: "custom",
190
+ message: `ui.root "${ui.root}" does not exist in ui.elements`,
191
+ path: ["ui", "root"],
192
+ }],
193
+ };
194
+ }
195
+ // Structural limits and URL validation only apply to v2+ snaps
196
+ if (parsed.data.version !== SPEC_VERSION_1) {
197
+ const structuralIssues = validateStructure(ui);
198
+ if (structuralIssues.length > 0) {
199
+ return { valid: false, issues: structuralIssues };
200
+ }
201
+ const urlIssues = validateUrls(ui.elements);
202
+ if (urlIssues.length > 0) {
203
+ return { valid: false, issues: urlIssues };
204
+ }
205
+ }
15
206
  return { valid: true, issues: [] };
16
207
  }
package/llms.txt CHANGED
@@ -30,6 +30,15 @@ Top-level fields: `version` (required, `"1.0"`), `theme` (optional, `{ accent: P
30
30
 
31
31
  `ui.root` is the ID of the root element. `ui.elements` is a flat map of element ID to element definition.
32
32
 
33
+ ## Structural Constraints
34
+
35
+ | Constraint | Limit |
36
+ |------------|-------|
37
+ | Total elements | Max **64** in `ui.elements` |
38
+ | Root children | Max **7** children on the root element |
39
+ | Children per element | Max **6** per non-root container (`stack`, `item_group`) |
40
+ | Nesting depth | Max **4** levels from root to deepest leaf |
41
+
33
42
  ## Components (16 total)
34
43
 
35
44
  ### Display Components
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@farcaster/snap",
3
- "version": "1.15.4",
3
+ "version": "1.16.0",
4
4
  "description": "Farcaster Snaps 🫰",
5
5
  "repository": {
6
6
  "type": "git",
package/src/constants.ts CHANGED
@@ -1,4 +1,8 @@
1
- export const SPEC_VERSION = "1.0" as const;
1
+ export const SPEC_VERSION_1 = "1.0" as const;
2
+ export const SPEC_VERSION_2 = "2.0" as const;
3
+ export const SPEC_VERSION = SPEC_VERSION_1;
4
+ export const SUPPORTED_SPEC_VERSIONS = [SPEC_VERSION_1, SPEC_VERSION_2] as const;
5
+ export type SpecVersion = (typeof SUPPORTED_SPEC_VERSIONS)[number];
2
6
 
3
7
  export const MEDIA_TYPE = "application/vnd.farcaster.snap+json" as const;
4
8
 
@@ -12,6 +16,12 @@ export const GRID_MIN_ROWS = 2;
12
16
  export const GRID_MAX_ROWS = 16;
13
17
  export const GRID_GAP_VALUES = ["none", "sm", "md", "lg"] as const;
14
18
 
19
+ // ─── Snap structural limits ───────────────────────────
20
+ export const MAX_ELEMENTS = 64;
21
+ export const MAX_ROOT_CHILDREN = 7;
22
+ export const MAX_CHILDREN = 6;
23
+ export const MAX_DEPTH = 4;
24
+
15
25
  // ─── Bar chart ─────────────────────────────────────────
16
26
  export const BAR_CHART_MAX_BARS = 6;
17
27
  export const BAR_CHART_LABEL_MAX_CHARS = 40;
package/src/index.ts CHANGED
@@ -4,9 +4,17 @@ export type {
4
4
  } from "@json-render/core";
5
5
  export {
6
6
  SPEC_VERSION,
7
+ SPEC_VERSION_1,
8
+ SPEC_VERSION_2,
9
+ SUPPORTED_SPEC_VERSIONS,
10
+ type SpecVersion,
7
11
  MEDIA_TYPE,
8
12
  EFFECT_VALUES,
9
13
  POST_GRID_TAP_KEY,
14
+ MAX_ELEMENTS,
15
+ MAX_ROOT_CHILDREN,
16
+ MAX_CHILDREN,
17
+ MAX_DEPTH,
10
18
  } from "./constants";
11
19
  export {
12
20
  DEFAULT_THEME_ACCENT,
@@ -5,7 +5,7 @@ import { createContext, useContext, type ReactNode } from "react";
5
5
  type SnapPreviewContextValue = {
6
6
  /** From loaded snap `page.theme.accent` (undefined if the snap omits it). */
7
7
  pageAccent: string | undefined;
8
- /** Light/dark appearance passed from SnapView. */
8
+ /** Light/dark appearance passed from SnapCard. */
9
9
  appearance: "light" | "dark";
10
10
  };
11
11
 
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { Badge } from "@neynar/ui/badge";
4
- import { useSnapColors, pickForegroundForBg } from "../hooks/use-snap-colors";
4
+ import { useSnapColors } from "../hooks/use-snap-colors";
5
5
  import { ICON_MAP } from "./icon";
6
6
 
7
7
  export function SnapBadge({
@@ -16,14 +16,13 @@ export function SnapBadge({
16
16
  const colors = useSnapColors();
17
17
 
18
18
  const badgeColor = colors.colorHex(color);
19
- const badgeFg = variant === "default" ? pickForegroundForBg(badgeColor) : badgeColor;
20
19
 
21
20
  const Icon = iconName ? ICON_MAP[iconName] : undefined;
22
21
 
23
22
  const style =
24
23
  variant === "outline"
25
24
  ? { borderColor: badgeColor, color: badgeColor, backgroundColor: "transparent" }
26
- : { backgroundColor: badgeColor, color: badgeFg, borderColor: "transparent" };
25
+ : { backgroundColor: `${badgeColor}20`, color: badgeColor, borderColor: "transparent" };
27
26
 
28
27
  return (
29
28
  <Badge variant={variant} className="gap-1" style={style}>