@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.
- package/LICENSE +21 -0
- package/dist/colors.d.ts +10 -0
- package/dist/colors.js +22 -0
- package/dist/constants.d.ts +1 -0
- package/dist/constants.js +5 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.js +3 -3
- package/dist/react/components/cell-grid.js +25 -17
- package/dist/react/components/stack.js +19 -6
- package/dist/react/components/text.js +9 -2
- package/dist/react/hooks/use-snap-colors.js +2 -9
- package/dist/react-native/components/snap-cell-grid.js +25 -16
- package/dist/react-native/components/snap-stack.js +17 -7
- package/dist/react-native/use-snap-palette.js +2 -2
- package/dist/schemas.d.ts +52 -0
- package/dist/schemas.js +10 -4
- package/dist/server/index.d.ts +2 -1
- package/dist/server/index.js +2 -1
- package/dist/server/parseRequest.d.ts +4 -3
- package/dist/server/parseRequest.js +91 -67
- package/dist/server/verify.d.ts +12 -5
- package/dist/server/verify.js +67 -19
- package/dist/stack-horizontal-utils.d.ts +6 -0
- package/dist/stack-horizontal-utils.js +14 -0
- package/dist/ui/catalog.d.ts +3 -2
- package/dist/ui/catalog.js +1 -1
- package/dist/ui/cell-grid.d.ts +3 -2
- package/dist/ui/cell-grid.js +12 -2
- package/dist/verify.test.js +3 -3
- package/llms.txt +14 -4
- package/package.json +1 -1
- package/src/colors.ts +27 -0
- package/src/constants.ts +6 -1
- package/src/index.ts +6 -0
- package/src/react/components/cell-grid.tsx +28 -16
- package/src/react/components/stack.tsx +21 -5
- package/src/react/components/text.tsx +9 -2
- package/src/react/hooks/use-snap-colors.ts +3 -8
- package/src/react-native/components/snap-cell-grid.tsx +28 -15
- package/src/react-native/components/snap-stack.tsx +19 -7
- package/src/react-native/use-snap-palette.ts +2 -1
- package/src/schemas.ts +14 -4
- package/src/server/index.ts +7 -1
- package/src/server/parseRequest.ts +117 -71
- package/src/server/verify.ts +99 -26
- package/src/stack-horizontal-utils.ts +14 -0
- package/src/ui/catalog.ts +1 -1
- package/src/ui/cell-grid.ts +16 -2
- package/src/verify.test.ts +3 -3
|
@@ -1,85 +1,124 @@
|
|
|
1
|
-
import { ACTION_TYPE_GET, ACTION_TYPE_POST, payloadSchema, } from "../schemas.js";
|
|
2
|
-
import { decodePayload,
|
|
3
|
-
import {
|
|
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
|
|
13
|
-
*
|
|
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
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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: "
|
|
43
|
-
message: "
|
|
61
|
+
type: "fid_mismatch",
|
|
62
|
+
message: `fid "${payload.fid}" does not match user.fid "${payload.user.fid}"`,
|
|
44
63
|
},
|
|
45
64
|
};
|
|
46
65
|
}
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
51
|
-
error: {
|
|
83
|
+
ok: false,
|
|
84
|
+
error: {
|
|
85
|
+
type: "invalid_json",
|
|
86
|
+
message: invalidJsonMessage ?? parsed.error,
|
|
87
|
+
},
|
|
52
88
|
};
|
|
53
89
|
}
|
|
54
|
-
const
|
|
90
|
+
const jfs = parsed.jfs;
|
|
91
|
+
const payloadParsed = schema.safeParse(decodePayload(jfs.payload));
|
|
55
92
|
if (!payloadParsed.success) {
|
|
56
93
|
return {
|
|
57
|
-
|
|
94
|
+
ok: false,
|
|
58
95
|
error: { type: "validation", issues: payloadParsed.error.issues },
|
|
59
96
|
};
|
|
60
97
|
}
|
|
61
|
-
const
|
|
98
|
+
const payload = payloadParsed.data;
|
|
62
99
|
if (!options.skipJFSVerification) {
|
|
63
|
-
const
|
|
64
|
-
if (!
|
|
100
|
+
const verified = await verifyJFS(jfs);
|
|
101
|
+
if (!verified.valid) {
|
|
65
102
|
return {
|
|
66
|
-
|
|
67
|
-
error: { type: "signature", message:
|
|
103
|
+
ok: false,
|
|
104
|
+
error: { type: "signature", message: verified.error.message },
|
|
68
105
|
};
|
|
69
106
|
}
|
|
70
|
-
if (
|
|
107
|
+
if (verified.signingUserFid !== payload.user.fid) {
|
|
71
108
|
return {
|
|
72
|
-
|
|
109
|
+
ok: false,
|
|
73
110
|
error: {
|
|
74
111
|
type: "fid_mismatch",
|
|
75
|
-
message: `JFS header 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
|
-
|
|
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
|
-
|
|
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 &&
|
|
142
|
+
if (expectedOrigin !== undefined && payload.audience !== expectedOrigin) {
|
|
104
143
|
return {
|
|
105
|
-
|
|
144
|
+
ok: false,
|
|
106
145
|
error: {
|
|
107
146
|
type: "origin_mismatch",
|
|
108
|
-
message: `payload audience "${
|
|
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
|
}
|
package/dist/server/verify.d.ts
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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;
|
package/dist/server/verify.js
CHANGED
|
@@ -1,33 +1,40 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
5
|
-
|
|
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
|
-
|
|
8
|
-
header: requestBody.header,
|
|
9
|
-
payload: requestBody.payload,
|
|
10
|
-
signature: requestBody.signature,
|
|
11
|
-
});
|
|
11
|
+
jfsFromJson = JSON.parse(trimmed);
|
|
12
12
|
}
|
|
13
|
-
catch
|
|
14
|
-
|
|
15
|
-
valid: false,
|
|
16
|
-
error: error instanceof Error ? error : new Error(String(error)),
|
|
17
|
-
};
|
|
13
|
+
catch {
|
|
14
|
+
jfsFromJson = undefined;
|
|
18
15
|
}
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
decoded = decode(compactJfs);
|
|
16
|
+
if (jfsFromJson !== undefined && isJfsObject(jfsFromJson)) {
|
|
17
|
+
return { ok: true, jfs: jfsFromJson };
|
|
22
18
|
}
|
|
23
|
-
|
|
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:
|
|
33
|
+
error: new Error("invalid JFS envelope"),
|
|
27
34
|
};
|
|
28
35
|
}
|
|
29
36
|
try {
|
|
30
|
-
await verify({ data:
|
|
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;
|
|
@@ -2,3 +2,9 @@ import { type ReactNode } from "react";
|
|
|
2
2
|
export declare function horizontalChildrenAreAllButtons(children: ReactNode): boolean;
|
|
3
3
|
/** Direct snap catalog children under a stack (used for all-button grid column count). */
|
|
4
4
|
export declare function countRenderableChildren(children: ReactNode): number;
|
|
5
|
+
/**
|
|
6
|
+
* Default horizontal stack gap as a t-shirt size, chosen by column count:
|
|
7
|
+
* 2 cols → lg, 3 cols → md, 4+ cols → sm. Unknown count falls back to md.
|
|
8
|
+
* Tighter gaps for denser layouts; authors can always override via the `gap` prop.
|
|
9
|
+
*/
|
|
10
|
+
export declare function defaultHorizontalGapSize(columnCount: number | undefined): "sm" | "md" | "lg";
|
|
@@ -27,3 +27,17 @@ export function horizontalChildrenAreAllButtons(children) {
|
|
|
27
27
|
export function countRenderableChildren(children) {
|
|
28
28
|
return Children.toArray(children).filter(isRenderableChild).length;
|
|
29
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* Default horizontal stack gap as a t-shirt size, chosen by column count:
|
|
32
|
+
* 2 cols → lg, 3 cols → md, 4+ cols → sm. Unknown count falls back to md.
|
|
33
|
+
* Tighter gaps for denser layouts; authors can always override via the `gap` prop.
|
|
34
|
+
*/
|
|
35
|
+
export function defaultHorizontalGapSize(columnCount) {
|
|
36
|
+
if (columnCount === undefined)
|
|
37
|
+
return "md";
|
|
38
|
+
if (columnCount <= 2)
|
|
39
|
+
return "lg";
|
|
40
|
+
if (columnCount === 3)
|
|
41
|
+
return "md";
|
|
42
|
+
return "sm";
|
|
43
|
+
}
|
package/dist/ui/catalog.d.ts
CHANGED
|
@@ -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,8 +389,9 @@ 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
|
+
value: z.ZodOptional<z.ZodString>;
|
|
394
395
|
}, z.core.$strip>>;
|
|
395
396
|
gap: z.ZodOptional<z.ZodEnum<{
|
|
396
397
|
sm: "sm";
|
package/dist/ui/catalog.js
CHANGED
|
@@ -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: {
|
package/dist/ui/cell-grid.d.ts
CHANGED
|
@@ -6,7 +6,7 @@ export declare const cellGridProps: z.ZodObject<{
|
|
|
6
6
|
cells: z.ZodArray<z.ZodObject<{
|
|
7
7
|
row: z.ZodNumber;
|
|
8
8
|
col: z.ZodNumber;
|
|
9
|
-
color: z.ZodOptional<z.ZodEnum<{
|
|
9
|
+
color: z.ZodOptional<z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodUnion<readonly [z.ZodEnum<{
|
|
10
10
|
gray: "gray";
|
|
11
11
|
blue: "blue";
|
|
12
12
|
red: "red";
|
|
@@ -15,8 +15,9 @@ export declare const cellGridProps: z.ZodObject<{
|
|
|
15
15
|
teal: "teal";
|
|
16
16
|
purple: "purple";
|
|
17
17
|
pink: "pink";
|
|
18
|
-
}
|
|
18
|
+
}>, z.ZodString]>>>;
|
|
19
19
|
content: z.ZodOptional<z.ZodString>;
|
|
20
|
+
value: z.ZodOptional<z.ZodString>;
|
|
20
21
|
}, z.core.$strip>>;
|
|
21
22
|
gap: z.ZodOptional<z.ZodEnum<{
|
|
22
23
|
sm: "sm";
|
package/dist/ui/cell-grid.js
CHANGED
|
@@ -1,11 +1,21 @@
|
|
|
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 { GRID_MIN_COLS, GRID_MAX_COLS, GRID_MIN_ROWS, GRID_MAX_ROWS, GRID_GAP_VALUES, } from "../constants.js";
|
|
4
|
+
/** Palette name or `#rrggbb`; input is trimmed so palette and hex rules match runtime resolvers. */
|
|
5
|
+
const cellGridCellColorSchema = z.preprocess((v) => (typeof v === "string" ? v.trim() : v), z.union([
|
|
6
|
+
z.enum(PALETTE_COLOR_VALUES),
|
|
7
|
+
z
|
|
8
|
+
.string()
|
|
9
|
+
.refine(isSnapHexColorString, {
|
|
10
|
+
message: "cell_grid cell hex color must be #rrggbb",
|
|
11
|
+
}),
|
|
12
|
+
]));
|
|
4
13
|
const cellGridCellSchema = z.object({
|
|
5
14
|
row: z.number().int().nonnegative(),
|
|
6
15
|
col: z.number().int().nonnegative(),
|
|
7
|
-
color:
|
|
16
|
+
color: cellGridCellColorSchema.optional(),
|
|
8
17
|
content: z.string().optional(),
|
|
18
|
+
value: z.string().min(1).max(30).optional(),
|
|
9
19
|
});
|
|
10
20
|
export const cellGridProps = z
|
|
11
21
|
.object({
|
package/dist/verify.test.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
-
import {
|
|
2
|
+
import { verifyJFS } from "./server/verify.js";
|
|
3
3
|
const validRequestBody = `{
|
|
4
4
|
"header":"eyJmaWQiOjI2MTMxOSwidHlwZSI6ImFwcF9rZXkiLCJrZXkiOiIweGY0ZGQyNjczYTUzMjEwYzQ3ZGYzZjFmNTk0NjZlZTdhMTM3ZmQxOGQ5NTVjMmU2OGExMmQwOTE2MGE2NmMyMTUifQ",
|
|
5
5
|
"payload":"eyJmaWQiOjI2MTMxOSwiaW5wdXRzIjp7ImRpc3BsYXkiOiJJU08gKFVUQykifSwiYnV0dG9uX2luZGV4IjowLCJ0aW1lc3RhbXAiOjE3NzQ2OTMyMTN9",
|
|
@@ -7,7 +7,7 @@ const validRequestBody = `{
|
|
|
7
7
|
}`;
|
|
8
8
|
/** Matches JFS header `key` (Ed25519 public key, 32 bytes hex without `0x` in hub JSON field). */
|
|
9
9
|
const HUB_SIGNER_KEY_HEX = "f4dd2673a53210c47df3f1f59466ee7a137fd18d955c2e68a12d09160a66c215";
|
|
10
|
-
describe("
|
|
10
|
+
describe("verifyJFS", () => {
|
|
11
11
|
beforeEach(() => {
|
|
12
12
|
vi.stubGlobal("fetch", vi.fn(async (input) => {
|
|
13
13
|
const u = String(input);
|
|
@@ -41,7 +41,7 @@ describe("verifyJFSRequestBody", () => {
|
|
|
41
41
|
vi.unstubAllGlobals();
|
|
42
42
|
});
|
|
43
43
|
it("accepts JSON JFS body and verifies crypto + hub signer list", async () => {
|
|
44
|
-
const result = await
|
|
44
|
+
const result = await verifyJFS(JSON.parse(validRequestBody));
|
|
45
45
|
expect(result.valid).toBe(true);
|
|
46
46
|
if (result.valid) {
|
|
47
47
|
expect(result.data).toEqual({
|
package/llms.txt
CHANGED
|
@@ -95,17 +95,17 @@ Top-level fields: `version` (required, `"1.0"` or `"2.0"`), `theme` (optional, `
|
|
|
95
95
|
- `name` (string, optional): POST inputs key. Default: `"grid_tap"`
|
|
96
96
|
- `cols` (number, required, 2–32)
|
|
97
97
|
- `rows` (number, required, 2–16)
|
|
98
|
-
- `cells` (array, required): sparse list of `{ row, col, color?: PaletteColor, content?: string }`
|
|
98
|
+
- `cells` (array, required): sparse list of `{ row, col, color?: PaletteColor | #rrggbb, content?: string, value?: string (1–30 chars) }`. When `value` is set on a cell, that string is what's written to `inputs[name]` on press/select; otherwise `"row,col"` is used. Use `value` for grids with meaningful labels (calendar days, alphabet letters, region codes) so handlers don't have to reverse-lookup. Use the row/col fallback for true coordinate grids (minesweeper, tic-tac-toe, pixel art).
|
|
99
99
|
- `gap` (optional): `"none"` (0px) | `"sm"` (1px) | `"md"` (2px) | `"lg"` (4px). Default: `"sm"`
|
|
100
100
|
- `rowHeight` (number, optional, 8–64): pixel height per row. Default: 28. Grid height = rows × rowHeight
|
|
101
|
-
- `select` (optional): `"off"` | `"single"` | `"multiple"`. Default: `"off"`. With `select: "off"`, bind `on.press` for press-to-act (each press writes `"row,col"` to `inputs[name]` and fires the action). With `"single"` / `"multiple"`, presses accumulate selection state and pair with a separate submit `button
|
|
102
|
-
- Events: `press` — fires on cell press, only when `select: "off"`; `inputs[name]` is set to `"row,col"` before the bound action runs
|
|
101
|
+
- `select` (optional): `"off"` | `"single"` | `"multiple"`. Default: `"off"`. With `select: "off"`, bind `on.press` for press-to-act (each press writes the cell's `value` or `"row,col"` to `inputs[name]` and fires the action). With `"single"` / `"multiple"`, presses accumulate selection state and pair with a separate submit `button` (multi-select joins values with `|`); `on.press` is ignored.
|
|
102
|
+
- Events: `press` — fires on cell press, only when `select: "off"`; `inputs[name]` is set to the pressed cell's `value` (or `"row,col"` fallback) before the bound action runs
|
|
103
103
|
|
|
104
104
|
### Container Components
|
|
105
105
|
|
|
106
106
|
**stack** — Layout container.
|
|
107
107
|
- `direction` (optional): `"vertical"` | `"horizontal"`. Default: `"vertical"`
|
|
108
|
-
- `gap` (optional): `"none"` | `"sm"` | `"md"` | `"lg"`. Default: `"md"`
|
|
108
|
+
- `gap` (optional): `"none"` | `"sm"` | `"md"` | `"lg"`. Vertical px: 0/8/16/24. Horizontal px: 0/4/8/16 (tighter, since children sit side-by-side). Default for vertical: `"md"`. Default for horizontal is column-aware: 2 cols → `"lg"` (16px), 3 cols → `"md"` (8px), 4+ cols → `"sm"` (4px), unknown → `"md"` (8px). An explicit value always wins — override the default when you have a deliberate visual reason (e.g. tighter toolbar, extra breathing room around a hero row).
|
|
109
109
|
- `justify` (optional): `"start"` | `"center"` | `"end"` | `"between"` | `"around"`
|
|
110
110
|
- `columns` (optional, horizontal only): `2`–`6` — CSS grid with equal columns (mixed children or layout that needs fixed column counts).
|
|
111
111
|
- Children are element IDs
|
|
@@ -172,6 +172,16 @@ Bound to buttons via `on.press`:
|
|
|
172
172
|
| `send_token` | `token`, `amount?`, `recipientFid?`, `recipientAddress?` | Send token flow |
|
|
173
173
|
| `swap_token` | `sellToken?`, `buyToken?` | Swap token flow |
|
|
174
174
|
|
|
175
|
+
## Authenticated requests
|
|
176
|
+
|
|
177
|
+
POST requests carry a JFS envelope (JSON object **or** compact dot-separated string). `parseRequest` validates the payload and (unless `skipJFSVerification`) cryptographically verifies the JFS against an active hub signer for `user.fid`. On the server, `ctx.action.user.fid` is **always present and verified** for `type === "post"`.
|
|
178
|
+
|
|
179
|
+
GET requests MAY include optional viewer identity in the `X-Snap-Payload` request header (a JFS compact string with the same shape as POST minus `inputs` and `fid`). When present and valid, `ctx.action` on GET MAY include `user`, `timestamp`, `audience`, and `surface`.
|
|
180
|
+
|
|
181
|
+
`ctx.action.user` on GET is **best-effort and never guaranteed** — older or custom clients, cache layers, crawlers, and `curl` may yield an anonymous GET even for users who have POSTed to this snap before. Always render a working anonymous first load; treat viewer fields on GET as a strict enhancement. `parseRequest` (and `@farcaster/snap-hono`'s GET handler) silently fall back to anonymous `{ type: "get" }` when `X-Snap-Payload` is missing or invalid.
|
|
182
|
+
|
|
183
|
+
When responses depend on viewer identity, send `Vary: Accept, X-Snap-Payload` and consider `Cache-Control: private` so caches don't serve viewer-specific bodies to other viewers (`@farcaster/snap-hono` sets `Vary` automatically).
|
|
184
|
+
|
|
175
185
|
## Icon Names (34)
|
|
176
186
|
|
|
177
187
|
`arrow-right`, `arrow-left`, `external-link`, `chevron-right`, `check`, `x`, `alert-triangle`, `info`, `clock`, `heart`, `message-circle`, `repeat`, `share`, `user`, `users`, `star`, `trophy`, `zap`, `flame`, `gift`, `image`, `play`, `pause`, `wallet`, `coins`, `plus`, `minus`, `refresh-cw`, `bookmark`, `thumbs-up`, `thumbs-down`, `trending-up`, `trending-down`
|
package/package.json
CHANGED
package/src/colors.ts
CHANGED
|
@@ -38,6 +38,33 @@ export const PALETTE_COLOR_VALUES = [
|
|
|
38
38
|
|
|
39
39
|
export type PaletteColor = (typeof PALETTE_COLOR_VALUES)[number];
|
|
40
40
|
|
|
41
|
+
/** Strict `#rrggbb` literal used by cell_grid (and clients that accept hex). */
|
|
42
|
+
const SNAP_HEX_6 = /^#[0-9a-fA-F]{6}$/;
|
|
43
|
+
|
|
44
|
+
export function isSnapHexColorString(s: string): boolean {
|
|
45
|
+
return SNAP_HEX_6.test(s.trim());
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Resolve a snap color token for inline styles: `accent`, palette names, or
|
|
50
|
+
* literal `#rrggbb`. Unknown values fall back to `accentHex` (same as legacy
|
|
51
|
+
* `colorHex` behavior for non-hex strings).
|
|
52
|
+
*/
|
|
53
|
+
export function resolveSnapColorHex(
|
|
54
|
+
color: string | undefined,
|
|
55
|
+
opts: { accentHex: string; appearance: "light" | "dark" },
|
|
56
|
+
): string {
|
|
57
|
+
if (!color || color === PALETTE_COLOR_ACCENT) return opts.accentHex;
|
|
58
|
+
const trimmed = color.trim();
|
|
59
|
+
if (isSnapHexColorString(trimmed)) return trimmed;
|
|
60
|
+
const map =
|
|
61
|
+
opts.appearance === "dark" ? PALETTE_DARK_HEX : PALETTE_LIGHT_HEX;
|
|
62
|
+
if (Object.hasOwn(map, trimmed)) {
|
|
63
|
+
return map[trimmed as PaletteColor];
|
|
64
|
+
}
|
|
65
|
+
return opts.accentHex;
|
|
66
|
+
}
|
|
67
|
+
|
|
41
68
|
/** Light-mode hex for each palette color (emulator / reference client). */
|
|
42
69
|
export const PALETTE_LIGHT_HEX: Record<PaletteColor, string> = {
|
|
43
70
|
gray: "#6E6A86",
|
package/src/constants.ts
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
export const SPEC_VERSION_1 = "1.0" as const;
|
|
2
2
|
export const SPEC_VERSION_2 = "2.0" as const;
|
|
3
3
|
export const SPEC_VERSION = SPEC_VERSION_2;
|
|
4
|
-
export const SUPPORTED_SPEC_VERSIONS = [
|
|
4
|
+
export const SUPPORTED_SPEC_VERSIONS = [
|
|
5
|
+
SPEC_VERSION_1,
|
|
6
|
+
SPEC_VERSION_2,
|
|
7
|
+
] as const;
|
|
5
8
|
export type SpecVersion = (typeof SUPPORTED_SPEC_VERSIONS)[number];
|
|
6
9
|
|
|
10
|
+
export const SNAP_PAYLOAD_HEADER = "X-Snap-Payload" as const;
|
|
11
|
+
|
|
7
12
|
export const MEDIA_TYPE = "application/vnd.farcaster.snap+json" as const;
|
|
8
13
|
|
|
9
14
|
export const EFFECT_VALUES = ["confetti"] as const;
|
package/src/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ export {
|
|
|
8
8
|
SPEC_VERSION_2,
|
|
9
9
|
SUPPORTED_SPEC_VERSIONS,
|
|
10
10
|
type SpecVersion,
|
|
11
|
+
SNAP_PAYLOAD_HEADER,
|
|
11
12
|
MEDIA_TYPE,
|
|
12
13
|
EFFECT_VALUES,
|
|
13
14
|
POST_GRID_TAP_KEY,
|
|
@@ -23,6 +24,8 @@ export {
|
|
|
23
24
|
PALETTE_COLOR_VALUES,
|
|
24
25
|
PALETTE_LIGHT_HEX,
|
|
25
26
|
PALETTE_DARK_HEX,
|
|
27
|
+
isSnapHexColorString,
|
|
28
|
+
resolveSnapColorHex,
|
|
26
29
|
type PaletteColor,
|
|
27
30
|
} from "./colors";
|
|
28
31
|
export {
|
|
@@ -30,7 +33,9 @@ export {
|
|
|
30
33
|
ACTION_TYPE_POST,
|
|
31
34
|
snapResponseSchema,
|
|
32
35
|
payloadSchema,
|
|
36
|
+
getPayloadSchema,
|
|
33
37
|
type SnapAction,
|
|
38
|
+
type SnapGetAction,
|
|
34
39
|
type SnapContext,
|
|
35
40
|
type SnapResponse,
|
|
36
41
|
type SnapHandlerResult,
|
|
@@ -38,5 +43,6 @@ export {
|
|
|
38
43
|
type SnapSpecInput,
|
|
39
44
|
type SnapFunction,
|
|
40
45
|
type SnapPayload,
|
|
46
|
+
type SnapGetPayload,
|
|
41
47
|
} from "./schemas";
|
|
42
48
|
export { validateSnapResponse, type ValidationResult } from "./validator";
|