@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.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/dist/colors.d.ts +10 -0
  3. package/dist/colors.js +22 -0
  4. package/dist/constants.d.ts +1 -0
  5. package/dist/constants.js +5 -1
  6. package/dist/index.d.ts +3 -3
  7. package/dist/index.js +3 -3
  8. package/dist/react/components/cell-grid.js +25 -17
  9. package/dist/react/components/stack.js +19 -6
  10. package/dist/react/components/text.js +9 -2
  11. package/dist/react/hooks/use-snap-colors.js +2 -9
  12. package/dist/react-native/components/snap-cell-grid.js +25 -16
  13. package/dist/react-native/components/snap-stack.js +17 -7
  14. package/dist/react-native/use-snap-palette.js +2 -2
  15. package/dist/schemas.d.ts +52 -0
  16. package/dist/schemas.js +10 -4
  17. package/dist/server/index.d.ts +2 -1
  18. package/dist/server/index.js +2 -1
  19. package/dist/server/parseRequest.d.ts +4 -3
  20. package/dist/server/parseRequest.js +91 -67
  21. package/dist/server/verify.d.ts +12 -5
  22. package/dist/server/verify.js +67 -19
  23. package/dist/stack-horizontal-utils.d.ts +6 -0
  24. package/dist/stack-horizontal-utils.js +14 -0
  25. package/dist/ui/catalog.d.ts +3 -2
  26. package/dist/ui/catalog.js +1 -1
  27. package/dist/ui/cell-grid.d.ts +3 -2
  28. package/dist/ui/cell-grid.js +12 -2
  29. package/dist/verify.test.js +3 -3
  30. package/llms.txt +14 -4
  31. package/package.json +1 -1
  32. package/src/colors.ts +27 -0
  33. package/src/constants.ts +6 -1
  34. package/src/index.ts +6 -0
  35. package/src/react/components/cell-grid.tsx +28 -16
  36. package/src/react/components/stack.tsx +21 -5
  37. package/src/react/components/text.tsx +9 -2
  38. package/src/react/hooks/use-snap-colors.ts +3 -8
  39. package/src/react-native/components/snap-cell-grid.tsx +28 -15
  40. package/src/react-native/components/snap-stack.tsx +19 -7
  41. package/src/react-native/use-snap-palette.ts +2 -1
  42. package/src/schemas.ts +14 -4
  43. package/src/server/index.ts +7 -1
  44. package/src/server/parseRequest.ts +117 -71
  45. package/src/server/verify.ts +99 -26
  46. package/src/stack-horizontal-utils.ts +14 -0
  47. package/src/ui/catalog.ts +1 -1
  48. package/src/ui/cell-grid.ts +16 -2
  49. 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
- // Parse selection single mode: "row,col" string; multi mode: "row,col|row,col|..." string
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
- for (const part of tapRaw.split("|")) {
39
- if (part.includes(",")) selectedSet.add(part);
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(`${r},${c}`);
64
+ isSelectable && selectedSet.has(cellWireValue(r, c));
45
65
 
46
66
  const handleTap = (r: number, c: number) => {
47
- const key = `${r},${c}`;
67
+ const wire = cellWireValue(r, c);
48
68
  if (isMultiple) {
49
69
  const next = new Set(selectedSet);
50
- if (next.has(key)) next.delete(key);
51
- else next.add(key);
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, key);
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-3",
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
- /** Row peers hug content like RN `wrapRow`; avoid `flex-1` stretching peers across the row. */
34
- inHorizontalStack ? "min-w-0 shrink" : "flex-1",
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 type { PaletteColor } from "@farcaster/snap";
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
- if (!name || name === "accent") return accent;
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
- for (const part of tapRaw.split("|")) {
38
- if (part.includes(",")) selectedSet.add(part);
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(`${r},${c}`);
64
+ isSelectable && selectedSet.has(cellWireValue(r, c));
44
65
 
45
66
  const handleTap = (r: number, c: number) => {
46
- const key = `${r},${c}`;
67
+ const wire = cellWireValue(r, c);
47
68
  if (isMultiple) {
48
69
  const next = new Set(selectedSet);
49
- if (next.has(key)) next.delete(key);
50
- else next.add(key);
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, key);
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: 12,
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 === "accent" ? accentHex : resolveHex(semantic, mode);
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: z.number().int().nonnegative().optional(), // deprecated in favor of user.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: z.object({
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>;
@@ -1,4 +1,10 @@
1
- export { verifyJFSRequestBody, decodePayload, encodePayload } from "./verify";
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, verifyJFSRequestBody } from "./verify";
8
- import { z } from "zod";
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` is allowed for first-page loads and returns `{ type: "get" }`.
74
- * - `POST`: the body must be JSON in JFS form (`header` / `payload` / `signature`) even if JFS verification is skipped.
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 (!["GET", "POST"].includes(request.method)) {
81
- return {
82
- success: false,
83
- error: {
84
- type: "method_not_allowed",
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
- if (request.method === "GET") {
91
- return {
92
- success: true,
93
- action: { type: ACTION_TYPE_GET },
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 maxSkew = options.maxSkewSeconds ?? DEFAULT_SNAP_POST_MAX_SKEW_SECONDS;
98
- const nowSec = Math.floor(Date.now() / 1000);
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
- const text = await request.text();
114
+ return {
115
+ success: true,
116
+ action: { type: ACTION_TYPE_GET, ...result.payload },
117
+ };
118
+ }
101
119
 
102
- let jsonBody: unknown;
103
- try {
104
- jsonBody = JSON.parse(text);
105
- } catch {
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: "invalid_json",
110
- message: "request body is not valid JSON",
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
- const parsed = requestBodySchema.safeParse(jsonBody);
116
- if (!parsed.success) {
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
- success: false,
119
- error: { type: "invalid_json", message: parsed.error.message },
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 = payloadSchema.safeParse(
124
- decodePayload(parsed.data.payload),
125
- );
186
+ const payloadParsed = schema.safeParse(decodePayload(jfs.payload));
126
187
  if (!payloadParsed.success) {
127
188
  return {
128
- success: false,
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 jfs = await verifyJFSRequestBody(parsed.data);
137
- if (!jfs.valid) {
196
+ const verified = await verifyJFS(jfs);
197
+ if (!verified.valid) {
138
198
  return {
139
- success: false,
140
- error: { type: "signature", message: jfs.error.message },
199
+ ok: false,
200
+ error: { type: "signature", message: verified.error.message },
141
201
  };
142
202
  }
143
- if (jfs.signingUserFid !== body.user.fid) {
203
+ if (verified.signingUserFid !== payload.user.fid) {
144
204
  return {
145
- success: false,
205
+ ok: false,
146
206
  error: {
147
207
  type: "fid_mismatch",
148
- message: `JFS header fid "${jfs.signingUserFid}" does not match user.fid "${body.user.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
- if (Math.abs(nowSec - body.timestamp) > maxSkew) {
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
- success: false,
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 && body.audience !== expectedOrigin) {
241
+ if (expectedOrigin !== undefined && payload.audience !== expectedOrigin) {
180
242
  return {
181
- success: false,
243
+ ok: false,
182
244
  error: {
183
245
  type: "origin_mismatch",
184
- message: `payload audience "${body.audience}" does not match expected origin "${expectedOrigin}"`,
246
+ message: `payload audience "${payload.audience}" does not match expected origin "${expectedOrigin}"`,
185
247
  },
186
248
  };
187
249
  }
188
250
 
189
- if (body.fid !== undefined && body.fid !== body.user.fid) {
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
  }