@farcaster/snap 2.1.1 → 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.
Files changed (68) hide show
  1. package/dist/colors.d.ts +10 -0
  2. package/dist/colors.js +22 -0
  3. package/dist/constants.d.ts +3 -1
  4. package/dist/constants.js +7 -2
  5. package/dist/index.d.ts +3 -3
  6. package/dist/index.js +3 -3
  7. package/dist/react/components/cell-grid.js +13 -14
  8. package/dist/react/components/image.js +5 -1
  9. package/dist/react/components/stack.js +53 -3
  10. package/dist/react/components/text.js +7 -1
  11. package/dist/react/hooks/use-snap-colors.js +2 -9
  12. package/dist/react/stack-direction-context.d.ts +7 -0
  13. package/dist/react/stack-direction-context.js +10 -0
  14. package/dist/react-native/components/snap-cell-grid.js +5 -7
  15. package/dist/react-native/components/snap-image.js +15 -2
  16. package/dist/react-native/components/snap-item.js +12 -2
  17. package/dist/react-native/components/snap-progress.js +8 -2
  18. package/dist/react-native/components/snap-stack.d.ts +1 -1
  19. package/dist/react-native/components/snap-stack.js +85 -10
  20. package/dist/react-native/components/snap-text.js +7 -2
  21. package/dist/react-native/stack-direction-context.d.ts +7 -0
  22. package/dist/react-native/stack-direction-context.js +9 -0
  23. package/dist/react-native/use-snap-palette.js +2 -2
  24. package/dist/schemas.d.ts +52 -0
  25. package/dist/schemas.js +10 -4
  26. package/dist/server/index.d.ts +2 -1
  27. package/dist/server/index.js +2 -1
  28. package/dist/server/parseRequest.d.ts +4 -3
  29. package/dist/server/parseRequest.js +91 -67
  30. package/dist/server/verify.d.ts +12 -5
  31. package/dist/server/verify.js +67 -19
  32. package/dist/stack-horizontal-utils.d.ts +4 -0
  33. package/dist/stack-horizontal-utils.js +29 -0
  34. package/dist/ui/catalog.d.ts +3 -2
  35. package/dist/ui/catalog.js +2 -2
  36. package/dist/ui/cell-grid.d.ts +2 -2
  37. package/dist/ui/cell-grid.js +11 -2
  38. package/dist/ui/stack.d.ts +1 -0
  39. package/dist/ui/stack.js +8 -0
  40. package/dist/verify.test.js +3 -3
  41. package/llms.txt +13 -2
  42. package/package.json +1 -1
  43. package/src/colors.ts +27 -0
  44. package/src/constants.ts +8 -2
  45. package/src/index.ts +6 -0
  46. package/src/react/components/cell-grid.tsx +17 -24
  47. package/src/react/components/image.tsx +8 -1
  48. package/src/react/components/stack.tsx +84 -11
  49. package/src/react/components/text.tsx +8 -1
  50. package/src/react/hooks/use-snap-colors.ts +3 -8
  51. package/src/react/stack-direction-context.tsx +27 -0
  52. package/src/react-native/components/snap-cell-grid.tsx +5 -11
  53. package/src/react-native/components/snap-image.tsx +17 -2
  54. package/src/react-native/components/snap-item.tsx +14 -2
  55. package/src/react-native/components/snap-progress.tsx +8 -2
  56. package/src/react-native/components/snap-stack.tsx +116 -14
  57. package/src/react-native/components/snap-text.tsx +7 -2
  58. package/src/react-native/stack-direction-context.tsx +25 -0
  59. package/src/react-native/use-snap-palette.ts +2 -1
  60. package/src/schemas.ts +14 -4
  61. package/src/server/index.ts +7 -1
  62. package/src/server/parseRequest.ts +117 -71
  63. package/src/server/verify.ts +99 -26
  64. package/src/stack-horizontal-utils.ts +27 -0
  65. package/src/ui/catalog.ts +2 -2
  66. package/src/ui/cell-grid.ts +15 -2
  67. package/src/ui/stack.ts +8 -0
  68. 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;
@@ -0,0 +1,27 @@
1
+ import { Children, isValidElement, type ReactNode } from "react";
2
+
3
+ /**
4
+ * True when every rendered child comes from a catalog `button` element.
5
+ * json-render passes `{ element: { type, props, ... } }` into each catalog component.
6
+ */
7
+ function isRenderableChild(c: ReactNode): boolean {
8
+ if (c == null) return false;
9
+ if (typeof c === "boolean") return false;
10
+ return true;
11
+ }
12
+
13
+ export function horizontalChildrenAreAllButtons(children: ReactNode): boolean {
14
+ const items = Children.toArray(children).filter(isRenderableChild);
15
+ if (items.length === 0) return false;
16
+ for (const child of items) {
17
+ if (!isValidElement(child)) return false;
18
+ const typ = (child.props as { element?: { type?: unknown } }).element?.type;
19
+ if (typ !== "button") return false;
20
+ }
21
+ return true;
22
+ }
23
+
24
+ /** Direct snap catalog children under a stack (used for all-button grid column count). */
25
+ export function countRenderableChildren(children: ReactNode): number {
26
+ return Children.toArray(children).filter(isRenderableChild).length;
27
+ }
package/src/ui/catalog.ts CHANGED
@@ -92,7 +92,7 @@ export const snapJsonRenderCatalog = defineCatalog(snapJsonRenderSchema, {
92
92
  stack: {
93
93
  props: stackProps,
94
94
  description:
95
- "Layout container — direction: vertical (default) | horizontal. Children are element ids in order.",
95
+ "Layout container — direction: vertical (default) | horizontal. Children are element ids in order. Horizontal stacks use a single flex row so peers stay side-by-side and shrink with min-width 0. Nested stacks participate as flexible row peers. All-button horizontal stacks use an equal N-column grid where N is the number of buttons (1–6). Optional `columns` (`2`–`6`) forces an explicit equal grid for mixed children.",
96
96
  },
97
97
  text: {
98
98
  props: textProps,
@@ -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,10 +8,23 @@ 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(),
16
29
  });
17
30
 
package/src/ui/stack.ts CHANGED
@@ -8,6 +8,14 @@ export const stackProps = z.object({
8
8
  direction: z.enum(STACK_DIRECTIONS).optional(),
9
9
  gap: z.enum(STACK_GAPS).optional(),
10
10
  justify: z.enum(STACK_JUSTIFY).optional(),
11
+ /** Horizontal stacks only: fixed column grid (`2`–`6`). Prefer omitting this when children are stacks — they flex as row peers automatically. */
12
+ columns: z.union([
13
+ z.literal(2),
14
+ z.literal(3),
15
+ z.literal(4),
16
+ z.literal(5),
17
+ z.literal(6),
18
+ ]).optional(),
11
19
  });
12
20
 
13
21
  export type StackProps = z.infer<typeof stackProps>;
@@ -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({