@farcaster/snap 2.1.2 → 2.3.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.
- package/LICENSE +21 -0
- package/dist/colors.d.ts +10 -0
- package/dist/colors.js +22 -0
- package/dist/constants.d.ts +1 -0
- package/dist/constants.js +5 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.js +3 -3
- package/dist/react/components/cell-grid.js +25 -17
- package/dist/react/components/stack.js +19 -6
- package/dist/react/components/text.js +9 -2
- package/dist/react/hooks/use-snap-colors.js +2 -9
- package/dist/react-native/components/snap-cell-grid.js +25 -16
- package/dist/react-native/components/snap-stack.js +17 -7
- package/dist/react-native/use-snap-palette.js +2 -2
- package/dist/schemas.d.ts +52 -0
- package/dist/schemas.js +10 -4
- package/dist/server/index.d.ts +2 -1
- package/dist/server/index.js +2 -1
- package/dist/server/parseRequest.d.ts +4 -3
- package/dist/server/parseRequest.js +91 -67
- package/dist/server/verify.d.ts +12 -5
- package/dist/server/verify.js +67 -19
- package/dist/stack-horizontal-utils.d.ts +6 -0
- package/dist/stack-horizontal-utils.js +14 -0
- package/dist/ui/catalog.d.ts +3 -2
- package/dist/ui/catalog.js +1 -1
- package/dist/ui/cell-grid.d.ts +3 -2
- package/dist/ui/cell-grid.js +12 -2
- package/dist/verify.test.js +3 -3
- package/llms.txt +14 -4
- package/package.json +1 -1
- package/src/colors.ts +27 -0
- package/src/constants.ts +6 -1
- package/src/index.ts +6 -0
- package/src/react/components/cell-grid.tsx +28 -16
- package/src/react/components/stack.tsx +21 -5
- package/src/react/components/text.tsx +9 -2
- package/src/react/hooks/use-snap-colors.ts +3 -8
- package/src/react-native/components/snap-cell-grid.tsx +28 -15
- package/src/react-native/components/snap-stack.tsx +19 -7
- package/src/react-native/use-snap-palette.ts +2 -1
- package/src/schemas.ts +14 -4
- package/src/server/index.ts +7 -1
- package/src/server/parseRequest.ts +117 -71
- package/src/server/verify.ts +99 -26
- package/src/stack-horizontal-utils.ts +14 -0
- package/src/ui/catalog.ts +1 -1
- package/src/ui/cell-grid.ts +16 -2
- package/src/verify.test.ts +3 -3
|
@@ -32,38 +32,50 @@ export function SnapCellGrid({
|
|
|
32
32
|
const tapPath = `/inputs/${name}`;
|
|
33
33
|
const tapRaw = get(tapPath);
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
const cellMap = new Map<
|
|
36
|
+
string,
|
|
37
|
+
{ color?: string; content?: string; value?: string }
|
|
38
|
+
>();
|
|
39
|
+
for (const c of cells) {
|
|
40
|
+
cellMap.set(`${Number(c.row)},${Number(c.col)}`, {
|
|
41
|
+
color: c.color as string | undefined,
|
|
42
|
+
content: c.content != null ? String(c.content) : undefined,
|
|
43
|
+
value: typeof c.value === "string" ? c.value : undefined,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Each cell's wire value — its `value` if set, otherwise "row,col" fallback.
|
|
48
|
+
const cellWireValue = (r: number, c: number) =>
|
|
49
|
+
cellMap.get(`${r},${c}`)?.value ?? `${r},${c}`;
|
|
50
|
+
|
|
51
|
+
// Multi mode joins values with `|`; single mode is the value itself.
|
|
36
52
|
const selectedSet = new Set<string>();
|
|
37
53
|
if (typeof tapRaw === "string" && tapRaw.length > 0) {
|
|
38
|
-
|
|
39
|
-
|
|
54
|
+
if (isMultiple) {
|
|
55
|
+
for (const part of tapRaw.split("|")) {
|
|
56
|
+
if (part.length > 0) selectedSet.add(part);
|
|
57
|
+
}
|
|
58
|
+
} else {
|
|
59
|
+
selectedSet.add(tapRaw);
|
|
40
60
|
}
|
|
41
61
|
}
|
|
42
62
|
|
|
43
63
|
const isSelected = (r: number, c: number) =>
|
|
44
|
-
isSelectable && selectedSet.has(
|
|
64
|
+
isSelectable && selectedSet.has(cellWireValue(r, c));
|
|
45
65
|
|
|
46
66
|
const handleTap = (r: number, c: number) => {
|
|
47
|
-
const
|
|
67
|
+
const wire = cellWireValue(r, c);
|
|
48
68
|
if (isMultiple) {
|
|
49
69
|
const next = new Set(selectedSet);
|
|
50
|
-
if (next.has(
|
|
51
|
-
else next.add(
|
|
70
|
+
if (next.has(wire)) next.delete(wire);
|
|
71
|
+
else next.add(wire);
|
|
52
72
|
set(tapPath, [...next].join("|"));
|
|
53
73
|
} else {
|
|
54
|
-
set(tapPath,
|
|
74
|
+
set(tapPath, wire);
|
|
55
75
|
}
|
|
56
76
|
if (hasPressAction) emit("press");
|
|
57
77
|
};
|
|
58
78
|
|
|
59
|
-
const cellMap = new Map<string, { color?: string; content?: string }>();
|
|
60
|
-
for (const c of cells) {
|
|
61
|
-
cellMap.set(`${Number(c.row)},${Number(c.col)}`, {
|
|
62
|
-
color: c.color as string | undefined,
|
|
63
|
-
content: c.content != null ? String(c.content) : undefined,
|
|
64
|
-
});
|
|
65
|
-
}
|
|
66
|
-
|
|
67
79
|
/** Cells without a palette `color` — subtle fill so empty slots read as tiles. */
|
|
68
80
|
const emptyCellBg =
|
|
69
81
|
colors.mode === "dark" ? "rgba(255, 255, 255, 0.05)" : "rgba(0, 0, 0, 0.05)";
|
|
@@ -4,6 +4,7 @@ import type { ReactNode } from "react";
|
|
|
4
4
|
import { cn } from "@neynar/ui/utils";
|
|
5
5
|
import {
|
|
6
6
|
countRenderableChildren,
|
|
7
|
+
defaultHorizontalGapSize,
|
|
7
8
|
horizontalChildrenAreAllButtons,
|
|
8
9
|
} from "../../stack-horizontal-utils.js";
|
|
9
10
|
import {
|
|
@@ -22,7 +23,7 @@ const HGAP: Record<string, string> = {
|
|
|
22
23
|
none: "gap-0",
|
|
23
24
|
sm: "gap-1",
|
|
24
25
|
md: "gap-2",
|
|
25
|
-
lg: "gap-
|
|
26
|
+
lg: "gap-4",
|
|
26
27
|
};
|
|
27
28
|
|
|
28
29
|
const JUSTIFY_FLEX: Record<string, string> = {
|
|
@@ -52,11 +53,7 @@ export function SnapStack({
|
|
|
52
53
|
}) {
|
|
53
54
|
const parentDirection = useSnapStackDirection();
|
|
54
55
|
const direction = String(props.direction ?? "vertical");
|
|
55
|
-
const gapKey = String(props.gap ?? "md");
|
|
56
56
|
const isHorizontal = direction === "horizontal";
|
|
57
|
-
const gap = isHorizontal
|
|
58
|
-
? (HGAP[gapKey] ?? "gap-2")
|
|
59
|
-
: (VGAP[gapKey] ?? "gap-4");
|
|
60
57
|
const justifyKey = props.justify ? String(props.justify) : undefined;
|
|
61
58
|
const justifyFlex = justifyKey ? JUSTIFY_FLEX[justifyKey] : undefined;
|
|
62
59
|
const buttonRowGrid =
|
|
@@ -73,6 +70,25 @@ export function SnapStack({
|
|
|
73
70
|
Number.isInteger(columnsRaw)
|
|
74
71
|
? columnsRaw
|
|
75
72
|
: undefined;
|
|
73
|
+
|
|
74
|
+
// Horizontal default depends on column count: 2→lg, 3→md, 4+→sm. Vertical stays md.
|
|
75
|
+
// Count comes from explicit `columns`, then button-row inference, else direct children
|
|
76
|
+
// count (any horizontal stack is N columns wide regardless of child types).
|
|
77
|
+
const horizontalColumnCount = isHorizontal
|
|
78
|
+
? (columns ??
|
|
79
|
+
(buttonRowGrid ? buttonRowCount : undefined) ??
|
|
80
|
+
countRenderableChildren(children))
|
|
81
|
+
: undefined;
|
|
82
|
+
const explicitGap =
|
|
83
|
+
typeof props.gap === "string" && props.gap in (isHorizontal ? HGAP : VGAP);
|
|
84
|
+
const gapKey = explicitGap
|
|
85
|
+
? String(props.gap)
|
|
86
|
+
: isHorizontal
|
|
87
|
+
? defaultHorizontalGapSize(horizontalColumnCount)
|
|
88
|
+
: "md";
|
|
89
|
+
const gap = isHorizontal
|
|
90
|
+
? (HGAP[gapKey] ?? HGAP.md!)
|
|
91
|
+
: (VGAP[gapKey] ?? VGAP.md!);
|
|
76
92
|
const explicitColumnGrid =
|
|
77
93
|
isHorizontal && columns !== undefined && !buttonRowGrid;
|
|
78
94
|
const columnGridClass =
|
|
@@ -30,8 +30,15 @@ export function SnapText({
|
|
|
30
30
|
weight={weight}
|
|
31
31
|
align={align}
|
|
32
32
|
className={cn(
|
|
33
|
-
/**
|
|
34
|
-
|
|
33
|
+
/**
|
|
34
|
+
* Row peers hug content like RN `wrapRow` — `min-w-0 shrink` lets text wrap
|
|
35
|
+
* inside a horizontal stack without forcing peers wide. In a vertical stack
|
|
36
|
+
* the `<p>` already fills its parent's width via `display: block`; avoid
|
|
37
|
+
* `flex-1` here because `flex-grow: 1` on a vertical-flex child fills the
|
|
38
|
+
* column's height, distributing siblings when the row is taller than its
|
|
39
|
+
* content (e.g. text next to a tall image).
|
|
40
|
+
*/
|
|
41
|
+
inHorizontalStack ? "min-w-0 shrink" : "min-w-0",
|
|
35
42
|
)}
|
|
36
43
|
style={{ color: colors.text }}
|
|
37
44
|
>
|
|
@@ -4,8 +4,7 @@ import { useMemo } from "react";
|
|
|
4
4
|
import { useStateStore } from "@json-render/react";
|
|
5
5
|
import { resolveSnapPaletteHex } from "../lib/resolve-palette-hex";
|
|
6
6
|
import { useSnapPreviewPageAccent, useSnapAppearance } from "../accent-context";
|
|
7
|
-
import
|
|
8
|
-
import { PALETTE_DARK_HEX, PALETTE_LIGHT_HEX } from "@farcaster/snap";
|
|
7
|
+
import { resolveSnapColorHex } from "@farcaster/snap";
|
|
9
8
|
|
|
10
9
|
/** Readable foreground color (black or white) for a given hex background. */
|
|
11
10
|
export function pickForegroundForBg(hex: string): string {
|
|
@@ -76,7 +75,6 @@ function buildSnapColors(
|
|
|
76
75
|
const accent = resolveSnapPaletteHex(accentName, mode);
|
|
77
76
|
const accentFg = pickForegroundForBg(accent);
|
|
78
77
|
const neutrals = mode === "dark" ? NEUTRAL_DARK : NEUTRAL_LIGHT;
|
|
79
|
-
const paletteMap = mode === "dark" ? PALETTE_DARK_HEX : PALETTE_LIGHT_HEX;
|
|
80
78
|
|
|
81
79
|
const accentHover =
|
|
82
80
|
mode === "light"
|
|
@@ -87,11 +85,8 @@ function buildSnapColors(
|
|
|
87
85
|
|
|
88
86
|
const paletteHex = (name: string) => resolveSnapPaletteHex(name, mode);
|
|
89
87
|
|
|
90
|
-
const colorHex = (name: string | undefined) =>
|
|
91
|
-
|
|
92
|
-
if (Object.hasOwn(paletteMap, name)) return paletteMap[name as PaletteColor];
|
|
93
|
-
return accent;
|
|
94
|
-
};
|
|
88
|
+
const colorHex = (name: string | undefined) =>
|
|
89
|
+
resolveSnapColorHex(name, { accentHex: accent, appearance: mode });
|
|
95
90
|
|
|
96
91
|
return {
|
|
97
92
|
accent,
|
|
@@ -32,37 +32,50 @@ export function SnapCellGrid({
|
|
|
32
32
|
const tapPath = `/inputs/${name}`;
|
|
33
33
|
const tapRaw = get(tapPath);
|
|
34
34
|
|
|
35
|
+
const cellMap = new Map<
|
|
36
|
+
string,
|
|
37
|
+
{ color?: string; content?: string; value?: string }
|
|
38
|
+
>();
|
|
39
|
+
for (const c of cells) {
|
|
40
|
+
cellMap.set(`${Number(c.row)},${Number(c.col)}`, {
|
|
41
|
+
color: c.color as string | undefined,
|
|
42
|
+
content: c.content != null ? String(c.content) : undefined,
|
|
43
|
+
value: typeof c.value === "string" ? c.value : undefined,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Each cell's wire value — its `value` if set, otherwise "row,col" fallback.
|
|
48
|
+
const cellWireValue = (r: number, c: number) =>
|
|
49
|
+
cellMap.get(`${r},${c}`)?.value ?? `${r},${c}`;
|
|
50
|
+
|
|
51
|
+
// Multi mode joins values with `|`; single mode is the value itself.
|
|
35
52
|
const selectedSet = new Set<string>();
|
|
36
53
|
if (typeof tapRaw === "string" && tapRaw.length > 0) {
|
|
37
|
-
|
|
38
|
-
|
|
54
|
+
if (isMultiple) {
|
|
55
|
+
for (const part of tapRaw.split("|")) {
|
|
56
|
+
if (part.length > 0) selectedSet.add(part);
|
|
57
|
+
}
|
|
58
|
+
} else {
|
|
59
|
+
selectedSet.add(tapRaw);
|
|
39
60
|
}
|
|
40
61
|
}
|
|
41
62
|
|
|
42
63
|
const isSelected = (r: number, c: number) =>
|
|
43
|
-
isSelectable && selectedSet.has(
|
|
64
|
+
isSelectable && selectedSet.has(cellWireValue(r, c));
|
|
44
65
|
|
|
45
66
|
const handleTap = (r: number, c: number) => {
|
|
46
|
-
const
|
|
67
|
+
const wire = cellWireValue(r, c);
|
|
47
68
|
if (isMultiple) {
|
|
48
69
|
const next = new Set(selectedSet);
|
|
49
|
-
if (next.has(
|
|
50
|
-
else next.add(
|
|
70
|
+
if (next.has(wire)) next.delete(wire);
|
|
71
|
+
else next.add(wire);
|
|
51
72
|
set(tapPath, [...next].join("|"));
|
|
52
73
|
} else {
|
|
53
|
-
set(tapPath,
|
|
74
|
+
set(tapPath, wire);
|
|
54
75
|
}
|
|
55
76
|
if (hasPressAction) emit("press");
|
|
56
77
|
};
|
|
57
78
|
|
|
58
|
-
const cellMap = new Map<string, { color?: string; content?: string }>();
|
|
59
|
-
for (const c of cells) {
|
|
60
|
-
cellMap.set(`${Number(c.row)},${Number(c.col)}`, {
|
|
61
|
-
color: c.color as string | undefined,
|
|
62
|
-
content: c.content != null ? String(c.content) : undefined,
|
|
63
|
-
});
|
|
64
|
-
}
|
|
65
|
-
|
|
66
79
|
const ringOuter = appearance === "dark" ? "#fff" : "#000";
|
|
67
80
|
const ringInner = appearance === "dark" ? "#000" : "#fff";
|
|
68
81
|
|
|
@@ -3,6 +3,7 @@ import { Children, type ReactNode } from "react";
|
|
|
3
3
|
import { StyleSheet, View } from "react-native";
|
|
4
4
|
import {
|
|
5
5
|
countRenderableChildren,
|
|
6
|
+
defaultHorizontalGapSize,
|
|
6
7
|
horizontalChildrenAreAllButtons,
|
|
7
8
|
} from "../../stack-horizontal-utils.js";
|
|
8
9
|
import {
|
|
@@ -21,7 +22,7 @@ const HGAP: Record<string, number> = {
|
|
|
21
22
|
none: 0,
|
|
22
23
|
sm: 4,
|
|
23
24
|
md: 8,
|
|
24
|
-
lg:
|
|
25
|
+
lg: 16,
|
|
25
26
|
};
|
|
26
27
|
|
|
27
28
|
const JUSTIFY: Record<string, "flex-start" | "center" | "flex-end" | "space-between" | "space-around"> = {
|
|
@@ -53,12 +54,6 @@ export function SnapStack({
|
|
|
53
54
|
const rawGap = props.gap;
|
|
54
55
|
const isHorizontal = direction === "horizontal";
|
|
55
56
|
const gapMap = isHorizontal ? HGAP : VGAP;
|
|
56
|
-
const gap =
|
|
57
|
-
typeof rawGap === "number"
|
|
58
|
-
? rawGap
|
|
59
|
-
: typeof rawGap === "string" && rawGap in gapMap
|
|
60
|
-
? gapMap[rawGap]!
|
|
61
|
-
: isHorizontal ? HGAP.md! : VGAP.md!;
|
|
62
57
|
const buttonRowGrid =
|
|
63
58
|
isHorizontal && horizontalChildrenAreAllButtons(children);
|
|
64
59
|
const buttonRowCount = buttonRowGrid
|
|
@@ -73,6 +68,23 @@ export function SnapStack({
|
|
|
73
68
|
Number.isInteger(columnsRaw)
|
|
74
69
|
? columnsRaw
|
|
75
70
|
: undefined;
|
|
71
|
+
|
|
72
|
+
// Horizontal default depends on column count: 2→lg, 3→md, 4+→sm. Vertical stays md.
|
|
73
|
+
// Count comes from explicit `columns`, then button-row inference, else direct children
|
|
74
|
+
// count (any horizontal stack is N columns wide regardless of child types).
|
|
75
|
+
const horizontalColumnCount = isHorizontal
|
|
76
|
+
? (columns ??
|
|
77
|
+
(buttonRowGrid ? buttonRowCount : undefined) ??
|
|
78
|
+
countRenderableChildren(children))
|
|
79
|
+
: undefined;
|
|
80
|
+
const gap =
|
|
81
|
+
typeof rawGap === "number"
|
|
82
|
+
? rawGap
|
|
83
|
+
: typeof rawGap === "string" && rawGap in gapMap
|
|
84
|
+
? gapMap[rawGap]!
|
|
85
|
+
: isHorizontal
|
|
86
|
+
? gapMap[defaultHorizontalGapSize(horizontalColumnCount)]!
|
|
87
|
+
: VGAP.md!;
|
|
76
88
|
const explicitColumnGrid =
|
|
77
89
|
isHorizontal && columns !== undefined && !buttonRowGrid;
|
|
78
90
|
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
PALETTE_COLOR_VALUES,
|
|
4
4
|
PALETTE_LIGHT_HEX,
|
|
5
5
|
PALETTE_DARK_HEX,
|
|
6
|
+
resolveSnapColorHex,
|
|
6
7
|
type PaletteColor,
|
|
7
8
|
} from "@farcaster/snap";
|
|
8
9
|
import { useStateStore } from "@json-render/react-native";
|
|
@@ -35,7 +36,7 @@ export function useSnapPalette() {
|
|
|
35
36
|
const accentHex = resolveHex(accentName, mode);
|
|
36
37
|
|
|
37
38
|
const hex = (semantic: string) =>
|
|
38
|
-
semantic
|
|
39
|
+
resolveSnapColorHex(semantic, { accentHex, appearance: mode });
|
|
39
40
|
|
|
40
41
|
return { appearance: mode, accentName, accentHex, hex };
|
|
41
42
|
}
|
package/src/schemas.ts
CHANGED
|
@@ -104,26 +104,36 @@ const surfaceSchema = z.discriminatedUnion("type", [
|
|
|
104
104
|
standaloneSurfaceSchema,
|
|
105
105
|
]);
|
|
106
106
|
|
|
107
|
+
const fidSchema = z.number().int().nonnegative();
|
|
108
|
+
const userSchema = z.object({ fid: fidSchema });
|
|
109
|
+
|
|
107
110
|
export const payloadSchema = z
|
|
108
111
|
.object({
|
|
109
|
-
fid:
|
|
112
|
+
fid: fidSchema.optional(), // deprecated in favor of user.fid
|
|
110
113
|
inputs: z.record(z.string(), postInputValueSchema).default({}),
|
|
111
114
|
timestamp: z.number().int(),
|
|
112
115
|
audience: z.string(),
|
|
113
|
-
user:
|
|
114
|
-
fid: z.number().int().nonnegative(),
|
|
115
|
-
}),
|
|
116
|
+
user: userSchema,
|
|
116
117
|
surface: surfaceSchema,
|
|
117
118
|
})
|
|
118
119
|
.strip();
|
|
119
120
|
|
|
120
121
|
export type SnapPayload = z.infer<typeof payloadSchema>;
|
|
121
122
|
|
|
123
|
+
/** JFS payload shape for POST minus deprecated `fid`; used for GET auth via payload header. */
|
|
124
|
+
export const getPayloadSchema = payloadSchema.omit({ inputs: true, fid: true });
|
|
125
|
+
|
|
126
|
+
export type SnapGetPayload = z.infer<typeof getPayloadSchema>;
|
|
127
|
+
|
|
122
128
|
export const ACTION_TYPE_GET = "get" as const;
|
|
123
129
|
export const ACTION_TYPE_POST = "post" as const;
|
|
124
130
|
|
|
125
131
|
const snapGetActionSchema = z.object({
|
|
126
132
|
type: z.literal(ACTION_TYPE_GET),
|
|
133
|
+
user: userSchema.optional(),
|
|
134
|
+
timestamp: z.number().int().optional(),
|
|
135
|
+
audience: z.string().optional(),
|
|
136
|
+
surface: surfaceSchema.optional(),
|
|
127
137
|
});
|
|
128
138
|
|
|
129
139
|
export type SnapGetAction = z.infer<typeof snapGetActionSchema>;
|
package/src/server/index.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export {
|
|
2
|
+
verifyJFS as verifyJFSRequestBody, // deprecated alias. drop in v3
|
|
3
|
+
parseJfs,
|
|
4
|
+
verifyJFS,
|
|
5
|
+
decodePayload,
|
|
6
|
+
encodePayload,
|
|
7
|
+
} from "./verify";
|
|
2
8
|
export {
|
|
3
9
|
DEFAULT_SNAP_HUB_HTTP_BASE_URL,
|
|
4
10
|
getActiveEd25519SignerKeysFromHubHttp,
|
|
@@ -1,11 +1,15 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
1
2
|
import {
|
|
2
3
|
ACTION_TYPE_GET,
|
|
3
4
|
ACTION_TYPE_POST,
|
|
5
|
+
getPayloadSchema,
|
|
4
6
|
payloadSchema,
|
|
5
7
|
type SnapAction,
|
|
8
|
+
type SnapPayload,
|
|
9
|
+
type SnapGetPayload,
|
|
6
10
|
} from "../schemas";
|
|
7
|
-
import { decodePayload,
|
|
8
|
-
import {
|
|
11
|
+
import { decodePayload, parseJfs, verifyJFS } from "./verify";
|
|
12
|
+
import { SNAP_PAYLOAD_HEADER } from "../constants";
|
|
9
13
|
|
|
10
14
|
const DEFAULT_SNAP_POST_MAX_SKEW_SECONDS = 300 as const;
|
|
11
15
|
|
|
@@ -62,98 +66,156 @@ export type ParseRequestResult =
|
|
|
62
66
|
| { success: true; action: SnapAction }
|
|
63
67
|
| { success: false; error: ParseRequestError };
|
|
64
68
|
|
|
65
|
-
const requestBodySchema = z.object({
|
|
66
|
-
header: z.string(),
|
|
67
|
-
payload: z.string(),
|
|
68
|
-
signature: z.string(),
|
|
69
|
-
});
|
|
70
|
-
|
|
71
69
|
/**
|
|
72
70
|
* Parse and validate Farcaster snap requests:
|
|
73
|
-
* - `GET
|
|
74
|
-
*
|
|
71
|
+
* - `GET`: returns `{ type: "get" }`, or optional viewer fields when `X-Snap-Payload`
|
|
72
|
+
* carries a JFS compact string whose decoded payload validates against {@link getPayloadSchema}.
|
|
73
|
+
* - `POST`: the body must be a JFS envelope — either JSON `{ header, payload, signature }` or the same **compact** string form as GET (`BASE64URL(header).BASE64URL(payload).BASE64URL(signature)`), even if JFS verification is skipped.
|
|
75
74
|
*/
|
|
76
75
|
export async function parseRequest(
|
|
77
76
|
request: Request,
|
|
78
77
|
options: ParseRequestOptions = {},
|
|
79
78
|
): Promise<ParseRequestResult> {
|
|
80
|
-
if (
|
|
81
|
-
return
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
message: `expected POST, received ${request.method}`,
|
|
86
|
-
},
|
|
87
|
-
};
|
|
79
|
+
if (request.method === "GET") {
|
|
80
|
+
return await parseGetRequest(request, options);
|
|
81
|
+
}
|
|
82
|
+
if (request.method === "POST") {
|
|
83
|
+
return await parsePostRequest(request, options);
|
|
88
84
|
}
|
|
85
|
+
return {
|
|
86
|
+
success: false,
|
|
87
|
+
error: {
|
|
88
|
+
type: "method_not_allowed",
|
|
89
|
+
message: `expected GET or POST, received ${request.method}`,
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
89
93
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
94
|
+
async function parseGetRequest(
|
|
95
|
+
request: Request,
|
|
96
|
+
options: ParseRequestOptions,
|
|
97
|
+
): Promise<ParseRequestResult> {
|
|
98
|
+
const compactHeader = request.headers.get(SNAP_PAYLOAD_HEADER)?.trim();
|
|
99
|
+
if (!compactHeader) {
|
|
100
|
+
return { success: true, action: { type: ACTION_TYPE_GET } };
|
|
95
101
|
}
|
|
96
102
|
|
|
97
|
-
const
|
|
98
|
-
|
|
103
|
+
const result = await validateJfsPayload({
|
|
104
|
+
jfsText: compactHeader,
|
|
105
|
+
schema: getPayloadSchema,
|
|
106
|
+
request,
|
|
107
|
+
options,
|
|
108
|
+
invalidJsonMessage: `${SNAP_PAYLOAD_HEADER} must be a valid JFS compact string`,
|
|
109
|
+
});
|
|
110
|
+
if (!result.ok) {
|
|
111
|
+
return { success: false, error: result.error };
|
|
112
|
+
}
|
|
99
113
|
|
|
100
|
-
|
|
114
|
+
return {
|
|
115
|
+
success: true,
|
|
116
|
+
action: { type: ACTION_TYPE_GET, ...result.payload },
|
|
117
|
+
};
|
|
118
|
+
}
|
|
101
119
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
120
|
+
async function parsePostRequest(
|
|
121
|
+
request: Request,
|
|
122
|
+
options: ParseRequestOptions,
|
|
123
|
+
): Promise<ParseRequestResult> {
|
|
124
|
+
const result = await validateJfsPayload({
|
|
125
|
+
jfsText: await request.text(),
|
|
126
|
+
schema: payloadSchema,
|
|
127
|
+
request,
|
|
128
|
+
options,
|
|
129
|
+
});
|
|
130
|
+
if (!result.ok) {
|
|
131
|
+
return { success: false, error: result.error };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const payload = result.payload;
|
|
135
|
+
if (payload.fid !== undefined && payload.fid !== payload.user.fid) {
|
|
106
136
|
return {
|
|
107
137
|
success: false,
|
|
108
138
|
error: {
|
|
109
|
-
type: "
|
|
110
|
-
message: "
|
|
139
|
+
type: "fid_mismatch",
|
|
140
|
+
message: `fid "${payload.fid}" does not match user.fid "${payload.user.fid}"`,
|
|
111
141
|
},
|
|
112
142
|
};
|
|
113
143
|
}
|
|
114
144
|
|
|
115
|
-
|
|
116
|
-
|
|
145
|
+
return {
|
|
146
|
+
success: true,
|
|
147
|
+
action: { type: ACTION_TYPE_POST, ...payload },
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Shared pipeline for authenticated snap requests: parse the JFS envelope,
|
|
153
|
+
* decode and schema-validate the payload, optionally verify the JFS signature
|
|
154
|
+
* against an active hub signer (matching `user.fid`), then check timestamp
|
|
155
|
+
* skew and that `audience` matches the request origin.
|
|
156
|
+
*
|
|
157
|
+
* Both GET (payload header) and POST (request body) feed into this.
|
|
158
|
+
*/
|
|
159
|
+
async function validateJfsPayload<T extends SnapPayload | SnapGetPayload>({
|
|
160
|
+
jfsText,
|
|
161
|
+
schema,
|
|
162
|
+
request,
|
|
163
|
+
options,
|
|
164
|
+
invalidJsonMessage,
|
|
165
|
+
}: {
|
|
166
|
+
jfsText: string;
|
|
167
|
+
schema: z.ZodType<T>;
|
|
168
|
+
request: Request;
|
|
169
|
+
options: ParseRequestOptions;
|
|
170
|
+
invalidJsonMessage?: string;
|
|
171
|
+
}): Promise<
|
|
172
|
+
{ ok: true; payload: T } | { ok: false; error: ParseRequestError }
|
|
173
|
+
> {
|
|
174
|
+
const parsed = parseJfs(jfsText);
|
|
175
|
+
if (!parsed.ok) {
|
|
117
176
|
return {
|
|
118
|
-
|
|
119
|
-
error: {
|
|
177
|
+
ok: false,
|
|
178
|
+
error: {
|
|
179
|
+
type: "invalid_json",
|
|
180
|
+
message: invalidJsonMessage ?? parsed.error,
|
|
181
|
+
},
|
|
120
182
|
};
|
|
121
183
|
}
|
|
184
|
+
const jfs = parsed.jfs;
|
|
122
185
|
|
|
123
|
-
const payloadParsed =
|
|
124
|
-
decodePayload(parsed.data.payload),
|
|
125
|
-
);
|
|
186
|
+
const payloadParsed = schema.safeParse(decodePayload(jfs.payload));
|
|
126
187
|
if (!payloadParsed.success) {
|
|
127
188
|
return {
|
|
128
|
-
|
|
189
|
+
ok: false,
|
|
129
190
|
error: { type: "validation", issues: payloadParsed.error.issues },
|
|
130
191
|
};
|
|
131
192
|
}
|
|
132
|
-
|
|
133
|
-
const body = payloadParsed.data;
|
|
193
|
+
const payload = payloadParsed.data;
|
|
134
194
|
|
|
135
195
|
if (!options.skipJFSVerification) {
|
|
136
|
-
const
|
|
137
|
-
if (!
|
|
196
|
+
const verified = await verifyJFS(jfs);
|
|
197
|
+
if (!verified.valid) {
|
|
138
198
|
return {
|
|
139
|
-
|
|
140
|
-
error: { type: "signature", message:
|
|
199
|
+
ok: false,
|
|
200
|
+
error: { type: "signature", message: verified.error.message },
|
|
141
201
|
};
|
|
142
202
|
}
|
|
143
|
-
if (
|
|
203
|
+
if (verified.signingUserFid !== payload.user.fid) {
|
|
144
204
|
return {
|
|
145
|
-
|
|
205
|
+
ok: false,
|
|
146
206
|
error: {
|
|
147
207
|
type: "fid_mismatch",
|
|
148
|
-
message: `JFS header fid "${
|
|
208
|
+
message: `JFS header fid "${verified.signingUserFid}" does not match user.fid "${payload.user.fid}"`,
|
|
149
209
|
},
|
|
150
210
|
};
|
|
151
211
|
}
|
|
152
212
|
}
|
|
153
213
|
|
|
154
|
-
|
|
214
|
+
const maxSkew = options.maxSkewSeconds ?? DEFAULT_SNAP_POST_MAX_SKEW_SECONDS;
|
|
215
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
216
|
+
if (Math.abs(nowSec - payload.timestamp) > maxSkew) {
|
|
155
217
|
return {
|
|
156
|
-
|
|
218
|
+
ok: false,
|
|
157
219
|
error: {
|
|
158
220
|
type: "replay",
|
|
159
221
|
message: `timestamp outside allowed skew of ${maxSkew}s`,
|
|
@@ -176,31 +238,15 @@ export async function parseRequest(
|
|
|
176
238
|
}
|
|
177
239
|
}
|
|
178
240
|
|
|
179
|
-
if (expectedOrigin !== undefined &&
|
|
241
|
+
if (expectedOrigin !== undefined && payload.audience !== expectedOrigin) {
|
|
180
242
|
return {
|
|
181
|
-
|
|
243
|
+
ok: false,
|
|
182
244
|
error: {
|
|
183
245
|
type: "origin_mismatch",
|
|
184
|
-
message: `payload audience "${
|
|
246
|
+
message: `payload audience "${payload.audience}" does not match expected origin "${expectedOrigin}"`,
|
|
185
247
|
},
|
|
186
248
|
};
|
|
187
249
|
}
|
|
188
250
|
|
|
189
|
-
|
|
190
|
-
return {
|
|
191
|
-
success: false,
|
|
192
|
-
error: {
|
|
193
|
-
type: "fid_mismatch",
|
|
194
|
-
message: `fid "${body.fid}" does not match user.fid "${body.user.fid}"`,
|
|
195
|
-
},
|
|
196
|
-
};
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
return {
|
|
200
|
-
success: true,
|
|
201
|
-
action: {
|
|
202
|
-
type: ACTION_TYPE_POST,
|
|
203
|
-
...body,
|
|
204
|
-
},
|
|
205
|
-
};
|
|
251
|
+
return { ok: true, payload };
|
|
206
252
|
}
|