@farcaster/snap 2.1.2 → 2.2.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/dist/colors.d.ts CHANGED
@@ -24,6 +24,16 @@ export declare const PALETTE_COLOR_ACCENT: "accent";
24
24
  export declare const DEFAULT_THEME_ACCENT: "purple";
25
25
  export declare const PALETTE_COLOR_VALUES: readonly ["gray", "blue", "red", "amber", "green", "teal", "purple", "pink"];
26
26
  export type PaletteColor = (typeof PALETTE_COLOR_VALUES)[number];
27
+ export declare function isSnapHexColorString(s: string): boolean;
28
+ /**
29
+ * Resolve a snap color token for inline styles: `accent`, palette names, or
30
+ * literal `#rrggbb`. Unknown values fall back to `accentHex` (same as legacy
31
+ * `colorHex` behavior for non-hex strings).
32
+ */
33
+ export declare function resolveSnapColorHex(color: string | undefined, opts: {
34
+ accentHex: string;
35
+ appearance: "light" | "dark";
36
+ }): string;
27
37
  /** Light-mode hex for each palette color (emulator / reference client). */
28
38
  export declare const PALETTE_LIGHT_HEX: Record<PaletteColor, string>;
29
39
  /** Dark-mode hex for each palette color (reference). */
package/dist/colors.js CHANGED
@@ -32,6 +32,28 @@ export const PALETTE_COLOR_VALUES = [
32
32
  PALETTE_COLOR.purple,
33
33
  PALETTE_COLOR.pink,
34
34
  ];
35
+ /** Strict `#rrggbb` literal used by cell_grid (and clients that accept hex). */
36
+ const SNAP_HEX_6 = /^#[0-9a-fA-F]{6}$/;
37
+ export function isSnapHexColorString(s) {
38
+ return SNAP_HEX_6.test(s.trim());
39
+ }
40
+ /**
41
+ * Resolve a snap color token for inline styles: `accent`, palette names, or
42
+ * literal `#rrggbb`. Unknown values fall back to `accentHex` (same as legacy
43
+ * `colorHex` behavior for non-hex strings).
44
+ */
45
+ export function resolveSnapColorHex(color, opts) {
46
+ if (!color || color === PALETTE_COLOR_ACCENT)
47
+ return opts.accentHex;
48
+ const trimmed = color.trim();
49
+ if (isSnapHexColorString(trimmed))
50
+ return trimmed;
51
+ const map = opts.appearance === "dark" ? PALETTE_DARK_HEX : PALETTE_LIGHT_HEX;
52
+ if (Object.hasOwn(map, trimmed)) {
53
+ return map[trimmed];
54
+ }
55
+ return opts.accentHex;
56
+ }
35
57
  /** Light-mode hex for each palette color (emulator / reference client). */
