@farcaster/snap 1.5.0 → 1.5.2

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.
@@ -42,6 +42,7 @@ export declare const LIST_STYLE_VALUES: readonly ["ordered", "unordered", "plain
42
42
  export declare const DEFAULT_LIST_STYLE: "ordered";
43
43
  export declare const GRID_CELL_SIZE_VALUES: readonly ["auto", "square"];
44
44
  export declare const GRID_GAP_VALUES: readonly ["none", "small", "medium"];
45
+ export declare const DEFAULT_GRID_GAP: "small";
45
46
  export declare const BUTTON_GROUP_STYLE: {
46
47
  readonly row: "row";
47
48
  readonly stack: "stack";
@@ -52,9 +53,18 @@ export declare const BUTTON_ACTION: {
52
53
  readonly post: "post";
53
54
  readonly link: "link";
54
55
  readonly mini_app: "mini_app";
55
- readonly sdk: "sdk";
56
+ readonly client: "client";
56
57
  };
57
- export declare const BUTTON_ACTION_VALUES: readonly ["post", "link", "mini_app", "sdk"];
58
+ export declare const BUTTON_ACTION_VALUES: readonly ["post", "link", "mini_app", "client"];
59
+ export declare const CLIENT_ACTION: {
60
+ readonly view_cast: "view_cast";
61
+ readonly view_profile: "view_profile";
62
+ readonly compose_cast: "compose_cast";
63
+ readonly view_token: "view_token";
64
+ readonly send_token: "send_token";
65
+ readonly swap_token: "swap_token";
66
+ };
67
+ export declare const CLIENT_ACTION_VALUES: readonly ["view_cast", "view_profile", "compose_cast", "view_token", "send_token", "swap_token"];
58
68
  export declare const BUTTON_STYLE: {
59
69
  readonly primary: "primary";
60
70
  readonly secondary: "secondary";
package/dist/constants.js CHANGED
@@ -57,6 +57,7 @@ export const LIST_STYLE_VALUES = ["ordered", "unordered", "plain"];
57
57
  export const DEFAULT_LIST_STYLE = "ordered";
58
58
  export const GRID_CELL_SIZE_VALUES = ["auto", "square"];
59
59
  export const GRID_GAP_VALUES = ["none", "small", "medium"];
60
+ export const DEFAULT_GRID_GAP = "small";
60
61
  export const BUTTON_GROUP_STYLE = {
61
62
  row: "row",
62
63
  stack: "stack",
@@ -71,13 +72,29 @@ export const BUTTON_ACTION = {
71
72
  post: "post",
72
73
  link: "link",
73
74
  mini_app: "mini_app",
74
- sdk: "sdk",
75
+ client: "client",
75
76
  };
76
77
  export const BUTTON_ACTION_VALUES = [
77
78
  BUTTON_ACTION.post,
78
79
  BUTTON_ACTION.link,
79
80
  BUTTON_ACTION.mini_app,
80
- BUTTON_ACTION.sdk,
81
+ BUTTON_ACTION.client,
82
+ ];
83
+ export const CLIENT_ACTION = {
84
+ view_cast: "view_cast",
85
+ view_profile: "view_profile",
86
+ compose_cast: "compose_cast",
87
+ view_token: "view_token",
88
+ send_token: "send_token",
89
+ swap_token: "swap_token",
90
+ };
91
+ export const CLIENT_ACTION_VALUES = [
92
+ CLIENT_ACTION.view_cast,
93
+ CLIENT_ACTION.view_profile,
94
+ CLIENT_ACTION.compose_cast,
95
+ CLIENT_ACTION.view_token,
96
+ CLIENT_ACTION.send_token,
97
+ CLIENT_ACTION.swap_token,
81
98
  ];
82
99
  export const BUTTON_STYLE = {
83
100
  primary: "primary",
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
- export { POST_GRID_TAP_KEY, PAGE_ROOT_TYPE, ELEMENT_TYPE, MEDIA_TYPE, DEFAULT_LIST_STYLE, DEFAULT_SLIDER_STEP, } from "./constants.js";
1
+ export { POST_GRID_TAP_KEY, PAGE_ROOT_TYPE, ELEMENT_TYPE, MEDIA_TYPE, DEFAULT_LIST_STYLE, DEFAULT_SLIDER_STEP, CLIENT_ACTION, CLIENT_ACTION_VALUES, } from "./constants.js";
2
2
  export { DEFAULT_THEME_ACCENT, PALETTE_COLOR, PALETTE_COLOR_ACCENT, PALETTE_COLOR_VALUES, PALETTE_LIGHT_HEX, PALETTE_DARK_HEX, type PaletteColor, } from "./colors.js";
3
- export { ACTION_TYPE_GET, ACTION_TYPE_POST, snapResponseSchema, firstPageResponseSchema, payloadSchema, createDefaultDataStore, type Button, type Element, type Elements, type GroupChildElement, type SnapAction, type SnapPageElementInput, type SnapContext, type SnapResponse, type SnapHandlerResult, type SnapFunction, type SnapPayload, type DataStoreValue, type SnapDataStore, type SnapDataStoreOperations, } from "./schemas.js";
3
+ export { ACTION_TYPE_GET, ACTION_TYPE_POST, snapResponseSchema, firstPageResponseSchema, payloadSchema, clientActionSchema, createDefaultDataStore, type Button, type Element, type Elements, type GroupChildElement, type ClientAction, type SnapAction, type SnapPageElementInput, type SnapContext, type SnapResponse, type SnapHandlerResult, type SnapFunction, type SnapPayload, type DataStoreValue, type SnapDataStore, type SnapDataStoreOperations, } from "./schemas.js";
4
4
  export { validateSnapResponse, validateFirstPageResponse, type ValidationResult, } from "./validator.js";
5
+ export { type Middleware, useMiddleware } from "./middleware.js";
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
- export { POST_GRID_TAP_KEY, PAGE_ROOT_TYPE, ELEMENT_TYPE, MEDIA_TYPE, DEFAULT_LIST_STYLE, DEFAULT_SLIDER_STEP, } from "./constants.js";
1
+ export { POST_GRID_TAP_KEY, PAGE_ROOT_TYPE, ELEMENT_TYPE, MEDIA_TYPE, DEFAULT_LIST_STYLE, DEFAULT_SLIDER_STEP, CLIENT_ACTION, CLIENT_ACTION_VALUES, } from "./constants.js";
2
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, firstPageResponseSchema, payloadSchema, createDefaultDataStore, } from "./schemas.js";
3
+ export { ACTION_TYPE_GET, ACTION_TYPE_POST, snapResponseSchema, firstPageResponseSchema, payloadSchema, clientActionSchema, createDefaultDataStore, } from "./schemas.js";
4
4
  export { validateSnapResponse, validateFirstPageResponse, } from "./validator.js";
5
+ export { useMiddleware } from "./middleware.js";
@@ -0,0 +1,3 @@
1
+ import { SnapFunction } from "./schemas.js";
2
+ export type Middleware = (fn: SnapFunction) => SnapFunction;
3
+ export declare function useMiddleware(fn: SnapFunction, middleware: Middleware[]): SnapFunction;
@@ -0,0 +1,3 @@
1
+ export function useMiddleware(fn, middleware) {
2
+ return middleware.reduce((acc, middleware) => middleware(acc), fn);
3
+ }
package/dist/schemas.d.ts CHANGED
@@ -1,13 +1,74 @@
1
1
  import { z } from "zod";
2
+ export declare const clientActionSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
3
+ type: z.ZodLiteral<"view_cast">;
4
+ hash: z.ZodString;
5
+ }, z.core.$strict>, z.ZodObject<{
6
+ type: z.ZodLiteral<"view_profile">;
7
+ fid: z.ZodNumber;
8
+ }, z.core.$strict>, z.ZodObject<{
9
+ type: z.ZodLiteral<"compose_cast">;
10
+ text: z.ZodOptional<z.ZodString>;
11
+ embeds: z.ZodOptional<z.ZodArray<z.ZodString>>;
12
+ parent: z.ZodOptional<z.ZodObject<{
13
+ type: z.ZodLiteral<"cast">;
14
+ hash: z.ZodString;
15
+ }, z.core.$strict>>;
16
+ channelKey: z.ZodOptional<z.ZodString>;
17
+ }, z.core.$strict>, z.ZodObject<{
18
+ type: z.ZodLiteral<"view_token">;
19
+ token: z.ZodString;
20
+ }, z.core.$strict>, z.ZodObject<{
21
+ type: z.ZodLiteral<"send_token">;
22
+ token: z.ZodOptional<z.ZodString>;
23
+ amount: z.ZodOptional<z.ZodString>;
24
+ recipientFid: z.ZodOptional<z.ZodNumber>;
25
+ recipientAddress: z.ZodOptional<z.ZodString>;
26
+ }, z.core.$strict>, z.ZodObject<{
27
+ type: z.ZodLiteral<"swap_token">;
28
+ sellToken: z.ZodOptional<z.ZodString>;
29
+ buyToken: z.ZodOptional<z.ZodString>;
30
+ sellAmount: z.ZodOptional<z.ZodString>;
31
+ }, z.core.$strict>], "type">;
32
+ export type ClientAction = z.infer<typeof clientActionSchema>;
2
33
  declare const buttonSchema: z.ZodObject<{
3
34
  label: z.ZodString;
4
35
  action: z.ZodEnum<{
5
36
  post: "post";
6
37
  link: "link";
7
38
  mini_app: "mini_app";
8
- sdk: "sdk";
39
+ client: "client";
9
40
  }>;
10
- target: z.ZodString;
41
+ target: z.ZodOptional<z.ZodString>;
42
+ client_action: z.ZodOptional<z.ZodDiscriminatedUnion<[z.ZodObject<{
43
+ type: z.ZodLiteral<"view_cast">;
44
+ hash: z.ZodString;
45
+ }, z.core.$strict>, z.ZodObject<{
46
+ type: z.ZodLiteral<"view_profile">;
47
+ fid: z.ZodNumber;
48
+ }, z.core.$strict>, z.ZodObject<{
49
+ type: z.ZodLiteral<"compose_cast">;
50
+ text: z.ZodOptional<z.ZodString>;
51
+ embeds: z.ZodOptional<z.ZodArray<z.ZodString>>;
52
+ parent: z.ZodOptional<z.ZodObject<{
53
+ type: z.ZodLiteral<"cast">;
54
+ hash: z.ZodString;
55
+ }, z.core.$strict>>;
56
+ channelKey: z.ZodOptional<z.ZodString>;
57
+ }, z.core.$strict>, z.ZodObject<{
58
+ type: z.ZodLiteral<"view_token">;
59
+ token: z.ZodString;
60
+ }, z.core.$strict>, z.ZodObject<{
61
+ type: z.ZodLiteral<"send_token">;
62
+ token: z.ZodOptional<z.ZodString>;
63
+ amount: z.ZodOptional<z.ZodString>;
64
+ recipientFid: z.ZodOptional<z.ZodNumber>;
65
+ recipientAddress: z.ZodOptional<z.ZodString>;
66
+ }, z.core.$strict>, z.ZodObject<{
67
+ type: z.ZodLiteral<"swap_token">;
68
+ sellToken: z.ZodOptional<z.ZodString>;
69
+ buyToken: z.ZodOptional<z.ZodString>;
70
+ sellAmount: z.ZodOptional<z.ZodString>;
71
+ }, z.core.$strict>], "type">>;
11
72
  style: z.ZodOptional<z.ZodEnum<{
12
73
  primary: "primary";
13
74
  secondary: "secondary";
@@ -230,7 +291,7 @@ declare const elementSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
230
291
  auto: "auto";
231
292
  square: "square";
232
293
  }>>;
233
- gap: z.ZodOptional<z.ZodEnum<{
294
+ gap: z.ZodDefault<z.ZodEnum<{
234
295
  small: "small";
235
296
  medium: "medium";
236
297
  none: "none";
@@ -546,7 +607,7 @@ declare const elementsSchema: z.ZodObject<{
546
607
  auto: "auto";
547
608
  square: "square";
548
609
  }>>;
549
- gap: z.ZodOptional<z.ZodEnum<{
610
+ gap: z.ZodDefault<z.ZodEnum<{
550
611
  small: "small";
551
612
  medium: "medium";
552
613
  none: "none";
@@ -885,7 +946,7 @@ export declare const snapResponseSchema: z.ZodObject<{
885
946
  auto: "auto";
886
947
  square: "square";
887
948
  }>>;
888
- gap: z.ZodOptional<z.ZodEnum<{
949
+ gap: z.ZodDefault<z.ZodEnum<{
889
950
  small: "small";
890
951
  medium: "medium";
891
952
  none: "none";
@@ -1129,9 +1190,39 @@ export declare const snapResponseSchema: z.ZodObject<{
1129
1190
  post: "post";
1130
1191
  link: "link";
1131
1192
  mini_app: "mini_app";
1132
- sdk: "sdk";
1193
+ client: "client";
1133
1194
  }>;
1134
- target: z.ZodString;
1195
+ target: z.ZodOptional<z.ZodString>;
1196
+ client_action: z.ZodOptional<z.ZodDiscriminatedUnion<[z.ZodObject<{
1197
+ type: z.ZodLiteral<"view_cast">;
1198
+ hash: z.ZodString;
1199
+ }, z.core.$strict>, z.ZodObject<{
1200
+ type: z.ZodLiteral<"view_profile">;
1201
+ fid: z.ZodNumber;
1202
+ }, z.core.$strict>, z.ZodObject<{
1203
+ type: z.ZodLiteral<"compose_cast">;
1204
+ text: z.ZodOptional<z.ZodString>;
1205
+ embeds: z.ZodOptional<z.ZodArray<z.ZodString>>;
1206
+ parent: z.ZodOptional<z.ZodObject<{
1207
+ type: z.ZodLiteral<"cast">;
1208
+ hash: z.ZodString;
1209
+ }, z.core.$strict>>;
1210
+ channelKey: z.ZodOptional<z.ZodString>;
1211
+ }, z.core.$strict>, z.ZodObject<{
1212
+ type: z.ZodLiteral<"view_token">;
1213
+ token: z.ZodString;
1214
+ }, z.core.$strict>, z.ZodObject<{
1215
+ type: z.ZodLiteral<"send_token">;
1216
+ token: z.ZodOptional<z.ZodString>;
1217
+ amount: z.ZodOptional<z.ZodString>;
1218
+ recipientFid: z.ZodOptional<z.ZodNumber>;
1219
+ recipientAddress: z.ZodOptional<z.ZodString>;
1220
+ }, z.core.$strict>, z.ZodObject<{
1221
+ type: z.ZodLiteral<"swap_token">;
1222
+ sellToken: z.ZodOptional<z.ZodString>;
1223
+ buyToken: z.ZodOptional<z.ZodString>;
1224
+ sellAmount: z.ZodOptional<z.ZodString>;
1225
+ }, z.core.$strict>], "type">>;
1135
1226
  style: z.ZodOptional<z.ZodEnum<{
1136
1227
  primary: "primary";
1137
1228
  secondary: "secondary";
@@ -1241,7 +1332,7 @@ export declare const firstPageResponseSchema: z.ZodObject<{
1241
1332
  auto: "auto";
1242
1333
  square: "square";
1243
1334
  }>>;
1244
- gap: z.ZodOptional<z.ZodEnum<{
1335
+ gap: z.ZodDefault<z.ZodEnum<{
1245
1336
  small: "small";
1246
1337
  medium: "medium";
1247
1338
  none: "none";
@@ -1485,9 +1576,39 @@ export declare const firstPageResponseSchema: z.ZodObject<{
1485
1576
  post: "post";
1486
1577
  link: "link";
1487
1578
  mini_app: "mini_app";
1488
- sdk: "sdk";
1579
+ client: "client";
1489
1580
  }>;
1490
- target: z.ZodString;
1581
+ target: z.ZodOptional<z.ZodString>;
1582
+ client_action: z.ZodOptional<z.ZodDiscriminatedUnion<[z.ZodObject<{
1583
+ type: z.ZodLiteral<"view_cast">;
1584
+ hash: z.ZodString;
1585
+ }, z.core.$strict>, z.ZodObject<{
1586
+ type: z.ZodLiteral<"view_profile">;
1587
+ fid: z.ZodNumber;
1588
+ }, z.core.$strict>, z.ZodObject<{
1589
+ type: z.ZodLiteral<"compose_cast">;
1590
+ text: z.ZodOptional<z.ZodString>;
1591
+ embeds: z.ZodOptional<z.ZodArray<z.ZodString>>;
1592
+ parent: z.ZodOptional<z.ZodObject<{
1593
+ type: z.ZodLiteral<"cast">;
1594
+ hash: z.ZodString;
1595
+ }, z.core.$strict>>;
1596
+ channelKey: z.ZodOptional<z.ZodString>;
1597
+ }, z.core.$strict>, z.ZodObject<{
1598
+ type: z.ZodLiteral<"view_token">;
1599
+ token: z.ZodString;
1600
+ }, z.core.$strict>, z.ZodObject<{
1601
+ type: z.ZodLiteral<"send_token">;
1602
+ token: z.ZodOptional<z.ZodString>;
1603
+ amount: z.ZodOptional<z.ZodString>;
1604
+ recipientFid: z.ZodOptional<z.ZodNumber>;
1605
+ recipientAddress: z.ZodOptional<z.ZodString>;
1606
+ }, z.core.$strict>, z.ZodObject<{
1607
+ type: z.ZodLiteral<"swap_token">;
1608
+ sellToken: z.ZodOptional<z.ZodString>;
1609
+ buyToken: z.ZodOptional<z.ZodString>;
1610
+ sellAmount: z.ZodOptional<z.ZodString>;
1611
+ }, z.core.$strict>], "type">>;
1491
1612
  style: z.ZodOptional<z.ZodEnum<{
1492
1613
  primary: "primary";
1493
1614
  secondary: "secondary";
package/dist/schemas.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { BUTTON_ACTION, BUTTON_ACTION_VALUES, BUTTON_GROUP_STYLE, BUTTON_GROUP_STYLE_VALUES, BUTTON_LAYOUT_VALUES, BUTTON_STYLE_VALUES, DEFAULT_BUTTON_LAYOUT, DEFAULT_LIST_STYLE, DEFAULT_SLIDER_STEP, EFFECT_VALUES, ELEMENT_TYPE, GRID_CELL_SIZE_VALUES, GRID_GAP_VALUES, GROUP_LAYOUT_VALUES, HEX_COLOR_6_RE, HTTP_PREFIX, HTTPS_PREFIX, IMAGE_ASPECT_VALUES, INTERACTIVE_ELEMENT_TYPES, LIMITS, LIST_STYLE_VALUES, MEDIA_ELEMENT_TYPES, PAGE_ROOT_TYPE, SLIDER_STEP_ALIGN_EPS, SPACER_SIZE, SPACER_SIZE_VALUES, SPEC_VERSION, TEXT_ALIGN_VALUES, TEXT_CONTENT_MAX, TEXT_STYLE, TEXT_STYLE_VALUES, } from "./constants.js";
2
+ import { BUTTON_ACTION, BUTTON_ACTION_VALUES, CLIENT_ACTION, BUTTON_GROUP_STYLE, BUTTON_GROUP_STYLE_VALUES, BUTTON_LAYOUT_VALUES, BUTTON_STYLE_VALUES, DEFAULT_BUTTON_LAYOUT, DEFAULT_GRID_GAP, DEFAULT_LIST_STYLE, DEFAULT_SLIDER_STEP, EFFECT_VALUES, ELEMENT_TYPE, GRID_CELL_SIZE_VALUES, GRID_GAP_VALUES, GROUP_LAYOUT_VALUES, HEX_COLOR_6_RE, HTTP_PREFIX, HTTPS_PREFIX, IMAGE_ASPECT_VALUES, INTERACTIVE_ELEMENT_TYPES, LIMITS, LIST_STYLE_VALUES, MEDIA_ELEMENT_TYPES, PAGE_ROOT_TYPE, SLIDER_STEP_ALIGN_EPS, SPACER_SIZE, SPACER_SIZE_VALUES, SPEC_VERSION, TEXT_ALIGN_VALUES, TEXT_CONTENT_MAX, TEXT_STYLE, TEXT_STYLE_VALUES, } from "./constants.js";
3
3
  import { BAR_CHART_COLOR_VALUES, DEFAULT_THEME_ACCENT, PALETTE_COLOR_VALUES, PROGRESS_COLOR_VALUES, } from "./colors.js";
4
4
  /**
5
5
  * post/link/mini_app targets must be HTTPS in production; allow HTTP only for
@@ -29,25 +29,8 @@ const themeSchema = z
29
29
  accent: themeAccentSchema.default(DEFAULT_THEME_ACCENT),
30
30
  })
31
31
  .strict();
32
- const httpsUrl = z.string().refine((s) => s.startsWith(HTTPS_PREFIX), {
33
- message: "URL must use HTTPS",
34
- });
35
- function hasAllowedMediaExtension(urlString, allowedExtensions) {
36
- try {
37
- const url = new URL(urlString);
38
- if (url.protocol !== "https:")
39
- return false;
40
- const lowerPathname = url.pathname.toLowerCase();
41
- return allowedExtensions.some((extension) => lowerPathname.endsWith(`.${extension}`));
42
- }
43
- catch {
44
- return false;
45
- }
46
- }
47
- const imageUrlSchema = z
48
- .string()
49
- .refine((s) => hasAllowedMediaExtension(s, ["jpg", "png", "gif", "webp"]), {
50
- message: "image URL must use HTTPS and end with a supported extension (.jpg, .png, .gif, .webp)",
32
+ const imageUrlSchema = z.string().refine((s) => s.startsWith(HTTPS_PREFIX), {
33
+ message: "image URL must use HTTPS",
51
34
  });
52
35
  const textAlignSchema = z.enum(TEXT_ALIGN_VALUES);
53
36
  const textElementSchema = z
@@ -153,7 +136,7 @@ const gridElementSchema = z
153
136
  rows: z.number().int().min(LIMITS.minGridRows).max(LIMITS.maxGridRows),
154
137
  cells: z.array(gridCellSchema),
155
138
  cellSize: z.enum(GRID_CELL_SIZE_VALUES).optional(),
156
- gap: z.enum(GRID_GAP_VALUES).optional(),
139
+ gap: z.enum(GRID_GAP_VALUES).default(DEFAULT_GRID_GAP),
157
140
  interactive: z.boolean().optional(),
158
141
  })
159
142
  .superRefine((val, ctx) => {
@@ -301,24 +284,134 @@ const barChartElementSchema = z
301
284
  });
302
285
  const buttonActionSchema = z.enum(BUTTON_ACTION_VALUES);
303
286
  const buttonStyleSchema = z.enum(BUTTON_STYLE_VALUES);
287
+ /* ------------------------------------------------------------------ */
288
+ /* Client action schemas */
289
+ /* ------------------------------------------------------------------ */
290
+ const viewCastClientActionSchema = z
291
+ .object({
292
+ type: z.literal(CLIENT_ACTION.view_cast),
293
+ hash: z.string().min(1),
294
+ })
295
+ .strict();
296
+ const viewProfileClientActionSchema = z
297
+ .object({
298
+ type: z.literal(CLIENT_ACTION.view_profile),
299
+ fid: z.number().int().nonnegative(),
300
+ })
301
+ .strict();
302
+ const composeCastClientActionSchema = z
303
+ .object({
304
+ type: z.literal(CLIENT_ACTION.compose_cast),
305
+ text: z.string().optional(),
306
+ embeds: z
307
+ .array(z.string())
308
+ .max(2, { message: "compose_cast embeds: max 2 URLs" })
309
+ .optional(),
310
+ parent: z
311
+ .object({
312
+ type: z.literal("cast"),
313
+ hash: z.string().min(1),
314
+ })
315
+ .strict()
316
+ .optional(),
317
+ channelKey: z.string().optional(),
318
+ })
319
+ .strict();
320
+ const viewTokenClientActionSchema = z
321
+ .object({
322
+ type: z.literal(CLIENT_ACTION.view_token),
323
+ /** CAIP-19 asset ID (e.g. "eip155:8453/erc20:0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913") */
324
+ token: z.string().min(1),
325
+ })
326
+ .strict();
327
+ const sendTokenClientActionSchema = z
328
+ .object({
329
+ type: z.literal(CLIENT_ACTION.send_token),
330
+ /** CAIP-19 asset ID */
331
+ token: z.string().optional(),
332
+ /** Amount in raw token units (e.g. "1000000" for 1 USDC) */
333
+ amount: z.string().optional(),
334
+ recipientFid: z.number().int().nonnegative().optional(),
335
+ recipientAddress: z.string().optional(),
336
+ })
337
+ .strict();
338
+ const swapTokenClientActionSchema = z
339
+ .object({
340
+ type: z.literal(CLIENT_ACTION.swap_token),
341
+ /** CAIP-19 asset ID to sell */
342
+ sellToken: z.string().optional(),
343
+ /** CAIP-19 asset ID to buy */
344
+ buyToken: z.string().optional(),
345
+ /** Amount in raw token units */
346
+ sellAmount: z.string().optional(),
347
+ })
348
+ .strict();
349
+ export const clientActionSchema = z.discriminatedUnion("type", [
350
+ viewCastClientActionSchema,
351
+ viewProfileClientActionSchema,
352
+ composeCastClientActionSchema,
353
+ viewTokenClientActionSchema,
354
+ sendTokenClientActionSchema,
355
+ swapTokenClientActionSchema,
356
+ ]);
357
+ /* ------------------------------------------------------------------ */
358
+ /* Button schema */
359
+ /* ------------------------------------------------------------------ */
304
360
  const buttonSchema = z
305
361
  .object({
306
362
  label: z.string().min(1).max(LIMITS.maxButtonLabelChars),
307
363
  action: buttonActionSchema,
308
- /** URL (HTTPS for post/link/mini_app) or SDK action id (e.g. cast:view:...) */
309
- target: z.string().min(1),
364
+ /** URL target for post/link/mini_app buttons */
365
+ target: z.string().min(1).optional(),
366
+ /** Structured client action for client buttons */
367
+ client_action: clientActionSchema.optional(),
310
368
  style: buttonStyleSchema.optional(),
311
369
  })
312
370
  .superRefine((val, ctx) => {
313
- if ((val.action === BUTTON_ACTION.post ||
314
- val.action === BUTTON_ACTION.link ||
315
- val.action === BUTTON_ACTION.mini_app) &&
316
- !isSecureOrLoopbackHttpButtonTarget(val.target)) {
317
- ctx.addIssue({
318
- code: "custom",
319
- message: `button target must use HTTPS (or http:// on localhost / 127.0.0.1 for development) for action "${val.action}" (received: ${val.target})`,
320
- path: ["target"],
321
- });
371
+ if (val.action === BUTTON_ACTION.client) {
372
+ // client buttons require client_action, must not have target
373
+ if (val.client_action === undefined) {
374
+ ctx.addIssue({
375
+ code: "custom",
376
+ message: `button with action "client" must include a "client_action" object`,
377
+ path: ["client_action"],
378
+ });
379
+ }
380
+ if (val.target !== undefined) {
381
+ ctx.addIssue({
382
+ code: "custom",
383
+ message: `button with action "client" must not include "target"`,
384
+ path: ["target"],
385
+ });
386
+ }
387
+ }
388
+ else {
389
+ // post/link/mini_app buttons require target, must not have client_action
390
+ if (val.target === undefined) {
391
+ ctx.addIssue({
392
+ code: "custom",
393
+ message: `button with action "${val.action}" must include a "target" URL`,
394
+ path: ["target"],
395
+ });
396
+ }
397
+ if (val.client_action !== undefined) {
398
+ ctx.addIssue({
399
+ code: "custom",
400
+ message: `button with action "${val.action}" must not include "client_action"`,
401
+ path: ["client_action"],
402
+ });
403
+ }
404
+ if (val.target &&
405
+ (val.action === BUTTON_ACTION.post ||
406
+ val.action === BUTTON_ACTION.link ||
407
+ val.action === BUTTON_ACTION.mini_app) &&
408
+ !isSecureOrLoopbackHttpButtonTarget(val.target)) {
409
+ ctx.addIssue({
410
+ code: "custom",
411
+ message: `button target must use HTTPS (or http:// on localhost / 127.0.0.1 for development) for action "${val.action}" (received: ${val.target})`,
412
+ path: ["target"],
413
+ });
414
+ }
322
415
  }
323
416
  });
324
417
  /** Child elements allowed inside `group` (no media, no nested group) */
@@ -21,6 +21,12 @@ export type ParseRequestOptions = {
21
21
  * When true, skip {@link verifyJFSRequestBody} (signature checks).
22
22
  */
23
23
  skipJFSVerification?: boolean;
24
+ /**
25
+ * Maximum allowed absolute difference between the request timestamp and the
26
+ * server clock, in seconds. Requests outside this window are rejected as
27
+ * potential replays. Defaults to 300 (5 minutes) when not provided.
28
+ */
29
+ maxSkewSeconds?: number;
24
30
  };
25
31
  export type ParseRequestResult = {
26
32
  success: true;
@@ -1,7 +1,6 @@
1
1
  import { ACTION_TYPE_GET, ACTION_TYPE_POST, payloadSchema, } from "../schemas.js";
2
2
  import { decodePayload, verifyJFSRequestBody } from "./verify.js";
3
3
  import { z } from "zod";
4
- /** Default replay window per SPEC.md § Replay Protection (5 minutes). */
5
4
  const DEFAULT_SNAP_POST_MAX_SKEW_SECONDS = 300;
6
5
  const requestBodySchema = z.object({
7
6
  header: z.string(),
@@ -29,7 +28,7 @@ export async function parseRequest(request, options = {}) {
29
28
  action: { type: ACTION_TYPE_GET },
30
29
  };
31
30
  }
32
- const maxSkew = DEFAULT_SNAP_POST_MAX_SKEW_SECONDS;
31
+ const maxSkew = options.maxSkewSeconds ?? DEFAULT_SNAP_POST_MAX_SKEW_SECONDS;
33
32
  const nowSec = Math.floor(Date.now() / 1000);
34
33
  const text = await request.text();
35
34
  let jsonBody;
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- export declare const buttonGroupProps: z.ZodObject<{
2
+ export declare const buttonGroupProps: z.ZodPipe<z.ZodObject<{
3
3
  name: z.ZodString;
4
4
  options: z.ZodArray<z.ZodString>;
5
5
  style: z.ZodOptional<z.ZodEnum<{
@@ -7,5 +7,13 @@ export declare const buttonGroupProps: z.ZodObject<{
7
7
  stack: "stack";
8
8
  grid: "grid";
9
9
  }>>;
10
- }, z.core.$strip>;
10
+ }, z.core.$strip>, z.ZodTransform<{
11
+ style: "row" | "stack" | "grid";
12
+ name: string;
13
+ options: string[];
14
+ }, {
15
+ name: string;
16
+ options: string[];
17
+ style?: "row" | "stack" | "grid" | undefined;
18
+ }>>;
11
19
  export type ButtonGroupProps = z.infer<typeof buttonGroupProps>;
@@ -1,10 +1,18 @@
1
1
  import { z } from "zod";
2
- import { BUTTON_GROUP_STYLE_VALUES, LIMITS } from "../constants.js";
3
- export const buttonGroupProps = z.object({
2
+ import { BUTTON_GROUP_STYLE, BUTTON_GROUP_STYLE_VALUES, LIMITS, } from "../constants.js";
3
+ export const buttonGroupProps = z
4
+ .object({
4
5
  name: z.string().min(1),
5
6
  options: z
6
- .array(z.string())
7
+ .array(z.string().max(LIMITS.maxButtonGroupOptionChars))
7
8
  .min(LIMITS.minButtonGroupOptions)
8
9
  .max(LIMITS.maxButtonGroupOptions),
9
10
  style: z.enum(BUTTON_GROUP_STYLE_VALUES).optional(),
10
- });
11
+ })
12
+ .transform((val) => ({
13
+ ...val,
14
+ style: val.style ??
15
+ (val.options.length <= 3
16
+ ? BUTTON_GROUP_STYLE.row
17
+ : BUTTON_GROUP_STYLE.stack),
18
+ }));
@@ -5,9 +5,10 @@ export declare const actionButtonProps: z.ZodObject<{
5
5
  post: "post";
6
6
  link: "link";
7
7
  mini_app: "mini_app";
8
- sdk: "sdk";
8
+ client: "client";
9
9
  }>;
10
- target: z.ZodString;
10
+ target: z.ZodOptional<z.ZodString>;
11
+ client_action: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
11
12
  style: z.ZodOptional<z.ZodEnum<{
12
13
  primary: "primary";
13
14
  secondary: "secondary";
@@ -21,9 +22,10 @@ export declare const buttonProps: z.ZodObject<{
21
22
  post: "post";
22
23
  link: "link";
23
24
  mini_app: "mini_app";
24
- sdk: "sdk";
25
+ client: "client";
25
26
  }>;
26
- target: z.ZodString;
27
+ target: z.ZodOptional<z.ZodString>;
28
+ client_action: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
27
29
  style: z.ZodOptional<z.ZodEnum<{
28
30
  primary: "primary";
29
31
  secondary: "secondary";
package/dist/ui/button.js CHANGED
@@ -3,7 +3,8 @@ import { BUTTON_ACTION_VALUES, BUTTON_STYLE_VALUES } from "../constants.js";
3
3
  export const actionButtonProps = z.object({
4
4
  label: z.string(),
5
5
  action: z.enum(BUTTON_ACTION_VALUES),
6
- target: z.string(),
6
+ target: z.string().optional(),
7
+ client_action: z.record(z.string(), z.unknown()).optional(),
7
8
  style: z.enum(BUTTON_STYLE_VALUES).optional(),
8
9
  });
9
10
  /** Same schema as `actionButtonProps` (legacy export name). */
@@ -69,7 +69,7 @@ export declare const snapJsonRenderCatalog: import("@json-render/core").Catalog<
69
69
  };
70
70
  Spacer: {
71
71
  props: z.ZodObject<{
72
- size: z.ZodOptional<z.ZodEnum<{
72
+ size: z.ZodDefault<z.ZodEnum<{
73
73
  small: "small";
74
74
  medium: "medium";
75
75
  large: "large";
@@ -155,7 +155,7 @@ export declare const snapJsonRenderCatalog: import("@json-render/core").Catalog<
155
155
  description: string;
156
156
  };
157
157
  ButtonGroup: {
158
- props: z.ZodObject<{
158
+ props: z.ZodPipe<z.ZodObject<{
159
159
  name: z.ZodString;
160
160
  options: z.ZodArray<z.ZodString>;
161
161
  style: z.ZodOptional<z.ZodEnum<{
@@ -163,14 +163,22 @@ export declare const snapJsonRenderCatalog: import("@json-render/core").Catalog<
163
163
  stack: "stack";
164
164
  grid: "grid";
165
165
  }>>;
166
- }, z.core.$strip>;
166
+ }, z.core.$strip>, z.ZodTransform<{
167
+ style: "row" | "stack" | "grid";
168
+ name: string;
169
+ options: string[];
170
+ }, {
171
+ name: string;
172
+ options: string[];
173
+ style?: "row" | "stack" | "grid" | undefined;
174
+ }>>;
167
175
  description: string;
168
176
  };
169
177
  Toggle: {
170
178
  props: z.ZodObject<{
171
179
  name: z.ZodString;
172
180
  label: z.ZodString;
173
- value: z.ZodOptional<z.ZodBoolean>;
181
+ value: z.ZodDefault<z.ZodBoolean>;
174
182
  }, z.core.$strip>;
175
183
  description: string;
176
184
  };
@@ -209,7 +217,6 @@ export declare const snapJsonRenderCatalog: import("@json-render/core").Catalog<
209
217
  props: z.ZodObject<{
210
218
  layout: z.ZodEnum<{
211
219
  row: "row";
212
- grid: "grid";
213
220
  }>;
214
221
  }, z.core.$strip>;
215
222
  description: string;
@@ -225,9 +232,10 @@ export declare const snapJsonRenderCatalog: import("@json-render/core").Catalog<
225
232
  post: "post";
226
233
  link: "link";
227
234
  mini_app: "mini_app";
228
- sdk: "sdk";
235
+ client: "client";
229
236
  }>;
230
- target: z.ZodString;
237
+ target: z.ZodOptional<z.ZodString>;
238
+ client_action: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
231
239
  style: z.ZodOptional<z.ZodEnum<{
232
240
  primary: "primary";
233
241
  secondary: "secondary";
@@ -261,10 +269,10 @@ export declare const snapJsonRenderCatalog: import("@json-render/core").Catalog<
261
269
  target: z.ZodString;
262
270
  }, z.core.$strip>;
263
271
  };
264
- snap_sdk: {
272
+ snap_client: {
265
273
  description: string;
266
274
  params: z.ZodObject<{
267
- target: z.ZodString;
275
+ client_action: z.ZodRecord<z.ZodString, z.ZodUnknown>;
268
276
  }, z.core.$strip>;
269
277
  };
270
278
  };
@@ -26,6 +26,9 @@ const snapPostParams = z.object({
26
26
  const snapTargetParams = z.object({
27
27
  target: z.string(),
28
28
  });
29
+ const snapClientParams = z.object({
30
+ client_action: z.record(z.string(), z.unknown()),
31
+ });
29
32
  /**
30
33
  * Basic catalog: one json-render component per snap element type, plus ActionButton for snap buttons.
31
34
  * Does not validate cross-field rules (media count, height budget); snap JSON still goes through `@farcaster/snap` validation.
@@ -90,7 +93,7 @@ export const snapJsonRenderCatalog = defineCatalog(snapJsonRenderSchema, {
90
93
  },
91
94
  ActionButton: {
92
95
  props: actionButtonProps,
93
- description: "Snap action button: post (next page), link (browser), mini_app, sdk — target is HTTPS URL or SDK id.",
96
+ description: "Snap action button: post (next page), link (browser), mini_app, client — target is HTTPS URL or client_action object.",
94
97
  },
95
98
  },
96
99
  actions: {
@@ -106,9 +109,9 @@ export const snapJsonRenderCatalog = defineCatalog(snapJsonRenderSchema, {
106
109
  description: "Open `target` as an in-app Farcaster mini app.",
107
110
  params: snapTargetParams,
108
111
  },
109
- snap_sdk: {
110
- description: "Run a Farcaster client SDK action (cast:view, user:follow, …).",
111
- params: snapTargetParams,
112
+ snap_client: {
113
+ description: "Trigger a Farcaster client action (view_cast, view_profile, compose_cast, …).",
114
+ params: snapClientParams,
112
115
  },
113
116
  },
114
117
  });
@@ -2,7 +2,6 @@ import { z } from "zod";
2
2
  export declare const groupProps: z.ZodObject<{
3
3
  layout: z.ZodEnum<{
4
4
  row: "row";
5
- grid: "grid";
6
5
  }>;
7
6
  }, z.core.$strip>;
8
7
  export type GroupProps = z.infer<typeof groupProps>;
package/dist/ui/group.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { GROUP_LAYOUT_VALUES } from "../constants.js";
2
3
  export const groupProps = z.object({
3
- layout: z.enum(["row", "grid"]),
4
+ layout: z.enum(GROUP_LAYOUT_VALUES),
4
5
  });
package/dist/ui/schema.js CHANGED
@@ -26,6 +26,6 @@ export const snapJsonRenderSchema = defineSchema((s) => ({
26
26
  defaultRules: [
27
27
  "You are generating auxiliary UI for a Farcaster Snap. Prefer components matching snap element types (Text, Image, ButtonGroup, …).",
28
28
  "Snap pages use a Stack root with at most 5 body children and 1 media element (Image or Grid); keep generated trees small.",
29
- "Bottom-of-card snap buttons are ActionButton components; use actions post / link / mini_app / sdk per SPEC.md.",
29
+ "Bottom-of-card snap buttons are ActionButton components; use actions post / link / mini_app / client.",
30
30
  ],
31
31
  });
@@ -1,6 +1,6 @@
1
1
  import { z } from "zod";
2
2
  export declare const spacerProps: z.ZodObject<{
3
- size: z.ZodOptional<z.ZodEnum<{
3
+ size: z.ZodDefault<z.ZodEnum<{
4
4
  small: "small";
5
5
  medium: "medium";
6
6
  large: "large";
package/dist/ui/spacer.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { SPACER_SIZE_VALUES } from "../constants.js";
2
+ import { SPACER_SIZE, SPACER_SIZE_VALUES } from "../constants.js";
3
3
  export const spacerProps = z.object({
4
- size: z.enum(SPACER_SIZE_VALUES).optional(),
4
+ size: z.enum(SPACER_SIZE_VALUES).default(SPACER_SIZE.medium),
5
5
  });
@@ -2,6 +2,6 @@ import { z } from "zod";
2
2
  export declare const toggleProps: z.ZodObject<{
3
3
  name: z.ZodString;
4
4
  label: z.ZodString;
5
- value: z.ZodOptional<z.ZodBoolean>;
5
+ value: z.ZodDefault<z.ZodBoolean>;
6
6
  }, z.core.$strip>;
7
7
  export type ToggleProps = z.infer<typeof toggleProps>;
package/dist/ui/toggle.js CHANGED
@@ -2,5 +2,5 @@ import { z } from "zod";
2
2
  export const toggleProps = z.object({
3
3
  name: z.string().min(1),
4
4
  label: z.string(),
5
- value: z.boolean().optional(),
5
+ value: z.boolean().default(false),
6
6
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@farcaster/snap",
3
- "version": "1.5.0",
3
+ "version": "1.5.2",
4
4
  "description": "Farcaster Snaps 🫰",
5
5
  "repository": {
6
6
  "type": "git",
package/src/constants.ts CHANGED
@@ -68,6 +68,8 @@ export const DEFAULT_LIST_STYLE = "ordered" as const;
68
68
 
69
69
  export const GRID_CELL_SIZE_VALUES = ["auto", "square"] as const;
70
70
  export const GRID_GAP_VALUES = ["none", "small", "medium"] as const;
71
+ export const DEFAULT_GRID_GAP =
72
+ "small" as const satisfies (typeof GRID_GAP_VALUES)[number];
71
73
 
72
74
  export const BUTTON_GROUP_STYLE = {
73
75
  row: "row",
@@ -85,14 +87,32 @@ export const BUTTON_ACTION = {
85
87
  post: "post",
86
88
  link: "link",
87
89
  mini_app: "mini_app",
88
- sdk: "sdk",
90
+ client: "client",
89
91
  } as const;
90
92
 
91
93
  export const BUTTON_ACTION_VALUES = [
92
94
  BUTTON_ACTION.post,
93
95
  BUTTON_ACTION.link,
94
96
  BUTTON_ACTION.mini_app,
95
- BUTTON_ACTION.sdk,
97
+ BUTTON_ACTION.client,
98
+ ] as const;
99
+
100
+ export const CLIENT_ACTION = {
101
+ view_cast: "view_cast",
102
+ view_profile: "view_profile",
103
+ compose_cast: "compose_cast",
104
+ view_token: "view_token",
105
+ send_token: "send_token",
106
+ swap_token: "swap_token",
107
+ } as const;
108
+
109
+ export const CLIENT_ACTION_VALUES = [
110
+ CLIENT_ACTION.view_cast,
111
+ CLIENT_ACTION.view_profile,
112
+ CLIENT_ACTION.compose_cast,
113
+ CLIENT_ACTION.view_token,
114
+ CLIENT_ACTION.send_token,
115
+ CLIENT_ACTION.swap_token,
96
116
  ] as const;
97
117
 
98
118
  export const BUTTON_STYLE = {
package/src/index.ts CHANGED
@@ -5,6 +5,8 @@ export {
5
5
  MEDIA_TYPE,
6
6
  DEFAULT_LIST_STYLE,
7
7
  DEFAULT_SLIDER_STEP,
8
+ CLIENT_ACTION,
9
+ CLIENT_ACTION_VALUES,
8
10
  } from "./constants";
9
11
  export {
10
12
  DEFAULT_THEME_ACCENT,
@@ -21,11 +23,13 @@ export {
21
23
  snapResponseSchema,
22
24
  firstPageResponseSchema,
23
25
  payloadSchema,
26
+ clientActionSchema,
24
27
  createDefaultDataStore,
25
28
  type Button,
26
29
  type Element,
27
30
  type Elements,
28
31
  type GroupChildElement,
32
+ type ClientAction,
29
33
  type SnapAction,
30
34
  type SnapPageElementInput,
31
35
  type SnapContext,
@@ -42,3 +46,4 @@ export {
42
46
  validateFirstPageResponse,
43
47
  type ValidationResult,
44
48
  } from "./validator";
49
+ export { type Middleware, useMiddleware } from "./middleware";
@@ -0,0 +1,7 @@
1
+ import { SnapFunction } from "./schemas";
2
+
3
+ export type Middleware = (fn: SnapFunction) => SnapFunction;
4
+
5
+ export function useMiddleware(fn: SnapFunction, middleware: Middleware[]) {
6
+ return middleware.reduce((acc, middleware) => middleware(acc), fn);
7
+ }
package/src/schemas.ts CHANGED
@@ -2,11 +2,13 @@ import { z } from "zod";
2
2
  import {
3
3
  BUTTON_ACTION,
4
4
  BUTTON_ACTION_VALUES,
5
+ CLIENT_ACTION,
5
6
  BUTTON_GROUP_STYLE,
6
7
  BUTTON_GROUP_STYLE_VALUES,
7
8
  BUTTON_LAYOUT_VALUES,
8
9
  BUTTON_STYLE_VALUES,
9
10
  DEFAULT_BUTTON_LAYOUT,
11
+ DEFAULT_GRID_GAP,
10
12
  DEFAULT_LIST_STYLE,
11
13
  DEFAULT_SLIDER_STEP,
12
14
  EFFECT_VALUES,
@@ -68,33 +70,10 @@ const themeSchema = z
68
70
  })
69
71
  .strict();
70
72
 
71
- const httpsUrl = z.string().refine((s) => s.startsWith(HTTPS_PREFIX), {
72
- message: "URL must use HTTPS",
73
+ const imageUrlSchema = z.string().refine((s) => s.startsWith(HTTPS_PREFIX), {
74
+ message: "image URL must use HTTPS",
73
75
  });
74
76
 
75
- function hasAllowedMediaExtension(
76
- urlString: string,
77
- allowedExtensions: string[],
78
- ): boolean {
79
- try {
80
- const url = new URL(urlString);
81
- if (url.protocol !== "https:") return false;
82
- const lowerPathname = url.pathname.toLowerCase();
83
- return allowedExtensions.some((extension) =>
84
- lowerPathname.endsWith(`.${extension}`),
85
- );
86
- } catch {
87
- return false;
88
- }
89
- }
90
-
91
- const imageUrlSchema = z
92
- .string()
93
- .refine((s) => hasAllowedMediaExtension(s, ["jpg", "png", "gif", "webp"]), {
94
- message:
95
- "image URL must use HTTPS and end with a supported extension (.jpg, .png, .gif, .webp)",
96
- });
97
-
98
77
  const textAlignSchema = z.enum(TEXT_ALIGN_VALUES);
99
78
 
100
79
  const textElementSchema = z
@@ -208,7 +187,7 @@ const gridElementSchema = z
208
187
  rows: z.number().int().min(LIMITS.minGridRows).max(LIMITS.maxGridRows),
209
188
  cells: z.array(gridCellSchema),
210
189
  cellSize: z.enum(GRID_CELL_SIZE_VALUES).optional(),
211
- gap: z.enum(GRID_GAP_VALUES).optional(),
190
+ gap: z.enum(GRID_GAP_VALUES).default(DEFAULT_GRID_GAP),
212
191
  interactive: z.boolean().optional(),
213
192
  })
214
193
  .superRefine((val, ctx) => {
@@ -370,26 +349,146 @@ const buttonActionSchema = z.enum(BUTTON_ACTION_VALUES);
370
349
 
371
350
  const buttonStyleSchema = z.enum(BUTTON_STYLE_VALUES);
372
351
 
352
+ /* ------------------------------------------------------------------ */
353
+ /* Client action schemas */
354
+ /* ------------------------------------------------------------------ */
355
+
356
+ const viewCastClientActionSchema = z
357
+ .object({
358
+ type: z.literal(CLIENT_ACTION.view_cast),
359
+ hash: z.string().min(1),
360
+ })
361
+ .strict();
362
+
363
+ const viewProfileClientActionSchema = z
364
+ .object({
365
+ type: z.literal(CLIENT_ACTION.view_profile),
366
+ fid: z.number().int().nonnegative(),
367
+ })
368
+ .strict();
369
+
370
+ const composeCastClientActionSchema = z
371
+ .object({
372
+ type: z.literal(CLIENT_ACTION.compose_cast),
373
+ text: z.string().optional(),
374
+ embeds: z
375
+ .array(z.string())
376
+ .max(2, { message: "compose_cast embeds: max 2 URLs" })
377
+ .optional(),
378
+ parent: z
379
+ .object({
380
+ type: z.literal("cast"),
381
+ hash: z.string().min(1),
382
+ })
383
+ .strict()
384
+ .optional(),
385
+ channelKey: z.string().optional(),
386
+ })
387
+ .strict();
388
+
389
+ const viewTokenClientActionSchema = z
390
+ .object({
391
+ type: z.literal(CLIENT_ACTION.view_token),
392
+ /** CAIP-19 asset ID (e.g. "eip155:8453/erc20:0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913") */
393
+ token: z.string().min(1),
394
+ })
395
+ .strict();
396
+
397
+ const sendTokenClientActionSchema = z
398
+ .object({
399
+ type: z.literal(CLIENT_ACTION.send_token),
400
+ /** CAIP-19 asset ID */
401
+ token: z.string().optional(),
402
+ /** Amount in raw token units (e.g. "1000000" for 1 USDC) */
403
+ amount: z.string().optional(),
404
+ recipientFid: z.number().int().nonnegative().optional(),
405
+ recipientAddress: z.string().optional(),
406
+ })
407
+ .strict();
408
+
409
+ const swapTokenClientActionSchema = z
410
+ .object({
411
+ type: z.literal(CLIENT_ACTION.swap_token),
412
+ /** CAIP-19 asset ID to sell */
413
+ sellToken: z.string().optional(),
414
+ /** CAIP-19 asset ID to buy */
415
+ buyToken: z.string().optional(),
416
+ /** Amount in raw token units */
417
+ sellAmount: z.string().optional(),
418
+ })
419
+ .strict();
420
+
421
+ export const clientActionSchema = z.discriminatedUnion("type", [
422
+ viewCastClientActionSchema,
423
+ viewProfileClientActionSchema,
424
+ composeCastClientActionSchema,
425
+ viewTokenClientActionSchema,
426
+ sendTokenClientActionSchema,
427
+ swapTokenClientActionSchema,
428
+ ]);
429
+
430
+ export type ClientAction = z.infer<typeof clientActionSchema>;
431
+
432
+ /* ------------------------------------------------------------------ */
433
+ /* Button schema */
434
+ /* ------------------------------------------------------------------ */
435
+
373
436
  const buttonSchema = z
374
437
  .object({
375
438
  label: z.string().min(1).max(LIMITS.maxButtonLabelChars),
376
439
  action: buttonActionSchema,
377
- /** URL (HTTPS for post/link/mini_app) or SDK action id (e.g. cast:view:...) */
378
- target: z.string().min(1),
440
+ /** URL target for post/link/mini_app buttons */
441
+ target: z.string().min(1).optional(),
442
+ /** Structured client action for client buttons */
443
+ client_action: clientActionSchema.optional(),
379
444
  style: buttonStyleSchema.optional(),
380
445
  })
381
446
  .superRefine((val, ctx) => {
382
- if (
383
- (val.action === BUTTON_ACTION.post ||
384
- val.action === BUTTON_ACTION.link ||
385
- val.action === BUTTON_ACTION.mini_app) &&
386
- !isSecureOrLoopbackHttpButtonTarget(val.target)
387
- ) {
388
- ctx.addIssue({
389
- code: "custom",
390
- message: `button target must use HTTPS (or http:// on localhost / 127.0.0.1 for development) for action "${val.action}" (received: ${val.target})`,
391
- path: ["target"],
392
- });
447
+ if (val.action === BUTTON_ACTION.client) {
448
+ // client buttons require client_action, must not have target
449
+ if (val.client_action === undefined) {
450
+ ctx.addIssue({
451
+ code: "custom",
452
+ message: `button with action "client" must include a "client_action" object`,
453
+ path: ["client_action"],
454
+ });
455
+ }
456
+ if (val.target !== undefined) {
457
+ ctx.addIssue({
458
+ code: "custom",
459
+ message: `button with action "client" must not include "target"`,
460
+ path: ["target"],
461
+ });
462
+ }
463
+ } else {
464
+ // post/link/mini_app buttons require target, must not have client_action
465
+ if (val.target === undefined) {
466
+ ctx.addIssue({
467
+ code: "custom",
468
+ message: `button with action "${val.action}" must include a "target" URL`,
469
+ path: ["target"],
470
+ });
471
+ }
472
+ if (val.client_action !== undefined) {
473
+ ctx.addIssue({
474
+ code: "custom",
475
+ message: `button with action "${val.action}" must not include "client_action"`,
476
+ path: ["client_action"],
477
+ });
478
+ }
479
+ if (
480
+ val.target &&
481
+ (val.action === BUTTON_ACTION.post ||
482
+ val.action === BUTTON_ACTION.link ||
483
+ val.action === BUTTON_ACTION.mini_app) &&
484
+ !isSecureOrLoopbackHttpButtonTarget(val.target)
485
+ ) {
486
+ ctx.addIssue({
487
+ code: "custom",
488
+ message: `button target must use HTTPS (or http:// on localhost / 127.0.0.1 for development) for action "${val.action}" (received: ${val.target})`,
489
+ path: ["target"],
490
+ });
491
+ }
393
492
  }
394
493
  });
395
494
 
@@ -608,7 +707,9 @@ export function createDefaultDataStore(): SnapDataStore {
608
707
  set(_key: string, _value: DataStoreValue): Promise<never> {
609
708
  return Promise.reject(err);
610
709
  },
611
- withLock<T>(_fn: (store: SnapDataStoreOperations) => Promise<T>): Promise<never> {
710
+ withLock<T>(
711
+ _fn: (store: SnapDataStoreOperations) => Promise<T>,
712
+ ): Promise<never> {
612
713
  return Promise.reject(err);
613
714
  },
614
715
  };
@@ -7,7 +7,6 @@ import {
7
7
  import { decodePayload, verifyJFSRequestBody } from "./verify";
8
8
  import { z } from "zod";
9
9
 
10
- /** Default replay window per SPEC.md § Replay Protection (5 minutes). */
11
10
  const DEFAULT_SNAP_POST_MAX_SKEW_SECONDS = 300 as const;
12
11
 
13
12
  export type ParseRequestError =
@@ -37,6 +36,13 @@ export type ParseRequestOptions = {
37
36
  * When true, skip {@link verifyJFSRequestBody} (signature checks).
38
37
  */
39
38
  skipJFSVerification?: boolean;
39
+
40
+ /**
41
+ * Maximum allowed absolute difference between the request timestamp and the
42
+ * server clock, in seconds. Requests outside this window are rejected as
43
+ * potential replays. Defaults to 300 (5 minutes) when not provided.
44
+ */
45
+ maxSkewSeconds?: number;
40
46
  };
41
47
 
42
48
  export type ParseRequestResult =
@@ -75,7 +81,7 @@ export async function parseRequest(
75
81
  };
76
82
  }
77
83
 
78
- const maxSkew = DEFAULT_SNAP_POST_MAX_SKEW_SECONDS;
84
+ const maxSkew = options.maxSkewSeconds ?? DEFAULT_SNAP_POST_MAX_SKEW_SECONDS;
79
85
  const nowSec = Math.floor(Date.now() / 1000);
80
86
 
81
87
  const text = await request.text();
@@ -1,13 +1,26 @@
1
1
  import { z } from "zod";
2
- import { BUTTON_GROUP_STYLE_VALUES, LIMITS } from "../constants.js";
2
+ import {
3
+ BUTTON_GROUP_STYLE,
4
+ BUTTON_GROUP_STYLE_VALUES,
5
+ LIMITS,
6
+ } from "../constants.js";
3
7
 
4
- export const buttonGroupProps = z.object({
5
- name: z.string().min(1),
6
- options: z
7
- .array(z.string())
8
- .min(LIMITS.minButtonGroupOptions)
9
- .max(LIMITS.maxButtonGroupOptions),
10
- style: z.enum(BUTTON_GROUP_STYLE_VALUES).optional(),
11
- });
8
+ export const buttonGroupProps = z
9
+ .object({
10
+ name: z.string().min(1),
11
+ options: z
12
+ .array(z.string().max(LIMITS.maxButtonGroupOptionChars))
13
+ .min(LIMITS.minButtonGroupOptions)
14
+ .max(LIMITS.maxButtonGroupOptions),
15
+ style: z.enum(BUTTON_GROUP_STYLE_VALUES).optional(),
16
+ })
17
+ .transform((val) => ({
18
+ ...val,
19
+ style:
20
+ val.style ??
21
+ (val.options.length <= 3
22
+ ? BUTTON_GROUP_STYLE.row
23
+ : BUTTON_GROUP_STYLE.stack),
24
+ }));
12
25
 
13
26
  export type ButtonGroupProps = z.infer<typeof buttonGroupProps>;
package/src/ui/button.ts CHANGED
@@ -4,7 +4,8 @@ import { BUTTON_ACTION_VALUES, BUTTON_STYLE_VALUES } from "../constants.js";
4
4
  export const actionButtonProps = z.object({
5
5
  label: z.string(),
6
6
  action: z.enum(BUTTON_ACTION_VALUES),
7
- target: z.string(),
7
+ target: z.string().optional(),
8
+ client_action: z.record(z.string(), z.unknown()).optional(),
8
9
  style: z.enum(BUTTON_STYLE_VALUES).optional(),
9
10
  });
10
11
 
package/src/ui/catalog.ts CHANGED
@@ -29,6 +29,10 @@ const snapTargetParams = z.object({
29
29
  target: z.string(),
30
30
  });
31
31
 
32
+ const snapClientParams = z.object({
33
+ client_action: z.record(z.string(), z.unknown()),
34
+ });
35
+
32
36
  /**
33
37
  * Basic catalog: one json-render component per snap element type, plus ActionButton for snap buttons.
34
38
  * Does not validate cross-field rules (media count, height budget); snap JSON still goes through `@farcaster/snap` validation.
@@ -102,7 +106,7 @@ export const snapJsonRenderCatalog = defineCatalog(snapJsonRenderSchema, {
102
106
  ActionButton: {
103
107
  props: actionButtonProps,
104
108
  description:
105
- "Snap action button: post (next page), link (browser), mini_app, sdk — target is HTTPS URL or SDK id.",
109
+ "Snap action button: post (next page), link (browser), mini_app, client — target is HTTPS URL or client_action object.",
106
110
  },
107
111
  },
108
112
  actions: {
@@ -119,10 +123,10 @@ export const snapJsonRenderCatalog = defineCatalog(snapJsonRenderSchema, {
119
123
  description: "Open `target` as an in-app Farcaster mini app.",
120
124
  params: snapTargetParams,
121
125
  },
122
- snap_sdk: {
126
+ snap_client: {
123
127
  description:
124
- "Run a Farcaster client SDK action (cast:view, user:follow, …).",
125
- params: snapTargetParams,
128
+ "Trigger a Farcaster client action (view_cast, view_profile, compose_cast, …).",
129
+ params: snapClientParams,
126
130
  },
127
131
  },
128
132
  });
package/src/ui/group.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { z } from "zod";
2
+ import { GROUP_LAYOUT_VALUES } from "../constants.js";
2
3
 
3
4
  export const groupProps = z.object({
4
- layout: z.enum(["row", "grid"]),
5
+ layout: z.enum(GROUP_LAYOUT_VALUES),
5
6
  });
6
7
 
7
8
  export type GroupProps = z.infer<typeof groupProps>;
package/src/ui/schema.ts CHANGED
@@ -31,7 +31,7 @@ export const snapJsonRenderSchema = defineSchema(
31
31
  defaultRules: [
32
32
  "You are generating auxiliary UI for a Farcaster Snap. Prefer components matching snap element types (Text, Image, ButtonGroup, …).",
33
33
  "Snap pages use a Stack root with at most 5 body children and 1 media element (Image or Grid); keep generated trees small.",
34
- "Bottom-of-card snap buttons are ActionButton components; use actions post / link / mini_app / sdk per SPEC.md.",
34
+ "Bottom-of-card snap buttons are ActionButton components; use actions post / link / mini_app / client.",
35
35
  ],
36
36
  },
37
37
  );
package/src/ui/spacer.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import { z } from "zod";
2
- import { SPACER_SIZE_VALUES } from "../constants.js";
2
+ import { SPACER_SIZE, SPACER_SIZE_VALUES } from "../constants.js";
3
3
 
4
4
  export const spacerProps = z.object({
5
- size: z.enum(SPACER_SIZE_VALUES).optional(),
5
+ size: z.enum(SPACER_SIZE_VALUES).default(SPACER_SIZE.medium),
6
6
  });
7
7
 
8
8
  export type SpacerProps = z.infer<typeof spacerProps>;
package/src/ui/toggle.ts CHANGED
@@ -3,7 +3,7 @@ import { z } from "zod";
3
3
  export const toggleProps = z.object({
4
4
  name: z.string().min(1),
5
5
  label: z.string(),
6
- value: z.boolean().optional(),
6
+ value: z.boolean().default(false),
7
7
  });
8
8
 
9
9
  export type ToggleProps = z.infer<typeof toggleProps>;