@farcaster/snap 2.1.2 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/colors.d.ts +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/hooks/use-snap-colors.js +2 -9
- 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/ui/catalog.d.ts +2 -2
- package/dist/ui/catalog.js +1 -1
- package/dist/ui/cell-grid.d.ts +2 -2
- package/dist/ui/cell-grid.js +11 -2
- package/dist/verify.test.js +3 -3
- package/llms.txt +11 -1
- 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/hooks/use-snap-colors.ts +3 -8
- 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/ui/catalog.ts +1 -1
- package/src/ui/cell-grid.ts +15 -2
- package/src/verify.test.ts +3 -3
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,7 +15,7 @@ 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
20
|
}, z.core.$strip>>;
|
|
21
21
|
gap: z.ZodOptional<z.ZodEnum<{
|
package/dist/ui/cell-grid.js
CHANGED
|
@@ -1,10 +1,19 @@
|
|
|
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(),
|
|
9
18
|
});
|
|
10
19
|
export const cellGridProps = z
|
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,7 +95,7 @@ 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 }`
|
|
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
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`; `on.press` is ignored.
|
|
@@ -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";
|
|
@@ -4,8 +4,7 @@ import { useMemo } from "react";
|
|
|
4
4
|
import { useStateStore } from "@json-render/react";
|
|
5
5
|
import { resolveSnapPaletteHex } from "../lib/resolve-palette-hex";
|
|
6
6
|
import { useSnapPreviewPageAccent, useSnapAppearance } from "../accent-context";
|
|
7
|
-
import
|
|
8
|
-
import { PALETTE_DARK_HEX, PALETTE_LIGHT_HEX } from "@farcaster/snap";
|
|
7
|
+
import { resolveSnapColorHex } from "@farcaster/snap";
|
|
9
8
|
|
|
10
9
|
/** Readable foreground color (black or white) for a given hex background. */
|
|
11
10
|
export function pickForegroundForBg(hex: string): string {
|
|
@@ -76,7 +75,6 @@ function buildSnapColors(
|
|
|
76
75
|
const accent = resolveSnapPaletteHex(accentName, mode);
|
|
77
76
|
const accentFg = pickForegroundForBg(accent);
|
|
78
77
|
const neutrals = mode === "dark" ? NEUTRAL_DARK : NEUTRAL_LIGHT;
|
|
79
|
-
const paletteMap = mode === "dark" ? PALETTE_DARK_HEX : PALETTE_LIGHT_HEX;
|
|
80
78
|
|
|
81
79
|
const accentHover =
|
|
82
80
|
mode === "light"
|
|
@@ -87,11 +85,8 @@ function buildSnapColors(
|
|
|
87
85
|
|
|
88
86
|
const paletteHex = (name: string) => resolveSnapPaletteHex(name, mode);
|
|
89
87
|
|
|
90
|
-
const colorHex = (name: string | undefined) =>
|
|
91
|
-
|
|
92
|
-
if (Object.hasOwn(paletteMap, name)) return paletteMap[name as PaletteColor];
|
|
93
|
-
return accent;
|
|
94
|
-
};
|
|
88
|
+
const colorHex = (name: string | undefined) =>
|
|
89
|
+
resolveSnapColorHex(name, { accentHex: accent, appearance: mode });
|
|
95
90
|
|
|
96
91
|
return {
|
|
97
92
|
accent,
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
PALETTE_COLOR_VALUES,
|
|
4
4
|
PALETTE_LIGHT_HEX,
|
|
5
5
|
PALETTE_DARK_HEX,
|
|
6
|
+
resolveSnapColorHex,
|
|
6
7
|
type PaletteColor,
|
|
7
8
|
} from "@farcaster/snap";
|
|
8
9
|
import { useStateStore } from "@json-render/react-native";
|
|
@@ -35,7 +36,7 @@ export function useSnapPalette() {
|
|
|
35
36
|
const accentHex = resolveHex(accentName, mode);
|
|
36
37
|
|
|
37
38
|
const hex = (semantic: string) =>
|
|
38
|
-
semantic
|
|
39
|
+
resolveSnapColorHex(semantic, { accentHex, appearance: mode });
|
|
39
40
|
|
|
40
41
|
return { appearance: mode, accentName, accentHex, hex };
|
|
41
42
|
}
|
package/src/schemas.ts
CHANGED
|
@@ -104,26 +104,36 @@ const surfaceSchema = z.discriminatedUnion("type", [
|
|
|
104
104
|
standaloneSurfaceSchema,
|
|
105
105
|
]);
|
|
106
106
|
|
|
107
|
+
const fidSchema = z.number().int().nonnegative();
|
|
108
|
+
const userSchema = z.object({ fid: fidSchema });
|
|
109
|
+
|
|
107
110
|
export const payloadSchema = z
|
|
108
111
|
.object({
|
|
109
|
-
fid:
|
|
112
|
+
fid: fidSchema.optional(), // deprecated in favor of user.fid
|
|
110
113
|
inputs: z.record(z.string(), postInputValueSchema).default({}),
|
|
111
114
|
timestamp: z.number().int(),
|
|
112
115
|
audience: z.string(),
|
|
113
|
-
user:
|
|
114
|
-
fid: z.number().int().nonnegative(),
|
|
115
|
-
}),
|
|
116
|
+
user: userSchema,
|
|
116
117
|
surface: surfaceSchema,
|
|
117
118
|
})
|
|
118
119
|
.strip();
|
|
119
120
|
|
|
120
121
|
export type SnapPayload = z.infer<typeof payloadSchema>;
|
|
121
122
|
|
|
123
|
+
/** JFS payload shape for POST minus deprecated `fid`; used for GET auth via payload header. */
|
|
124
|
+
export const getPayloadSchema = payloadSchema.omit({ inputs: true, fid: true });
|
|
125
|
+
|
|
126
|
+
export type SnapGetPayload = z.infer<typeof getPayloadSchema>;
|
|
127
|
+
|
|
122
128
|
export const ACTION_TYPE_GET = "get" as const;
|
|
123
129
|
export const ACTION_TYPE_POST = "post" as const;
|
|
124
130
|
|
|
125
131
|
const snapGetActionSchema = z.object({
|
|
126
132
|
type: z.literal(ACTION_TYPE_GET),
|
|
133
|
+
user: userSchema.optional(),
|
|
134
|
+
timestamp: z.number().int().optional(),
|
|
135
|
+
audience: z.string().optional(),
|
|
136
|
+
surface: surfaceSchema.optional(),
|
|
127
137
|
});
|
|
128
138
|
|
|
129
139
|
export type SnapGetAction = z.infer<typeof snapGetActionSchema>;
|
package/src/server/index.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export {
|
|
2
|
+
verifyJFS as verifyJFSRequestBody, // deprecated alias. drop in v3
|
|
3
|
+
parseJfs,
|
|
4
|
+
verifyJFS,
|
|
5
|
+
decodePayload,
|
|
6
|
+
encodePayload,
|
|
7
|
+
} from "./verify";
|
|
2
8
|
export {
|
|
3
9
|
DEFAULT_SNAP_HUB_HTTP_BASE_URL,
|
|
4
10
|
getActiveEd25519SignerKeysFromHubHttp,
|
|
@@ -1,11 +1,15 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
1
2
|
import {
|
|
2
3
|
ACTION_TYPE_GET,
|
|
3
4
|
ACTION_TYPE_POST,
|
|
5
|
+
getPayloadSchema,
|
|
4
6
|
payloadSchema,
|
|
5
7
|
type SnapAction,
|
|
8
|
+
type SnapPayload,
|
|
9
|
+
type SnapGetPayload,
|
|
6
10
|
} from "../schemas";
|
|
7
|
-
import { decodePayload,
|
|
8
|
-
import {
|
|
11
|
+
import { decodePayload, parseJfs, verifyJFS } from "./verify";
|
|
12
|
+
import { SNAP_PAYLOAD_HEADER } from "../constants";
|
|
9
13
|
|
|
10
14
|
const DEFAULT_SNAP_POST_MAX_SKEW_SECONDS = 300 as const;
|
|
11
15
|
|
|
@@ -62,98 +66,156 @@ export type ParseRequestResult =
|
|
|
62
66
|
| { success: true; action: SnapAction }
|
|
63
67
|
| { success: false; error: ParseRequestError };
|
|
64
68
|
|
|
65
|
-
const requestBodySchema = z.object({
|
|
66
|
-
header: z.string(),
|
|
67
|
-
payload: z.string(),
|
|
68
|
-
signature: z.string(),
|
|
69
|
-
});
|
|
70
|
-
|
|
71
69
|
/**
|
|
72
70
|
* Parse and validate Farcaster snap requests:
|
|
73
|
-
* - `GET
|
|
74
|
-
*
|
|
71
|
+
* - `GET`: returns `{ type: "get" }`, or optional viewer fields when `X-Snap-Payload`
|
|
72
|
+
* carries a JFS compact string whose decoded payload validates against {@link getPayloadSchema}.
|
|
73
|
+
* - `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.
|
|
75
74
|
*/
|
|
76
75
|
export async function parseRequest(
|
|
77
76
|
request: Request,
|
|
78
77
|
options: ParseRequestOptions = {},
|
|
79
78
|
): Promise<ParseRequestResult> {
|
|
80
|
-
if (
|
|
81
|
-
return
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
message: `expected POST, received ${request.method}`,
|
|
86
|
-
},
|
|
87
|
-
};
|
|
79
|
+
if (request.method === "GET") {
|
|
80
|
+
return await parseGetRequest(request, options);
|
|
81
|
+
}
|
|
82
|
+
if (request.method === "POST") {
|
|
83
|
+
return await parsePostRequest(request, options);
|
|
88
84
|
}
|
|
85
|
+
return {
|
|
86
|
+
success: false,
|
|
87
|
+
error: {
|
|
88
|
+
type: "method_not_allowed",
|
|
89
|
+
message: `expected GET or POST, received ${request.method}`,
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
89
93
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
94
|
+
async function parseGetRequest(
|
|
95
|
+
request: Request,
|
|
96
|
+
options: ParseRequestOptions,
|
|
97
|
+
): Promise<ParseRequestResult> {
|
|
98
|
+
const compactHeader = request.headers.get(SNAP_PAYLOAD_HEADER)?.trim();
|
|
99
|
+
if (!compactHeader) {
|
|
100
|
+
return { success: true, action: { type: ACTION_TYPE_GET } };
|
|
95
101
|
}
|
|
96
102
|
|
|
97
|
-
const
|
|
98
|
-
|
|
103
|
+
const result = await validateJfsPayload({
|
|
104
|
+
jfsText: compactHeader,
|
|
105
|
+
schema: getPayloadSchema,
|
|
106
|
+
request,
|
|
107
|
+
options,
|
|
108
|
+
invalidJsonMessage: `${SNAP_PAYLOAD_HEADER} must be a valid JFS compact string`,
|
|
109
|
+
});
|
|
110
|
+
if (!result.ok) {
|
|
111
|
+
return { success: false, error: result.error };
|
|
112
|
+
}
|
|
99
113
|
|
|
100
|
-
|
|
114
|
+
return {
|
|
115
|
+
success: true,
|
|
116
|
+
action: { type: ACTION_TYPE_GET, ...result.payload },
|
|
117
|
+
};
|
|
118
|
+
}
|
|
101
119
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
120
|
+
async function parsePostRequest(
|
|
121
|
+
request: Request,
|
|
122
|
+
options: ParseRequestOptions,
|
|
123
|
+
): Promise<ParseRequestResult> {
|
|
124
|
+
const result = await validateJfsPayload({
|
|
125
|
+
jfsText: await request.text(),
|
|
126
|
+
schema: payloadSchema,
|
|
127
|
+
request,
|
|
128
|
+
options,
|
|
129
|
+
});
|
|
130
|
+
if (!result.ok) {
|
|
131
|
+
return { success: false, error: result.error };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const payload = result.payload;
|
|
135
|
+
if (payload.fid !== undefined && payload.fid !== payload.user.fid) {
|
|
106
136
|
return {
|
|
107
137
|
success: false,
|
|
108
138
|
error: {
|
|
109
|
-
type: "
|
|
110
|
-
message: "
|
|
139
|
+
type: "fid_mismatch",
|
|
140
|
+
message: `fid "${payload.fid}" does not match user.fid "${payload.user.fid}"`,
|
|
111
141
|
},
|
|
112
142
|
};
|
|
113
143
|
}
|
|
114
144
|
|
|
115
|
-
|
|
116
|
-
|
|
145
|
+
return {
|
|
146
|
+
success: true,
|
|
147
|
+
action: { type: ACTION_TYPE_POST, ...payload },
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Shared pipeline for authenticated snap requests: parse the JFS envelope,
|
|
153
|
+
* decode and schema-validate the payload, optionally verify the JFS signature
|
|
154
|
+
* against an active hub signer (matching `user.fid`), then check timestamp
|
|
155
|
+
* skew and that `audience` matches the request origin.
|
|
156
|
+
*
|
|
157
|
+
* Both GET (payload header) and POST (request body) feed into this.
|
|
158
|
+
*/
|
|
159
|
+
async function validateJfsPayload<T extends SnapPayload | SnapGetPayload>({
|
|
160
|
+
jfsText,
|
|
161
|
+
schema,
|
|
162
|
+
request,
|
|
163
|
+
options,
|
|
164
|
+
invalidJsonMessage,
|
|
165
|
+
}: {
|
|
166
|
+
jfsText: string;
|
|
167
|
+
schema: z.ZodType<T>;
|
|
168
|
+
request: Request;
|
|
169
|
+
options: ParseRequestOptions;
|
|
170
|
+
invalidJsonMessage?: string;
|
|
171
|
+
}): Promise<
|
|
172
|
+
{ ok: true; payload: T } | { ok: false; error: ParseRequestError }
|
|
173
|
+
> {
|
|
174
|
+
const parsed = parseJfs(jfsText);
|
|
175
|
+
if (!parsed.ok) {
|
|
117
176
|
return {
|
|
118
|
-
|
|
119
|
-
error: {
|
|
177
|
+
ok: false,
|
|
178
|
+
error: {
|
|
179
|
+
type: "invalid_json",
|
|
180
|
+
message: invalidJsonMessage ?? parsed.error,
|
|
181
|
+
},
|
|
120
182
|
};
|
|
121
183
|
}
|
|
184
|
+
const jfs = parsed.jfs;
|
|
122
185
|
|
|
123
|
-
const payloadParsed =
|
|
124
|
-
decodePayload(parsed.data.payload),
|
|
125
|
-
);
|
|
186
|
+
const payloadParsed = schema.safeParse(decodePayload(jfs.payload));
|
|
126
187
|
if (!payloadParsed.success) {
|
|
127
188
|
return {
|
|
128
|
-
|
|
189
|
+
ok: false,
|
|
129
190
|
error: { type: "validation", issues: payloadParsed.error.issues },
|
|
130
191
|
};
|
|
131
192
|
}
|
|
132
|
-
|
|
133
|
-
const body = payloadParsed.data;
|
|
193
|
+
const payload = payloadParsed.data;
|
|
134
194
|
|
|
135
195
|
if (!options.skipJFSVerification) {
|
|
136
|
-
const
|
|
137
|
-
if (!
|
|
196
|
+
const verified = await verifyJFS(jfs);
|
|
197
|
+
if (!verified.valid) {
|
|
138
198
|
return {
|
|
139
|
-
|
|
140
|
-
error: { type: "signature", message:
|
|
199
|
+
ok: false,
|
|
200
|
+
error: { type: "signature", message: verified.error.message },
|
|
141
201
|
};
|
|
142
202
|
}
|
|
143
|
-
if (
|
|
203
|
+
if (verified.signingUserFid !== payload.user.fid) {
|
|
144
204
|
return {
|
|
145
|
-
|
|
205
|
+
ok: false,
|
|
146
206
|
error: {
|
|
147
207
|
type: "fid_mismatch",
|
|
148
|
-
message: `JFS header fid "${
|
|
208
|
+
message: `JFS header fid "${verified.signingUserFid}" does not match user.fid "${payload.user.fid}"`,
|
|
149
209
|
},
|
|
150
210
|
};
|
|
151
211
|
}
|
|
152
212
|
}
|
|
153
213
|
|
|
154
|
-
|
|
214
|
+
const maxSkew = options.maxSkewSeconds ?? DEFAULT_SNAP_POST_MAX_SKEW_SECONDS;
|
|
215
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
216
|
+
if (Math.abs(nowSec - payload.timestamp) > maxSkew) {
|
|
155
217
|
return {
|
|
156
|
-
|
|
218
|
+
ok: false,
|
|
157
219
|
error: {
|
|
158
220
|
type: "replay",
|
|
159
221
|
message: `timestamp outside allowed skew of ${maxSkew}s`,
|
|
@@ -176,31 +238,15 @@ export async function parseRequest(
|
|
|
176
238
|
}
|
|
177
239
|
}
|
|
178
240
|
|
|
179
|
-
if (expectedOrigin !== undefined &&
|
|
241
|
+
if (expectedOrigin !== undefined && payload.audience !== expectedOrigin) {
|
|
180
242
|
return {
|
|
181
|
-
|
|
243
|
+
ok: false,
|
|
182
244
|
error: {
|
|
183
245
|
type: "origin_mismatch",
|
|
184
|
-
message: `payload audience "${
|
|
246
|
+
message: `payload audience "${payload.audience}" does not match expected origin "${expectedOrigin}"`,
|
|
185
247
|
},
|
|
186
248
|
};
|
|
187
249
|
}
|
|
188
250
|
|
|
189
|
-
|
|
190
|
-
return {
|
|
191
|
-
success: false,
|
|
192
|
-
error: {
|
|
193
|
-
type: "fid_mismatch",
|
|
194
|
-
message: `fid "${body.fid}" does not match user.fid "${body.user.fid}"`,
|
|
195
|
-
},
|
|
196
|
-
};
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
return {
|
|
200
|
-
success: true,
|
|
201
|
-
action: {
|
|
202
|
-
type: ACTION_TYPE_POST,
|
|
203
|
-
...body,
|
|
204
|
-
},
|
|
205
|
-
};
|
|
251
|
+
return { ok: true, payload };
|
|
206
252
|
}
|