36
58
  export const PALETTE_LIGHT_HEX = {
37
59
  gray: "#6E6A86",
@@ -3,6 +3,7 @@ export declare const SPEC_VERSION_2: "2.0";
3
3
  export declare const SPEC_VERSION: "2.0";
4
4
  export declare const SUPPORTED_SPEC_VERSIONS: readonly ["1.0", "2.0"];
5
5
  export type SpecVersion = (typeof SUPPORTED_SPEC_VERSIONS)[number];
6
+ export declare const SNAP_PAYLOAD_HEADER: "X-Snap-Payload";
6
7
  export declare const MEDIA_TYPE: "application/vnd.farcaster.snap+json";
7
8
  export declare const EFFECT_VALUES: readonly ["confetti"];
8
9
  export declare const POST_GRID_TAP_KEY: "grid_tap";
package/dist/constants.js CHANGED
@@ -1,7 +1,11 @@
1
1
  export const SPEC_VERSION_1 = "1.0";
2
2
  export const SPEC_VERSION_2 = "2.0";
3
3
  export const SPEC_VERSION = SPEC_VERSION_2;
4
- export const SUPPORTED_SPEC_VERSIONS = [SPEC_VERSION_1, SPEC_VERSION_2];
4
+ export const SUPPORTED_SPEC_VERSIONS = [
5
+ SPEC_VERSION_1,
6
+ SPEC_VERSION_2,
7
+ ];
8
+ export const SNAP_PAYLOAD_HEADER = "X-Snap-Payload";
5
9
  export const MEDIA_TYPE = "application/vnd.farcaster.snap+json";
6
10
  export const EFFECT_VALUES = ["confetti"];
7
11
  // ─── Pixel grid ────────────────────────────────────────
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export type { Spec as SnapSpec, UIElement as SnapUIElement, } from "@json-render/core";
2
- export { SPEC_VERSION, SPEC_VERSION_1, SPEC_VERSION_2, SUPPORTED_SPEC_VERSIONS, type SpecVersion, MEDIA_TYPE, EFFECT_VALUES, POST_GRID_TAP_KEY, MAX_ELEMENTS, MAX_ROOT_CHILDREN, MAX_CHILDREN, MAX_DEPTH, } from "./constants.js";
3
- export { DEFAULT_THEME_ACCENT, PALETTE_COLOR, PALETTE_COLOR_ACCENT, PALETTE_COLOR_VALUES, PALETTE_LIGHT_HEX, PALETTE_DARK_HEX, type PaletteColor, } from "./colors.js";
4
- export { ACTION_TYPE_GET, ACTION_TYPE_POST, snapResponseSchema, payloadSchema, type SnapAction, type SnapContext, type SnapResponse, type SnapHandlerResult, type SnapElementInput, type SnapSpecInput, type SnapFunction, type SnapPayload, } from "./schemas.js";
2
+ export { SPEC_VERSION, SPEC_VERSION_1, SPEC_VERSION_2, SUPPORTED_SPEC_VERSIONS, type SpecVersion, SNAP_PAYLOAD_HEADER, MEDIA_TYPE, EFFECT_VALUES, POST_GRID_TAP_KEY, MAX_ELEMENTS, MAX_ROOT_CHILDREN, MAX_CHILDREN, MAX_DEPTH, } from "./constants.js";
3
+ export { DEFAULT_THEME_ACCENT, PALETTE_COLOR, PALETTE_COLOR_ACCENT, PALETTE_COLOR_VALUES, PALETTE_LIGHT_HEX, PALETTE_DARK_HEX, isSnapHexColorString, resolveSnapColorHex, type PaletteColor, } from "./colors.js";
4
+ export { ACTION_TYPE_GET, ACTION_TYPE_POST, snapResponseSchema, payloadSchema, getPayloadSchema, type SnapAction, type SnapGetAction, type SnapContext, type SnapResponse, type SnapHandlerResult, type SnapElementInput, type SnapSpecInput, type SnapFunction, type SnapPayload, type SnapGetPayload, } from "./schemas.js";
5
5
  export { validateSnapResponse, type ValidationResult } from "./validator.js";
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- export { SPEC_VERSION, SPEC_VERSION_1, SPEC_VERSION_2, SUPPORTED_SPEC_VERSIONS, MEDIA_TYPE, EFFECT_VALUES, POST_GRID_TAP_KEY, MAX_ELEMENTS, MAX_ROOT_CHILDREN, MAX_CHILDREN, MAX_DEPTH, } from "./constants.js";
2
- export { DEFAULT_THEME_ACCENT, PALETTE_COLOR, PALETTE_COLOR_ACCENT, PALETTE_COLOR_VALUES, PALETTE_LIGHT_HEX, PALETTE_DARK_HEX, } from "./colors.js";
3
- export { ACTION_TYPE_GET, ACTION_TYPE_POST, snapResponseSchema, payloadSchema, } from "./schemas.js";
1
+ export { SPEC_VERSION, SPEC_VERSION_1, SPEC_VERSION_2, SUPPORTED_SPEC_VERSIONS, SNAP_PAYLOAD_HEADER, MEDIA_TYPE, EFFECT_VALUES, POST_GRID_TAP_KEY, MAX_ELEMENTS, MAX_ROOT_CHILDREN, MAX_CHILDREN, MAX_DEPTH, } from "./constants.js";
2
+ export { DEFAULT_THEME_ACCENT, PALETTE_COLOR, PALETTE_COLOR_ACCENT, PALETTE_COLOR_VALUES, PALETTE_LIGHT_HEX, PALETTE_DARK_HEX, isSnapHexColorString, resolveSnapColorHex, } from "./colors.js";
3
+ export { ACTION_TYPE_GET, ACTION_TYPE_POST, snapResponseSchema, payloadSchema, getPayloadSchema, } from "./schemas.js";
4
4
  export { validateSnapResponse } from "./validator.js";
@@ -3,7 +3,7 @@ import { useMemo } from "react";
3
3
  import { useStateStore } from "@json-render/react";
4
4
  import { resolveSnapPaletteHex } from "../lib/resolve-palette-hex.js";
5
5
  import { useSnapPreviewPageAccent, useSnapAppearance } from "../accent-context.js";
6
- import { PALETTE_DARK_HEX, PALETTE_LIGHT_HEX } from "@farcaster/snap";
6
+ import { resolveSnapColorHex } from "@farcaster/snap";
7
7
  /** Readable foreground color (black or white) for a given hex background. */
8
8
  export function pickForegroundForBg(hex) {
9
9
  const h = hex.replace(/^#/, "");
@@ -37,19 +37,12 @@ function buildSnapColors(accentName, mode) {
37
37
  const accent = resolveSnapPaletteHex(accentName, mode);
38
38
  const accentFg = pickForegroundForBg(accent);
39
39
  const neutrals = mode === "dark" ? NEUTRAL_DARK : NEUTRAL_LIGHT;
40
- const paletteMap = mode === "dark" ? PALETTE_DARK_HEX : PALETTE_LIGHT_HEX;
41
40
  const accentHover = mode === "light"
42
41
  ? `color-mix(in srgb, ${accent} 82%, #000000)`
43
42
  : `color-mix(in srgb, ${accent} 78%, #ffffff)`;
44
43
  const outlineHover = `color-mix(in srgb, ${accent} 14%, ${neutrals.surface})`;
45
44
  const paletteHex = (name) => resolveSnapPaletteHex(name, mode);
46
- const colorHex = (name) => {
47
- if (!name || name === "accent")
48
- return accent;
49
- if (Object.hasOwn(paletteMap, name))
50
- return paletteMap[name];
51
- return accent;
52
- };
45
+ const colorHex = (name) => resolveSnapColorHex(name, { accentHex: accent, appearance: mode });
53
46
  return {
54
47
  accent,
55
48
  accentFg,
@@ -1,4 +1,4 @@
1
- import { DEFAULT_THEME_ACCENT, PALETTE_COLOR_VALUES, PALETTE_LIGHT_HEX, PALETTE_DARK_HEX, } from "@farcaster/snap";
1
+ import { DEFAULT_THEME_ACCENT, PALETTE_COLOR_VALUES, PALETTE_LIGHT_HEX, PALETTE_DARK_HEX, resolveSnapColorHex, } from "@farcaster/snap";
2
2
  import { useStateStore } from "@json-render/react-native";
3
3
  import { useSnapTheme } from "./theme.js";
4
4
  function resolveHex(name, appearance) {
@@ -23,7 +23,7 @@ export function useSnapPalette() {
23
23
  const { get } = useStateStore();
24
24
  const accentName = themeAccentFromStore(get);
25
25
  const accentHex = resolveHex(accentName, mode);
26
- const hex = (semantic) => semantic === "accent" ? accentHex : resolveHex(semantic, mode);
26
+ const hex = (semantic) => resolveSnapColorHex(semantic, { accentHex, appearance: mode });
27
27
  return { appearance: mode, accentName, accentHex, hex };
28
28
  }
29
29
  /** `#RRGGBB` + alpha → `rgba(...)` for React Native styles. */
package/dist/schemas.d.ts CHANGED
@@ -89,10 +89,46 @@ export declare const payloadSchema: z.ZodObject<{
89
89
  }, z.core.$strip>], "type">;
90
90
  }, z.core.$strip>;
91
91
  export type SnapPayload = z.infer<typeof payloadSchema>;
92
+ /** JFS payload shape for POST minus deprecated `fid`; used for GET auth via payload header. */
93
+ export declare const getPayloadSchema: z.ZodObject<{
94
+ user: z.ZodObject<{
95
+ fid: z.ZodNumber;
96
+ }, z.core.$strip>;
97
+ timestamp: z.ZodNumber;
98
+ audience: z.ZodString;
99
+ surface: z.ZodDiscriminatedUnion<[z.ZodObject<{
100
+ type: z.ZodLiteral<"cast">;
101
+ cast: z.ZodObject<{
102
+ hash: z.ZodString;
103
+ author: z.ZodObject<{
104
+ fid: z.ZodNumber;
105
+ }, z.core.$strip>;
106
+ }, z.core.$strip>;
107
+ }, z.core.$strip>, z.ZodObject<{
108
+ type: z.ZodLiteral<"standalone">;
109
+ }, z.core.$strip>], "type">;
110
+ }, z.core.$strip>;
111
+ export type SnapGetPayload = z.infer<typeof getPayloadSchema>;
92
112
  export declare const ACTION_TYPE_GET: "get";
93
113
  export declare const ACTION_TYPE_POST: "post";
94
114
  declare const snapGetActionSchema: z.ZodObject<{
95
115
  type: z.ZodLiteral<"get">;
116
+ user: z.ZodOptional<z.ZodObject<{
117
+ fid: z.ZodNumber;
118
+ }, z.core.$strip>>;
119
+ timestamp: z.ZodOptional<z.ZodNumber>;
120
+ audience: z.ZodOptional<z.ZodString>;
121
+ surface: z.ZodOptional<z.ZodDiscriminatedUnion<[z.ZodObject<{
122
+ type: z.ZodLiteral<"cast">;
123
+ cast: z.ZodObject<{
124
+ hash: z.ZodString;
125
+ author: z.ZodObject<{
126
+ fid: z.ZodNumber;
127
+ }, z.core.$strip>;
128
+ }, z.core.$strip>;
129
+ }, z.core.$strip>, z.ZodObject<{
130
+ type: z.ZodLiteral<"standalone">;
131
+ }, z.core.$strip>], "type">>;
96
132
  }, z.core.$strip>;
97
133
  export type SnapGetAction = z.infer<typeof snapGetActionSchema>;
98
134
  declare const snapPostActionSchema: z.ZodObject<{
@@ -119,6 +155,22 @@ declare const snapPostActionSchema: z.ZodObject<{
119
155
  export type SnapPostAction = z.infer<typeof snapPostActionSchema>;
120
156
  export declare const snapActionSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
121
157
  type: z.ZodLiteral<"get">;
158
+ user: z.ZodOptional<z.ZodObject<{
159
+ fid: z.ZodNumber;
160
+ }, z.core.$strip>>;
161
+ timestamp: z.ZodOptional<z.ZodNumber>;
162
+ audience: z.ZodOptional<z.ZodString>;
163
+ surface: z.ZodOptional<z.ZodDiscriminatedUnion<[z.ZodObject<{
164
+ type: z.ZodLiteral<"cast">;
165
+ cast: z.ZodObject<{
166
+ hash: z.ZodString;
167
+ author: z.ZodObject<{
168
+ fid: z.ZodNumber;
169
+ }, z.core.$strip>;
170
+ }, z.core.$strip>;
171
+ }, z.core.$strip>, z.ZodObject<{
172
+ type: z.ZodLiteral<"standalone">;
173
+ }, z.core.$strip>], "type">>;
122
174
  }, z.core.$strip>, z.ZodObject<{
123
175
  fid: z.ZodOptional<z.ZodNumber>;
124
176
  inputs: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean, z.ZodArray<z.ZodString>]>>>;
package/dist/schemas.js CHANGED
@@ -47,22 +47,28 @@ const surfaceSchema = z.discriminatedUnion("type", [
47
47
  castSurfaceSchema,
48
48
  standaloneSurfaceSchema,
49
49
  ]);
50
+ const fidSchema = z.number().int().nonnegative();
51
+ const userSchema = z.object({ fid: fidSchema });
50
52
  export const payloadSchema = z
51
53
  .object({
52
- fid: z.number().int().nonnegative().optional(), // deprecated in favor of user.fid
54
+ fid: fidSchema.optional(), // deprecated in favor of user.fid
53
55
  inputs: z.record(z.string(), postInputValueSchema).default({}),
54
56
  timestamp: z.number().int(),
55
57
  audience: z.string(),
56
- user: z.object({
57
- fid: z.number().int().nonnegative(),
58
- }),
58
+ user: userSchema,
59
59
  surface: surfaceSchema,
60
60
  })
61
61
  .strip();
62
+ /** JFS payload shape for POST minus deprecated `fid`; used for GET auth via payload header. */
63
+ export const getPayloadSchema = payloadSchema.omit({ inputs: true, fid: true });
62
64
  export const ACTION_TYPE_GET = "get";
63
65
  export const ACTION_TYPE_POST = "post";
64
66
  const snapGetActionSchema = z.object({
65
67
  type: z.literal(ACTION_TYPE_GET),
68
+ user: userSchema.optional(),
69
+ timestamp: z.number().int().optional(),
70
+ audience: z.string().optional(),
71
+ surface: surfaceSchema.optional(),
66
72
  });
67
73
  const snapPostActionSchema = payloadSchema.extend({
68
74
  type: z.literal(ACTION_TYPE_POST),
@@ -1,3 +1,4 @@
1
- export { verifyJFSRequestBody, decodePayload, encodePayload } from "./verify.js";
1
+ export { verifyJFS as verifyJFSRequestBody, // deprecated alias. drop in v3
2
+ parseJfs, verifyJFS, decodePayload, encodePayload, } from "./verify.js";
2
3
  export { DEFAULT_SNAP_HUB_HTTP_BASE_URL, getActiveEd25519SignerKeysFromHubHttp, } from "./hubs.js";
3
4
  export { parseRequest, type ParseRequestError, type ParseRequestOptions, type ParseRequestResult, } from "./parseRequest.js";
@@ -1,3 +1,4 @@
1
- export { verifyJFSRequestBody, decodePayload, encodePayload } from "./verify.js";
1
+ export { verifyJFS as verifyJFSRequestBody, // deprecated alias. drop in v3
2
+ parseJfs, verifyJFS, decodePayload, encodePayload, } from "./verify.js";
2
3
  export { DEFAULT_SNAP_HUB_HTTP_BASE_URL, getActiveEd25519SignerKeysFromHubHttp, } from "./hubs.js";
3
4
  export { parseRequest, } from "./parseRequest.js";
@@ -1,5 +1,5 @@
1
- import { type SnapAction } from "../schemas.js";
2
1
  import { z } from "zod";
2
+ import { type SnapAction } from "../schemas.js";
3
3
  export type ParseRequestError = {
4
4
  type: "method_not_allowed";
5
5
  message: string;
@@ -47,7 +47,8 @@ export type ParseRequestResult = {
47
47
  };
48
48
  /**
49
49
  * Parse and validate Farcaster snap requests:
50
- * - `GET` is allowed for first-page loads and returns `{ type: "get" }`.
51
- * - `POST`: the body must be JSON in JFS form (`header` / `payload` / `signature`) even if JFS verification is skipped.
50
+ * - `GET`: returns `{ type: "get" }`, or optional viewer fields when `X-Snap-Payload`
51
+ * carries a JFS compact string whose decoded payload validates against {@link getPayloadSchema}.
52
+ * - `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.
52
53
  */
53
54
  export declare function parseRequest(request: Request, options?: ParseRequestOptions): Promise<ParseRequestResult>;
@@ -1,85 +1,124 @@
1
- import { ACTION_TYPE_GET, ACTION_TYPE_POST, payloadSchema, } from "../schemas.js";
2
- import { decodePayload, verifyJFSRequestBody } from "./verify.js";
3
- import { z } from "zod";
1
+ import { ACTION_TYPE_GET, ACTION_TYPE_POST, getPayloadSchema, payloadSchema, } from "../schemas.js";
2
+ import { decodePayload, parseJfs, verifyJFS } from "./verify.js";
3
+ import { SNAP_PAYLOAD_HEADER } from "../constants.js";
4
4
  const DEFAULT_SNAP_POST_MAX_SKEW_SECONDS = 300;
5
- const requestBodySchema = z.object({
6
- header: z.string(),
7
- payload: z.string(),
8
- signature: z.string(),
9
- });
10
5
  /**
11
6
  * Parse and validate Farcaster snap requests:
12
- * - `GET` is allowed for first-page loads and returns `{ type: "get" }`.
13
- * - `POST`: the body must be JSON in JFS form (`header` / `payload` / `signature`) even if JFS verification is skipped.
7
+ * - `GET`: returns `{ type: "get" }`, or optional viewer fields when `X-Snap-Payload`
8
+ * carries a JFS compact string whose decoded payload validates against {@link getPayloadSchema}.
9
+ * - `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.
14
10
  */
15
11
  export async function parseRequest(request, options = {}) {
16
- if (!["GET", "POST"].includes(request.method)) {
17
- return {
18
- success: false,
19
- error: {
20
- type: "method_not_allowed",
21
- message: `expected POST, received ${request.method}`,
22
- },
23
- };
24
- }
25
12
  if (request.method === "GET") {
26
- return {
27
- success: true,
28
- action: { type: ACTION_TYPE_GET },
29
- };
13
+ return await parseGetRequest(request, options);
30
14
  }
31
- const maxSkew = options.maxSkewSeconds ?? DEFAULT_SNAP_POST_MAX_SKEW_SECONDS;
32
- const nowSec = Math.floor(Date.now() / 1000);
33
- const text = await request.text();
34
- let jsonBody;
35
- try {
36
- jsonBody = JSON.parse(text);
15
+ if (request.method === "POST") {
16
+ return await parsePostRequest(request, options);
37
17
  }
38
- catch {
18
+ return {
19
+ success: false,
20
+ error: {
21
+ type: "method_not_allowed",
22
+ message: `expected GET or POST, received ${request.method}`,
23
+ },
24
+ };
25
+ }
26
+ async function parseGetRequest(request, options) {
27
+ const compactHeader = request.headers.get(SNAP_PAYLOAD_HEADER)?.trim();
28
+ if (!compactHeader) {
29
+ return { success: true, action: { type: ACTION_TYPE_GET } };
30
+ }
31
+ const result = await validateJfsPayload({
32
+ jfsText: compactHeader,
33
+ schema: getPayloadSchema,
34
+ request,
35
+ options,
36
+ invalidJsonMessage: `${SNAP_PAYLOAD_HEADER} must be a valid JFS compact string`,
37
+ });
38
+ if (!result.ok) {
39
+ return { success: false, error: result.error };
40
+ }
41
+ return {
42
+ success: true,
43
+ action: { type: ACTION_TYPE_GET, ...result.payload },
44
+ };
45
+ }
46
+ async function parsePostRequest(request, options) {
47
+ const result = await validateJfsPayload({
48
+ jfsText: await request.text(),
49
+ schema: payloadSchema,
50
+ request,
51
+ options,
52
+ });
53
+ if (!result.ok) {
54
+ return { success: false, error: result.error };
55
+ }
56
+ const payload = result.payload;
57
+ if (payload.fid !== undefined && payload.fid !== payload.user.fid) {
39
58
  return {
40
59
  success: false,
41
60
  error: {
42
- type: "invalid_json",
43
- message: "request body is not valid JSON",
61
+ type: "fid_mismatch",
62
+ message: `fid "${payload.fid}" does not match user.fid "${payload.user.fid}"`,
44
63
  },
45
64
  };
46
65
  }
47
- const parsed = requestBodySchema.safeParse(jsonBody);
48
- if (!parsed.success) {
66
+ return {
67
+ success: true,
68
+ action: { type: ACTION_TYPE_POST, ...payload },
69
+ };
70
+ }
71
+ /**
72
+ * Shared pipeline for authenticated snap requests: parse the JFS envelope,
73
+ * decode and schema-validate the payload, optionally verify the JFS signature
74
+ * against an active hub signer (matching `user.fid`), then check timestamp
75
+ * skew and that `audience` matches the request origin.
76
+ *
77
+ * Both GET (payload header) and POST (request body) feed into this.
78
+ */
79
+ async function validateJfsPayload({ jfsText, schema, request, options, invalidJsonMessage, }) {
80
+ const parsed = parseJfs(jfsText);
81
+ if (!parsed.ok) {
49
82
  return {
50
- success: false,
51
- error: { type: "invalid_json", message: parsed.error.message },
83
+ ok: false,
84
+ error: {
85
+ type: "invalid_json",
86
+ message: invalidJsonMessage ?? parsed.error,
87
+ },
52
88
  };
53
89
  }
54
- const payloadParsed = payloadSchema.safeParse(decodePayload(parsed.data.payload));
90
+ const jfs = parsed.jfs;
91
+ const payloadParsed = schema.safeParse(decodePayload(jfs.payload));
55
92
  if (!payloadParsed.success) {
56
93
  return {
57
- success: false,
94
+ ok: false,
58
95
  error: { type: "validation", issues: payloadParsed.error.issues },
59
96
  };
60
97
  }
61
- const body = payloadParsed.data;
98
+ const payload = payloadParsed.data;
62
99
  if (!options.skipJFSVerification) {
63
- const jfs = await verifyJFSRequestBody(parsed.data);
64
- if (!jfs.valid) {
100
+ const verified = await verifyJFS(jfs);
101
+ if (!verified.valid) {
65
102
  return {
66
- success: false,
67
- error: { type: "signature", message: jfs.error.message },
103
+ ok: false,
104
+ error: { type: "signature", message: verified.error.message },
68
105
  };
69
106
  }
70
- if (jfs.signingUserFid !== body.user.fid) {
107
+ if (verified.signingUserFid !== payload.user.fid) {
71
108
  return {
72
- success: false,
109
+ ok: false,
73
110
  error: {
74
111
  type: "fid_mismatch",
75
- message: `JFS header fid "${jfs.signingUserFid}" does not match user.fid "${body.user.fid}"`,
112
+ message: `JFS header fid "${verified.signingUserFid}" does not match user.fid "${payload.user.fid}"`,
76
113
  },
77
114
  };
78
115
  }
79
116
  }
80
- if (Math.abs(nowSec - body.timestamp) > maxSkew) {
117
+ const maxSkew = options.maxSkewSeconds ?? DEFAULT_SNAP_POST_MAX_SKEW_SECONDS;
118
+ const nowSec = Math.floor(Date.now() / 1000);
119
+ if (Math.abs(nowSec - payload.timestamp) > maxSkew) {
81
120
  return {
82
- success: false,
121
+ ok: false,
83
122
  error: {
84
123
  type: "replay",
85
124
  message: `timestamp outside allowed skew of ${maxSkew}s`,
@@ -100,29 +139,14 @@ export async function parseRequest(request, options = {}) {
100
139
  // do nothing
101
140
  }
102
141
  }
103
- if (expectedOrigin !== undefined && body.audience !== expectedOrigin) {
142
+ if (expectedOrigin !== undefined && payload.audience !== expectedOrigin) {
104
143
  return {
105
- success: false,
144
+ ok: false,
106
145
  error: {
107
146
  type: "origin_mismatch",
108
- message: `payload audience "${body.audience}" does not match expected origin "${expectedOrigin}"`,
109
- },
110
- };
111
- }
112
- if (body.fid !== undefined && body.fid !== body.user.fid) {
113
- return {
114
- success: false,
115
- error: {
116
- type: "fid_mismatch",
117
- message: `fid "${body.fid}" does not match user.fid "${body.user.fid}"`,
147
+ message: `payload audience "${payload.audience}" does not match expected origin "${expectedOrigin}"`,
118
148
  },
119
149
  };
120
150
  }
121
- return {
122
- success: true,
123
- action: {
124
- type: ACTION_TYPE_POST,
125
- ...body,
126
- },
127
- };
151
+ return { ok: true, payload };
128
152
  }
@@ -1,8 +1,15 @@
1
- export declare function verifyJFSRequestBody<TPayload>(requestBody: {
2
- header: string;
3
- payload: string;
4
- signature: string;
5
- }, options?: {
1
+ import { type JsonFarcasterSignature } from "@farcaster/jfs";
2
+ /**
3
+ * Parse a JFS object or string into normalized form (see {@link toJsonFarcasterSignature} / `uncompact`).
4
+ */
5
+ export declare function parseJfs(text: string): {
6
+ ok: true;
7
+ jfs: JsonFarcasterSignature;
8
+ } | {
9
+ ok: false;
10
+ error: string;
11
+ };
12
+ export declare function verifyJFS<TPayload>(jfs: JsonFarcasterSignature, options?: {
6
13
  hubHttpBaseUrl?: string;
7
14
  }): Promise<{
8
15
  valid: false;
@@ -1,33 +1,40 @@
1
- import { compact, decode, decodePayload as jfsDecodePayload, encodePayload as jfsEncodePayload, verify, } from "@farcaster/jfs";
1
+ import { decode, decodePayload as jfsDecodePayload, encodePayload as jfsEncodePayload, toJsonFarcasterSignature, verify, } from "@farcaster/jfs";
2
2
  import { hexToBytes } from "viem";
3
3
  import { DEFAULT_SNAP_HUB_HTTP_BASE_URL, getActiveEd25519SignerKeysFromHubHttp, } from "./hubs.js";
4
- export async function verifyJFSRequestBody(requestBody, options = {}) {
5
- let compactJfs;
4
+ /**
5
+ * Parse a JFS object or string into normalized form (see {@link toJsonFarcasterSignature} / `uncompact`).
6
+ */
7
+ export function parseJfs(text) {
8
+ const trimmed = text.trim();
9
+ let jfsFromJson;
6
10
  try {
7
- compactJfs = compact({
8
- header: requestBody.header,
9
- payload: requestBody.payload,
10
- signature: requestBody.signature,
11
- });
11
+ jfsFromJson = JSON.parse(trimmed);
12
12
  }
13
- catch (error) {
14
- return {
15
- valid: false,
16
- error: error instanceof Error ? error : new Error(String(error)),
17
- };
13
+ catch {
14
+ jfsFromJson = undefined;
18
15
  }
19
- let decoded;
20
- try {
21
- decoded = decode(compactJfs);
16
+ if (jfsFromJson !== undefined && isJfsObject(jfsFromJson)) {
17
+ return { ok: true, jfs: jfsFromJson };
22
18
  }
23
- catch (error) {
19
+ const jfsFromString = tryUncompactJfsString(trimmed);
20
+ if (jfsFromString) {
21
+ return { ok: true, jfs: jfsFromString };
22
+ }
23
+ return {
24
+ ok: false,
25
+ error: "invalid JFS envelope: must be JSON with header, payload, and signature fields, or a JFS compact string (three dot-separated segments)",
26
+ };
27
+ }
28
+ export async function verifyJFS(jfs, options = {}) {
29
+ const decoded = tryDecodeJfs(jfs);
30
+ if (!decoded) {
24
31
  return {
25
32
  valid: false,
26
- error: error instanceof Error ? error : new Error(String(error)),
33
+ error: new Error("invalid JFS envelope"),
27
34
  };
28
35
  }
29
36
  try {
30
- await verify({ data: compactJfs, strict: true, keyTypes: ["app_key"] });
37
+ await verify({ data: jfs, strict: true, keyTypes: ["app_key"] });
31
38
  }
32
39
  catch (error) {
33
40
  return {
@@ -35,6 +42,12 @@ export async function verifyJFSRequestBody(requestBody, options = {}) {
35
42
  error: error instanceof Error ? error : new Error(String(error)),
36
43
  };
37
44
  }
45
+ return hubVerifyDecodedPayload(decoded, options);
46
+ }
47
+ /**
48
+ * Verify that the signing key for a JFS payload is an active signer for the FID.
49
+ */
50
+ async function hubVerifyDecodedPayload(decoded, options) {
38
51
  const { header, payload } = decoded;
39
52
  const keys = await getActiveEd25519SignerKeysFromHubHttp(options.hubHttpBaseUrl ?? DEFAULT_SNAP_HUB_HTTP_BASE_URL, header.fid);
40
53
  if (!keys.ok) {
@@ -78,6 +91,41 @@ export function decodePayload(payload) {
78
91
  export function encodePayload(payload) {
79
92
  return jfsEncodePayload(payload);
80
93
  }
94
+ /**
95
+ * Normalize a compact JFS string to `{ header, payload, signature }` using
96
+ * `@farcaster/jfs` {@link toJsonFarcasterSignature} (which delegates to `uncompact` for strings).
97
+ * Returns null if the string is malformed.
98
+ */
99
+ function tryUncompactJfsString(value) {
100
+ try {
101
+ return toJsonFarcasterSignature(value.trim());
102
+ }
103
+ catch {
104
+ return null;
105
+ }
106
+ }
107
+ /**
108
+ * Fully decode a JFS envelope or compact string via `@farcaster/jfs` {@link decode}:
109
+ * parsed header object, parsed payload, signature bytes.
110
+ */
111
+ function tryDecodeJfs(input) {
112
+ try {
113
+ return decode(input);
114
+ }
115
+ catch {
116
+ return null;
117
+ }
118
+ }
119
+ function isJfsObject(v) {
120
+ return (v !== null &&
121
+ typeof v === "object" &&
122
+ "header" in v &&
123
+ typeof v.header === "string" &&
124
+ "payload" in v &&
125
+ typeof v.payload === "string" &&
126
+ "signature" in v &&
127
+ typeof v.signature === "string");
128
+ }
81
129
  function bytesEqual(a, b) {
82
130
  if (a.length !== b.length)
83
131
  return false;
@@ -380,7 +380,7 @@ export declare const snapJsonRenderCatalog: import("@json-render/core").Catalog<
380
380
  cells: z.ZodArray<z.ZodObject<{
381
381
  row: z.ZodNumber;
382
382
  col: z.ZodNumber;
383
- color: z.ZodOptional<z.ZodEnum<{
383
+ color: z.ZodOptional<z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodUnion<readonly [z.ZodEnum<{
384
384
  gray: "gray";
385
385
  blue: "blue";
386
386
  red: "red";
@@ -389,7 +389,7 @@ export declare const snapJsonRenderCatalog: import("@json-render/core").Catalog<
389
389
  teal: "teal";
390
390
  purple: "purple";
391
391
  pink: "pink";
392
- }>>;
392
+ }>, z.ZodString]>>>;
393
393
  content: z.ZodOptional<z.ZodString>;
394
394
  }, z.core.$strip>>;
395
395
  gap: z.ZodOptional<z.ZodEnum<{
@@ -90,7 +90,7 @@ export const snapJsonRenderCatalog = defineCatalog(snapJsonRenderSchema, {
90
90
  },
91
91
  cell_grid: {
92
92
  props: cellGridProps,
93
- description: "Cell grid — sparse colored cells on a rows×cols grid. Two interaction modes: leave select 'off' and bind on.press to fire an action per cell press (inputs[name] is the pressed 'row,col' before the action runs); or set select 'single'/'multiple' for press-to-select with a visual ring (no auto-fire — pair with a separate submit button). on.press is ignored when select is on.",
93
+ description: "Cell grid — sparse colored cells on a rows×cols grid. Cell color is a palette name or literal #rrggbb hex (hex ignores page accent). Two interaction modes: leave select 'off' and bind on.press to fire an action per cell press (inputs[name] is the pressed 'row,col' before the action runs); or set select 'single'/'multiple' for press-to-select with a visual ring (no auto-fire — pair with a separate submit button). on.press is ignored when select is on.",
94
94
  },
95
95
  },
96
96
  actions: {