@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,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,
|
|
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.
|
|
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:
|
|
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
|
-
.
|
|
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
|
-
.
|
|
106
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
}
|