@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.
- package/dist/constants.d.ts +8 -0
- package/dist/constants.js +9 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/react/components/badge.js +2 -3
- package/dist/react/index.d.ts +9 -4
- package/dist/react/index.js +9 -228
- package/dist/react/snap-view-core.d.ts +11 -0
- package/dist/react/snap-view-core.js +224 -0
- package/dist/react/v1/snap-view.d.ts +14 -0
- package/dist/react/v1/snap-view.js +9 -0
- package/dist/react/v2/snap-view.d.ts +21 -0
- package/dist/react/v2/snap-view.js +76 -0
- package/dist/react-native/components/snap-badge.js +3 -3
- package/dist/react-native/index.d.ts +15 -45
- package/dist/react-native/index.js +10 -166
- package/dist/react-native/snap-view-core.d.ts +11 -0
- package/dist/react-native/snap-view-core.js +153 -0
- package/dist/react-native/types.d.ts +41 -0
- package/dist/react-native/types.js +1 -0
- package/dist/react-native/v1/snap-view.d.ts +22 -0
- package/dist/react-native/v1/snap-view.js +31 -0
- package/dist/react-native/v2/snap-view.d.ts +31 -0
- package/dist/react-native/v2/snap-view.js +101 -0
- package/dist/schemas.d.ts +15 -9
- package/dist/schemas.js +7 -8
- package/dist/server/parseRequest.d.ts +7 -0
- package/dist/server/parseRequest.js +22 -0
- package/dist/ui/schema.js +1 -1
- package/dist/validator.d.ts +3 -2
- package/dist/validator.js +193 -2
- package/llms.txt +9 -0
- package/package.json +1 -1
- package/src/constants.ts +11 -1
- package/src/index.ts +8 -0
- package/src/react/accent-context.tsx +1 -1
- package/src/react/components/badge.tsx +2 -3
- package/src/react/index.tsx +37 -330
- package/src/react/snap-view-core.tsx +340 -0
- package/src/react/v1/snap-view.tsx +50 -0
- package/src/react/v2/snap-view.tsx +168 -0
- package/src/react-native/components/snap-badge.tsx +3 -3
- package/src/react-native/index.tsx +47 -267
- package/src/react-native/snap-view-core.tsx +209 -0
- package/src/react-native/types.ts +37 -0
- package/src/react-native/v1/snap-view.tsx +108 -0
- package/src/react-native/v2/snap-view.tsx +239 -0
- package/src/schemas.ts +9 -10
- package/src/server/parseRequest.ts +34 -0
- package/src/ui/schema.ts +1 -1
- 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 {
|
|
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.
|
|
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:
|
|
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
|
-
|
|
75
|
+
nonce: z.ZodOptional<z.ZodString>;
|
|
76
|
+
audience: z.ZodOptional<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.ZodOptional<z.ZodString>;
|
|
90
|
+
audience: z.ZodOptional<z.ZodString>;
|
|
86
91
|
type: z.ZodLiteral<"post">;
|
|
87
|
-
}, z.core.$
|
|
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.ZodOptional<z.ZodString>;
|
|
101
|
+
audience: z.ZodOptional<z.ZodString>;
|
|
96
102
|
type: z.ZodLiteral<"post">;
|
|
97
|
-
}, z.core.$
|
|
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,
|
|
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.
|
|
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().optional(),
|
|
40
|
+
audience: z.string().optional(),
|
|
40
41
|
})
|
|
41
|
-
.
|
|
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,28 @@ export async function parseRequest(request, options = {}) {
|
|
|
77
77
|
},
|
|
78
78
|
};
|
|
79
79
|
}
|
|
80
|
+
// Audience validation: only enforce when the client sends an audience field.
|
|
81
|
+
// v1 clients may not include nonce/audience yet.
|
|
82
|
+
if (body.audience !== undefined) {
|
|
83
|
+
let expectedOrigin = options.requestOrigin;
|
|
84
|
+
if (expectedOrigin === undefined) {
|
|
85
|
+
try {
|
|
86
|
+
expectedOrigin = new URL(request.url).origin;
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
// do nothing
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (expectedOrigin !== undefined && body.audience !== expectedOrigin) {
|
|
93
|
+
return {
|
|
94
|
+
success: false,
|
|
95
|
+
error: {
|
|
96
|
+
type: "origin_mismatch",
|
|
97
|
+
message: `payload audience "${body.audience}" does not match expected origin "${expectedOrigin}"`,
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
}
|
|
80
102
|
return {
|
|
81
103
|
success: true,
|
|
82
104
|
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
|
|
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
|
});
|
package/dist/validator.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
package/src/constants.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
export 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
|
|
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
|
|
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
|
|
25
|
+
: { backgroundColor: `${badgeColor}20`, color: badgeColor, borderColor: "transparent" };
|
|
27
26
|
|
|
28
27
|
return (
|
|
29
28
|
<Badge variant={variant} className="gap-1" style={style}>
|