diff --git a/framegear/app/api/postFrame/route.ts b/framegear/app/api/postFrame/route.ts new file mode 100644 index 0000000000..cf4a5b0e78 --- /dev/null +++ b/framegear/app/api/postFrame/route.ts @@ -0,0 +1,18 @@ +import { NextRequest } from 'next/server'; +import { getDebugFrameRequest } from '@coinbase/onchainkit'; + +export async function POST(req: NextRequest) { + const data = await req.json(); + const { frameData, options } = data; + const postUrl = frameData.url; + const debugPayload = getDebugFrameRequest({ untrustedData: frameData }, options); + + const res = await fetch(postUrl, { + method: 'POST', + body: JSON.stringify(debugPayload), + }); + + const html = await res.text(); + + return Response.json({ html }); +} diff --git a/framegear/components/Frame/Frame.tsx b/framegear/components/Frame/Frame.tsx index 1fc44a03cd..2054ea5cf9 100644 --- a/framegear/components/Frame/Frame.tsx +++ b/framegear/components/Frame/Frame.tsx @@ -1,6 +1,7 @@ +import { postFrame } from '@/utils/postFrame'; import { frameResultsAtom } from '@/utils/store'; import { useAtom } from 'jotai'; -import { PropsWithChildren, useMemo } from 'react'; +import { PropsWithChildren, useCallback, useMemo } from 'react'; export function Frame() { const [results] = useAtom(frameResultsAtom); @@ -23,11 +24,14 @@ function ValidFrame({ tags }: { tags: Record }) { const image = tags['fc:frame:image']; const imageAspectRatio = tags['fc:frame:image:aspect_ratio'] === '1:1' ? '1/1' : '1.91/1'; const input = tags['fc:frame:input:text']; - // TODO: when debugger is live we will also need to extract actions, etc. const buttons = [1, 2, 3, 4].map((index) => { const key = `fc:frame:button:${index}`; + const actionKey = `${key}:action`; + const targetKey = `${key}:target`; const value = tags[key]; - return value ? { key, value } : undefined; + const action = tags[actionKey] || 'post'; + const target = tags[targetKey] || tags['fc:frame:post_url']; + return value ? { key, value, action, target, index } : undefined; }); return { image, @@ -51,7 +55,11 @@ function ValidFrame({ tags }: { tags: Record }) { )}
{buttons.map((button) => - button ? {button.value} : null, + button ? ( + + {button.value} + + ) : null, )}
@@ -77,12 +85,41 @@ function PlaceholderFrame() { ); } -function FrameButton({ children }: PropsWithChildren<{}>) { +function FrameButton({ + children, + button, +}: PropsWithChildren<{ + button?: { key: string; value: string; action: string; target: string; index: number }; +}>) { + const [_, setResults] = useAtom(frameResultsAtom); + const handleClick = useCallback(async () => { + if (button?.action === 'post') { + // TODO: collect user options (follow, like, etc.) and include + const result = await postFrame({ + buttonIndex: button.index, + url: button.target, + // TODO: make these user-input-driven + castId: { + fid: 0, + hash: '0xthisisnotreal', + }, + inputText: '', + fid: 0, + messageHash: '0xthisisnotreal', + network: 0, + timestamp: 0, + }); + setResults((prev) => [...prev, result]); + return; + } + // TODO: implement other actions + }, [button, setResults]); return ( diff --git a/framegear/package.json b/framegear/package.json index c937b23473..5267d8a261 100644 --- a/framegear/package.json +++ b/framegear/package.json @@ -10,6 +10,7 @@ "test": "jest" }, "dependencies": { + "@coinbase/onchainkit": "portal:./..", "@radix-ui/react-icons": "^1.3.0", "jotai": "^2.6.4", "next": "14.1.0", diff --git a/framegear/utils/fetchFrame.ts b/framegear/utils/fetchFrame.ts index 97a671b11a..5f0f4e192b 100644 --- a/framegear/utils/fetchFrame.ts +++ b/framegear/utils/fetchFrame.ts @@ -1,4 +1,4 @@ -import { vNextSchema } from './validation'; +import { parseHtml } from './parseHtml'; export async function fetchFrame(url: string) { const response = await fetch('/api/getFrame', { @@ -13,47 +13,3 @@ export async function fetchFrame(url: string) { const html = json.html; return parseHtml(html); } - -function parseHtml(html: string) { - const document = new DOMParser().parseFromString(html, 'text/html'); - - const ogImage = document.querySelectorAll(`[property='og:image']`); - - // According to spec, keys on the metatags should be on "property", but there are examples - // in the wild where they're on "name". Process name tags first so that property tags take - // precedence. - const frameMetaTagsProperty = document.querySelectorAll(`[property^='fc:frame']`); - const frameMetaTagsName = document.querySelectorAll(`[name^='fc:frame']`); - - const nameTags = [...frameMetaTagsName]; - const propertyTags = [...ogImage, ...frameMetaTagsProperty]; - const tags: Record = {}; - - function processTag(tag: Element, keyName: 'property' | 'name') { - const key = tag.getAttribute(keyName); - const value = tag.getAttribute('content'); - if (key && value) { - tags[key] = value; - } - } - nameTags.forEach((t) => processTag(t, 'name')); - propertyTags.forEach((t) => processTag(t, 'property')); - - const isValid = vNextSchema.isValidSync(tags); - const errors = aggregateValidationErrors(tags); - - return { isValid, errors, tags }; -} - -function aggregateValidationErrors(tags: Record) { - try { - vNextSchema.validateSync(tags, { abortEarly: false }); - } catch (e) { - const errors: Record = {}; - (e as any).inner.forEach((error: any) => { - errors[error.path as string] = error.message; - }); - return errors; - } - return {}; -} diff --git a/framegear/utils/parseHtml.ts b/framegear/utils/parseHtml.ts new file mode 100644 index 0000000000..0913ed88b8 --- /dev/null +++ b/framegear/utils/parseHtml.ts @@ -0,0 +1,45 @@ +import { vNextSchema } from './validation'; + +export function parseHtml(html: string) { + const document = new DOMParser().parseFromString(html, 'text/html'); + + const ogImage = document.querySelectorAll(`[property='og:image']`); + + // According to spec, keys on the metatags should be on "property", but there are examples + // in the wild where they're on "name". Process name tags first so that property tags take + // precedence. + const frameMetaTagsProperty = document.querySelectorAll(`[property^='fc:frame']`); + const frameMetaTagsName = document.querySelectorAll(`[name^='fc:frame']`); + + const nameTags = [...frameMetaTagsName]; + const propertyTags = [...ogImage, ...frameMetaTagsProperty]; + const tags: Record = {}; + + function processTag(tag: Element, keyName: 'property' | 'name') { + const key = tag.getAttribute(keyName); + const value = tag.getAttribute('content'); + if (key && value) { + tags[key] = value; + } + } + nameTags.forEach((t) => processTag(t, 'name')); + propertyTags.forEach((t) => processTag(t, 'property')); + + const isValid = vNextSchema.isValidSync(tags); + const errors = aggregateValidationErrors(tags); + + return { isValid, errors, tags }; +} + +function aggregateValidationErrors(tags: Record) { + try { + vNextSchema.validateSync(tags, { abortEarly: false }); + } catch (e) { + const errors: Record = {}; + (e as any).inner.forEach((error: any) => { + errors[error.path as string] = error.message; + }); + return errors; + } + return {}; +} diff --git a/framegear/utils/postFrame.ts b/framegear/utils/postFrame.ts new file mode 100644 index 0000000000..2353606c74 --- /dev/null +++ b/framegear/utils/postFrame.ts @@ -0,0 +1,21 @@ +import { FrameRequest, DebugFrameRequestOptions } from '@coinbase/onchainkit'; +import { parseHtml } from './parseHtml'; + +type FrameData = FrameRequest['untrustedData']; + +export async function postFrame(frameData: FrameData, options?: DebugFrameRequestOptions) { + // TODO: handle exceptional cases + const res = await fetch('/api/postFrame', { + body: JSON.stringify({ + frameData, + options, + }), + method: 'POST', + headers: { + contentType: 'application/json', + }, + }); + const json = await res.json(); + const html = json.html; + return parseHtml(html); +} diff --git a/framegear/yarn.lock b/framegear/yarn.lock index 0c2bd3b7e5..bf29fb3f10 100644 --- a/framegear/yarn.lock +++ b/framegear/yarn.lock @@ -438,6 +438,18 @@ __metadata: languageName: node linkType: hard +"@coinbase/onchainkit@portal:./..::locator=framegear%40workspace%3A.": + version: 0.0.0-use.local + resolution: "@coinbase/onchainkit@portal:./..::locator=framegear%40workspace%3A." + peerDependencies: + graphql: ^14 + graphql-request: ^6 + react: ^18 + react-dom: ^18 + viem: ^2.7.0 + languageName: node + linkType: soft + "@cspotcode/source-map-support@npm:^0.8.0": version: 0.8.1 resolution: "@cspotcode/source-map-support@npm:0.8.1" @@ -3179,6 +3191,7 @@ __metadata: version: 0.0.0-use.local resolution: "framegear@workspace:." dependencies: + "@coinbase/onchainkit": "portal:./.." "@radix-ui/react-icons": "npm:^1.3.0" "@testing-library/jest-dom": "npm:^6.4.2" "@testing-library/react": "npm:^14.2.1" diff --git a/src/core/getFrameMessage.test.ts b/src/core/getFrameMessage.test.ts index bfa2a90cd6..698c950ad4 100644 --- a/src/core/getFrameMessage.test.ts +++ b/src/core/getFrameMessage.test.ts @@ -3,6 +3,7 @@ import { getFrameMessage } from './getFrameMessage'; import { neynarBulkUserLookup } from '../utils/neynar/user/neynarUserFunctions'; import { FrameRequest } from './types'; import { neynarFrameValidation } from '../utils/neynar/frame/neynarFrameFunctions'; +import { getMockFrameRequest } from './getMockFrameRequest'; jest.mock('../utils/neynar/user/neynarUserFunctions', () => { return { @@ -24,6 +25,68 @@ describe('getFrameValidatedMessage', () => { expect(result?.isValid).toEqual(false); }); + it('should consider invalid non-mock requests as invalid, even if mock requests are allowed', async () => { + const result = await getFrameMessage( + { + trustedData: { messageBytes: 'invalid' }, + } as FrameRequest, + { allowDebug: true }, + ); + expect(result?.isValid).toEqual(false); + expect(result.message).toBeUndefined(); + }); + + it('should consider mock messages valid, if allowed', async () => { + const result = await getFrameMessage( + getMockFrameRequest({ + untrustedData: { + buttonIndex: 1, + castId: { + fid: 0, + hash: '0xthisisnotreal', + }, + inputText: '', + fid: 0, + network: 0, + messageHash: '0xthisisnotreal', + timestamp: 0, + url: 'https://localhost:3000', + }, + trustedData: { + messageBytes: '0xthisisnotreal', + }, + }), + { allowDebug: true }, + ); + expect(result?.isValid).toEqual(true); + expect(result.message?.button).toEqual(1); + }); + + it('should consider mock messages invalid, if not allowed (default)', async () => { + const result = await getFrameMessage( + getMockFrameRequest({ + untrustedData: { + buttonIndex: 1, + castId: { + fid: 0, + hash: '0xthisisnotreal', + }, + inputText: '', + fid: 0, + network: 0, + messageHash: '0xthisisnotreal', + timestamp: 0, + url: 'https://localhost:3000', + }, + trustedData: { + messageBytes: '0xthisisnotreal', + }, + }), + ); + expect(result?.isValid).toEqual(false); + expect(result.message).toBeUndefined(); + }); + it('should return the message if the message is valid', async () => { const fid = 1234; const addresses = ['0xaddr1']; diff --git a/src/core/getFrameMessage.ts b/src/core/getFrameMessage.ts index 8450fd55a1..bad8b1c95b 100644 --- a/src/core/getFrameMessage.ts +++ b/src/core/getFrameMessage.ts @@ -1,4 +1,4 @@ -import { FrameRequest, FrameValidationResponse } from './types'; +import { FrameRequest, FrameValidationResponse, MockFrameRequest } from './types'; import { NEYNAR_DEFAULT_API_KEY, neynarFrameValidation, @@ -9,6 +9,7 @@ type FrameMessageOptions = neynarApiKey?: string; castReactionContext?: boolean; followContext?: boolean; + allowDebug?: boolean; } | undefined; @@ -20,9 +21,19 @@ type FrameMessageOptions = * @param body The JSON received by server on frame callback */ async function getFrameMessage( - body: FrameRequest, + body: FrameRequest | MockFrameRequest, messageOptions?: FrameMessageOptions, ): Promise { + // Skip validation only when allowed and when receiving a debug request + if (messageOptions?.allowDebug) { + if ((body as MockFrameRequest).onchainkitDebug) { + return { + isValid: true, + message: (body as MockFrameRequest).onchainkitDebug, + }; + } + } + // Validate the message const response = await neynarFrameValidation( body?.trustedData?.messageBytes, diff --git a/src/core/getMockFrameRequest.ts b/src/core/getMockFrameRequest.ts new file mode 100644 index 0000000000..726057d699 --- /dev/null +++ b/src/core/getMockFrameRequest.ts @@ -0,0 +1,37 @@ +import { FrameRequest, MockFrameRequest, MockFrameRequestOptions } from './types'; + +/** + * Modify a standard frame request to include simulated values (e.g., indicate the viewer + * follows the cast author) for development/debugging purposes. + * @param request A standard frame request. + * @param options An object containing values we will pretend are real for the purposes of debugging. + * @returns + */ +function getMockFrameRequest( + request: FrameRequest, + options?: MockFrameRequestOptions, +): MockFrameRequest { + return { + ...request, + onchainkitDebug: { + button: request.untrustedData.buttonIndex, + input: request.untrustedData.inputText, + following: !!options?.following, + interactor: { + fid: options?.interactor?.fid || 0, + custody_address: options?.interactor?.custody_address || '0xnotarealaddress', + verified_accounts: options?.interactor?.verified_accounts || [], + }, + liked: !!options?.liked, + recasted: !!options?.recasted, + valid: true, + raw: { + valid: true, + // TODO: unjank + action: {} as any, + }, + }, + }; +} + +export { getMockFrameRequest }; diff --git a/src/core/types.ts b/src/core/types.ts index f87d1864a4..3899fa35da 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -175,3 +175,26 @@ export type EASChainDefinition = { id: number; // blockchain source id schemaUids: EASSchemaUid[]; // Array of EAS Schema UIDs }; + +/** + * Settings to simulate statuses on mock frames. + * + * Note: exported as public Type + */ +export type MockFrameRequestOptions = { + following?: boolean; // Indicates if the viewer clicking the frame follows the cast author + interactor?: { + fid?: number; // Viewer Farcaster ID + custody_address?: string; // Viewer custody address + verified_accounts?: string[]; // Viewer account addresses + }; + liked?: boolean; // Indicates if the viewer clicking the frame liked the cast + recasted?: boolean; // Indicates if the viewer clicking the frame recasted the cast +}; + +/** + * A mock frame request payload + * + * Note: exported as public Type + */ +export type MockFrameRequest = FrameRequest & { onchainkitDebug: Required }; diff --git a/src/index.ts b/src/index.ts index b4aaf07920..f91e50afb8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ // 🌲☀️🌲 export { version } from './version'; +export { getMockFrameRequest } from './core/getMockFrameRequest'; export { getEASAttestations } from './core/getEASAttestations'; export { getFrameHtmlResponse } from './core/getFrameHtmlResponse'; export { getFrameMetadata } from './core/getFrameMetadata'; @@ -17,4 +18,6 @@ export type { FrameMetadataType, FrameRequest, FrameValidationData, + MockFrameRequest, + MockFrameRequestOptions, } from './core/types';