@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
@@ -1,9 +1,11 @@
1
1
  import {
2
- compact,
3
2
  decode,
4
3
  decodePayload as jfsDecodePayload,
5
4
  encodePayload as jfsEncodePayload,
5
+ toJsonFarcasterSignature,
6
6
  verify,
7
+ type JsonFarcasterSignature,
8
+ type DecodedJsonFarcasterSignature,
7
9
  } from "@farcaster/jfs";
8
10
  import { hexToBytes, type Hex } from "viem";
9
11
  import {
@@ -11,12 +13,39 @@ import {
11
13
  getActiveEd25519SignerKeysFromHubHttp,
12
14
  } from "./hubs";
13
15
 
14
- export async function verifyJFSRequestBody<TPayload>(
15
- requestBody: {
16
- header: string;
17
- payload: string;
18
- signature: string;
19
- },
16
+ /**
17
+ * Parse a JFS object or string into normalized form (see {@link toJsonFarcasterSignature} / `uncompact`).
18
+ */
19
+ export function parseJfs(
20
+ text: string,
21
+ ): { ok: true; jfs: JsonFarcasterSignature } | { ok: false; error: string } {
22
+ const trimmed = text.trim();
23
+
24
+ let jfsFromJson: unknown;
25
+ try {
26
+ jfsFromJson = JSON.parse(trimmed);
27
+ } catch {
28
+ jfsFromJson = undefined;
29
+ }
30
+
31
+ if (jfsFromJson !== undefined && isJfsObject(jfsFromJson)) {
32
+ return { ok: true, jfs: jfsFromJson };
33
+ }
34
+
35
+ const jfsFromString = tryUncompactJfsString(trimmed);
36
+ if (jfsFromString) {
37
+ return { ok: true, jfs: jfsFromString };
38
+ }
39
+
40
+ return {
41
+ ok: false,
42
+ error:
43
+ "invalid JFS envelope: must be JSON with header, payload, and signature fields, or a JFS compact string (three dot-separated segments)",
44
+ };
45
+ }
46
+
47
+ export async function verifyJFS<TPayload>(
48
+ jfs: JsonFarcasterSignature,
20
49
  options: {
21
50
  hubHttpBaseUrl?: string;
22
51
  } = {},
@@ -31,23 +60,16 @@ export async function verifyJFSRequestBody<TPayload>(
31
60
  data: TPayload;
32
61
  }
33
62
  > {
34
- let compactJfs: string;
35
- try {
36
- compactJfs = compact({
37
- header: requestBody.header,
38
- payload: requestBody.payload,
39
- signature: requestBody.signature,
40
- });
41
- } catch (error) {
63
+ const decoded = tryDecodeJfs<TPayload>(jfs);
64
+ if (!decoded) {
42
65
  return {
43
66
  valid: false,
44
- error: error instanceof Error ? error : new Error(String(error)),
67
+ error: new Error("invalid JFS envelope"),
45
68
  };
46
69
  }
47
70
 
48
- let decoded: ReturnType<typeof decode<TPayload>>;
49
71
  try {
50
- decoded = decode<TPayload>(compactJfs);
72
+ await verify({ data: jfs, strict: true, keyTypes: ["app_key"] });
51
73
  } catch (error) {
52
74
  return {
53
75
  valid: false,
@@ -55,15 +77,26 @@ export async function verifyJFSRequestBody<TPayload>(
55
77
  };
56
78
  }
57
79
 
58
- try {
59
- await verify({ data: compactJfs, strict: true, keyTypes: ["app_key"] });
60
- } catch (error) {
61
- return {
62
- valid: false,
63
- error: error instanceof Error ? error : new Error(String(error)),
64
- };
65
- }
80
+ return hubVerifyDecodedPayload(decoded, options);
81
+ }
66
82
 
83
+ /**
84
+ * Verify that the signing key for a JFS payload is an active signer for the FID.
85
+ */
86
+ async function hubVerifyDecodedPayload<TPayload>(
87
+ decoded: DecodedJsonFarcasterSignature<TPayload>,
88
+ options: { hubHttpBaseUrl?: string },
89
+ ): Promise<
90
+ | {
91
+ valid: false;
92
+ error: Error;
93
+ }
94
+ | {
95
+ valid: true;
96
+ signingUserFid: number;
97
+ data: TPayload;
98
+ }
99
+ > {
67
100
  const { header, payload } = decoded;
68
101
 
69
102
  const keys = await getActiveEd25519SignerKeysFromHubHttp(
@@ -121,6 +154,46 @@ export function encodePayload<TPayload>(payload: TPayload): string {
121
154
  return jfsEncodePayload(payload);
122
155
  }
123
156
 
157
+ /**
158
+ * Normalize a compact JFS string to `{ header, payload, signature }` using
159
+ * `@farcaster/jfs` {@link toJsonFarcasterSignature} (which delegates to `uncompact` for strings).
160
+ * Returns null if the string is malformed.
161
+ */
162
+ function tryUncompactJfsString(value: string): JsonFarcasterSignature | null {
163
+ try {
164
+ return toJsonFarcasterSignature(value.trim());
165
+ } catch {
166
+ return null;
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Fully decode a JFS envelope or compact string via `@farcaster/jfs` {@link decode}:
172
+ * parsed header object, parsed payload, signature bytes.
173
+ */
174
+ function tryDecodeJfs<TPayload>(
175
+ input: JsonFarcasterSignature | string,
176
+ ): DecodedJsonFarcasterSignature<TPayload> | null {
177
+ try {
178
+ return decode<TPayload>(input);
179
+ } catch {
180
+ return null;
181
+ }
182
+ }
183
+
184
+ function isJfsObject(v: unknown): v is JsonFarcasterSignature {
185
+ return (
186
+ v !== null &&
187
+ typeof v === "object" &&
188
+ "header" in v &&
189
+ typeof v.header === "string" &&
190
+ "payload" in v &&
191
+ typeof v.payload === "string" &&
192
+ "signature" in v &&
193
+ typeof v.signature === "string"
194
+ );
195
+ }
196
+
124
197
  function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
125
198
  if (a.length !== b.length) return false;
126
199
  let diff = 0;
@@ -25,3 +25,17 @@ export function horizontalChildrenAreAllButtons(children: ReactNode): boolean {
25
25
  export function countRenderableChildren(children: ReactNode): number {
26
26
  return Children.toArray(children).filter(isRenderableChild).length;
27
27
  }
28
+
29
+ /**
30
+ * Default horizontal stack gap as a t-shirt size, chosen by column count:
31
+ * 2 cols → lg, 3 cols → md, 4+ cols → sm. Unknown count falls back to md.
32
+ * Tighter gaps for denser layouts; authors can always override via the `gap` prop.
33
+ */
34
+ export function defaultHorizontalGapSize(
35
+ columnCount: number | undefined,
36
+ ): "sm" | "md" | "lg" {
37
+ if (columnCount === undefined) return "md";
38
+ if (columnCount <= 2) return "lg";
39
+ if (columnCount === 3) return "md";
40
+ return "sm";
41
+ }
package/src/ui/catalog.ts CHANGED
@@ -107,7 +107,7 @@ export const snapJsonRenderCatalog = defineCatalog(snapJsonRenderSchema, {
107
107
  cell_grid: {
108
108
  props: cellGridProps,
109
109
  description:
110
- "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.",
110
+ "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.",
111
111
  },
112
112
  },
113
113
  actions: {
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { PALETTE_COLOR_VALUES } from "../colors.js";
2
+ import { isSnapHexColorString, PALETTE_COLOR_VALUES } from "../colors.js";
3
3
  import {
4
4
  GRID_MIN_COLS,
5
5
  GRID_MAX_COLS,
@@ -8,11 +8,25 @@ import {
8
8
  GRID_GAP_VALUES,
9
9
  } from "../constants.js";
10
10
 
11
+ /** Palette name or `#rrggbb`; input is trimmed so palette and hex rules match runtime resolvers. */
12
+ const cellGridCellColorSchema = z.preprocess(
13
+ (v) => (typeof v === "string" ? v.trim() : v),
14
+ z.union([
15
+ z.enum(PALETTE_COLOR_VALUES),
16
+ z
17
+ .string()
18
+ .refine(isSnapHexColorString, {
19
+ message: "cell_grid cell hex color must be #rrggbb",
20
+ }),
21
+ ]),
22
+ );
23
+
11
24
  const cellGridCellSchema = z.object({
12
25
  row: z.number().int().nonnegative(),
13
26
  col: z.number().int().nonnegative(),
14
- color: z.enum(PALETTE_COLOR_VALUES).optional(),
27
+ color: cellGridCellColorSchema.optional(),
15
28
  content: z.string().optional(),
29
+ value: z.string().min(1).max(30).optional(),
16
30
  });
17
31
 
18
32
  export const cellGridProps = z
@@ -1,5 +1,5 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
- import { verifyJFSRequestBody } from "./server/verify";
2
+ import { verifyJFS } from "./server/verify";
3
3
 
4
4
  const validRequestBody = `{
5
5
  "header":"eyJmaWQiOjI2MTMxOSwidHlwZSI6ImFwcF9rZXkiLCJrZXkiOiIweGY0ZGQyNjczYTUzMjEwYzQ3ZGYzZjFmNTk0NjZlZTdhMTM3ZmQxOGQ5NTVjMmU2OGExMmQwOTE2MGE2NmMyMTUifQ",
@@ -11,7 +11,7 @@ const validRequestBody = `{
11
11
  const HUB_SIGNER_KEY_HEX =
12
12
  "f4dd2673a53210c47df3f1f59466ee7a137fd18d955c2e68a12d09160a66c215";
13
13
 
14
- describe("verifyJFSRequestBody", () => {
14
+ describe("verifyJFS", () => {
15
15
  beforeEach(() => {
16
16
  vi.stubGlobal(
17
17
  "fetch",
@@ -53,7 +53,7 @@ describe("verifyJFSRequestBody", () => {
53
53
  });
54
54
 
55
55
  it("accepts JSON JFS body and verifies crypto + hub signer list", async () => {
56
- const result = await verifyJFSRequestBody(JSON.parse(validRequestBody));
56
+ const result = await verifyJFS(JSON.parse(validRequestBody));
57
57
  expect(result.valid).toBe(true);
58
58
  if (result.valid) {
59
59
  expect(result.data).toEqual({