From 8563812b4f9172913cc6b5f4ce3f31c7038a1447 Mon Sep 17 00:00:00 2001 From: Shreyaschorge Date: Sat, 5 Jul 2025 00:51:31 +0530 Subject: [PATCH] Add signer creation --- bin/init.js | 321 +++++++------ src/app/api/auth/nonce/route.ts | 16 +- src/app/api/auth/signer/route.ts | 51 +- src/app/api/auth/signer/signed_key/route.ts | 101 ++++ src/app/api/auth/signers/route.ts | 38 ++ src/components/ui/NeynarAuthButton.tsx | 502 +++++++++++++++++--- 6 files changed, 804 insertions(+), 225 deletions(-) create mode 100644 src/app/api/auth/signer/signed_key/route.ts create mode 100644 src/app/api/auth/signers/route.ts diff --git a/bin/init.js b/bin/init.js index 3968b60..659f36c 100644 --- a/bin/init.js +++ b/bin/init.js @@ -12,7 +12,9 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const REPO_URL = 'https://github.com/neynarxyz/create-farcaster-mini-app.git'; -const SCRIPT_VERSION = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')).version; +const SCRIPT_VERSION = JSON.parse( + fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8') +).version; // ANSI color codes const purple = '\x1b[35m'; @@ -48,8 +50,8 @@ async function queryNeynarApp(apiKey) { `https://api.neynar.com/portal/app_by_api_key?starter_kit=true`, { headers: { - 'x-api-key': apiKey - } + 'x-api-key': apiKey, + }, } ); const data = await response.json(); @@ -80,16 +82,17 @@ export async function init(projectName = null, autoAcceptDefaults = false) { { type: 'confirm', name: 'useNeynar', - message: `🪐 ${purple}${bright}${italic}Neynar is an API that makes it easy to build on Farcaster.${reset}\n\n` + - 'Benefits of using Neynar in your mini app:\n' + - '- Pre-configured webhook handling (no setup required)\n' + - '- Automatic mini app analytics in your dev portal\n' + - '- Send manual notifications from dev.neynar.com\n' + - '- Built-in rate limiting and error handling\n\n' + - `${purple}${bright}${italic}A demo API key is included if you would like to try out Neynar before signing up!${reset}\n\n` + - 'Would you like to use Neynar in your mini app?', - default: true - } + message: + `🪐 ${purple}${bright}${italic}Neynar is an API that makes it easy to build on Farcaster.${reset}\n\n` + + 'Benefits of using Neynar in your mini app:\n' + + '- Pre-configured webhook handling (no setup required)\n' + + '- Automatic mini app analytics in your dev portal\n' + + '- Send manual notifications from dev.neynar.com\n' + + '- Built-in rate limiting and error handling\n\n' + + `${purple}${bright}${italic}A demo API key is included if you would like to try out Neynar before signing up!${reset}\n\n` + + 'Would you like to use Neynar in your mini app?', + default: true, + }, ]); } @@ -98,8 +101,10 @@ export async function init(projectName = null, autoAcceptDefaults = false) { break; } - console.log('\n🪐 Find your Neynar API key at: https://dev.neynar.com/app\n'); - + console.log( + '\n🪐 Find your Neynar API key at: https://dev.neynar.com/app\n' + ); + let neynarKeyAnswer; if (autoAcceptDefaults) { neynarKeyAnswer = { neynarApiKey: null }; @@ -109,8 +114,8 @@ export async function init(projectName = null, autoAcceptDefaults = false) { type: 'password', name: 'neynarApiKey', message: 'Enter your Neynar API key (or press enter to skip):', - default: null - } + default: null, + }, ]); } @@ -126,15 +131,21 @@ export async function init(projectName = null, autoAcceptDefaults = false) { type: 'confirm', name: 'useDemo', message: 'Would you like to try the demo Neynar API key?', - default: true - } + default: true, + }, ]); } if (useDemoKey.useDemo) { - console.warn('\n⚠️ Note: the demo key is for development purposes only and is aggressively rate limited.'); - console.log('For production, please sign up for a Neynar account at https://neynar.com/ and configure the API key in your .env or .env.local file with NEYNAR_API_KEY.'); - console.log(`\n${purple}${bright}${italic}Neynar now has a free tier! See https://neynar.com/#pricing for details.\n${reset}`); + console.warn( + '\n⚠️ Note: the demo key is for development purposes only and is aggressively rate limited.' + ); + console.log( + 'For production, please sign up for a Neynar account at https://neynar.com/ and configure the API key in your .env or .env.local file with NEYNAR_API_KEY.' + ); + console.log( + `\n${purple}${bright}${italic}Neynar now has a free tier! See https://neynar.com/#pricing for details.\n${reset}` + ); neynarApiKey = 'FARCASTER_V2_FRAMES_DEMO'; } } @@ -144,14 +155,16 @@ export async function init(projectName = null, autoAcceptDefaults = false) { useNeynar = false; break; } - console.log('\n⚠️ No valid API key provided. Would you like to try again?'); + console.log( + '\n⚠️ No valid API key provided. Would you like to try again?' + ); const { retry } = await inquirer.prompt([ { type: 'confirm', name: 'retry', message: 'Try configuring Neynar again?', - default: true - } + default: true, + }, ]); if (!retry) { useNeynar = false; @@ -176,9 +189,10 @@ export async function init(projectName = null, autoAcceptDefaults = false) { { type: 'confirm', name: 'retry', - message: '⚠️ Could not find a client ID for this API key. Would you like to try configuring Neynar again?', - default: true - } + message: + '⚠️ Could not find a client ID for this API key. Would you like to try configuring Neynar again?', + default: true, + }, ]); if (!retry) { useNeynar = false; @@ -191,7 +205,10 @@ export async function init(projectName = null, autoAcceptDefaults = false) { break; } - const defaultMiniAppName = (neynarAppName && !neynarAppName.toLowerCase().includes('demo')) ? neynarAppName : undefined; + const defaultMiniAppName = + neynarAppName && !neynarAppName.toLowerCase().includes('demo') + ? neynarAppName + : undefined; let answers; if (autoAcceptDefaults) { @@ -203,7 +220,7 @@ export async function init(projectName = null, autoAcceptDefaults = false) { buttonText: 'Launch Mini App', useWallet: true, useTunnel: true, - enableAnalytics: true + enableAnalytics: true, }; } else { // If autoAcceptDefaults is false but we have a projectName, we still need to ask for other options @@ -218,21 +235,22 @@ export async function init(projectName = null, autoAcceptDefaults = false) { return 'Project name cannot be empty'; } return true; - } - } + }, + }, ]); - + answers = await inquirer.prompt([ { type: 'input', name: 'description', message: 'Give a one-line description of your mini app (optional):', - default: 'A Farcaster mini app created with Neynar' + default: 'A Farcaster mini app created with Neynar', }, { type: 'list', name: 'primaryCategory', - message: 'It is strongly recommended to choose a primary category and tags to help users discover your mini app.\n\nSelect a primary category:', + message: + 'It is strongly recommended to choose a primary category and tags to help users discover your mini app.\n\nSelect a primary category:', choices: [ new inquirer.Separator(), { name: 'Skip (not recommended)', value: null }, @@ -249,23 +267,24 @@ export async function init(projectName = null, autoAcceptDefaults = false) { { name: 'Education', value: 'education' }, { name: 'Developer Tools', value: 'developer-tools' }, { name: 'Entertainment', value: 'entertainment' }, - { name: 'Art & Creativity', value: 'art-creativity' } + { name: 'Art & Creativity', value: 'art-creativity' }, ], - default: null + default: null, }, { type: 'input', name: 'tags', - message: 'Enter tags for your mini app (separate with spaces or commas, optional):', + message: + 'Enter tags for your mini app (separate with spaces or commas, optional):', default: '', filter: (input) => { if (!input.trim()) return []; // Split by both spaces and commas, trim whitespace, and filter out empty strings return input .split(/[,\s]+/) - .map(tag => tag.trim()) - .filter(tag => tag.length > 0); - } + .map((tag) => tag.trim()) + .filter((tag) => tag.length > 0); + }, }, { type: 'input', @@ -277,8 +296,8 @@ export async function init(projectName = null, autoAcceptDefaults = false) { return 'Button text cannot be empty'; } return true; - } - } + }, + }, ]); // Merge project name from the first prompt @@ -289,7 +308,8 @@ export async function init(projectName = null, autoAcceptDefaults = false) { { type: 'confirm', name: 'useWallet', - message: 'Would you like to include wallet and transaction tooling in your mini app?\n' + + message: + 'Would you like to include wallet and transaction tooling in your mini app?\n' + 'This includes:\n' + '- EVM wallet connection\n' + '- Transaction signing\n' + @@ -297,8 +317,8 @@ export async function init(projectName = null, autoAcceptDefaults = false) { '- Chain switching\n' + '- Solana support\n\n' + 'Include wallet and transaction features?', - default: true - } + default: true, + }, ]); answers.useWallet = walletAnswer.useWallet; @@ -307,11 +327,12 @@ export async function init(projectName = null, autoAcceptDefaults = false) { { type: 'confirm', name: 'useTunnel', - message: 'Would you like to test on mobile and/or test the app with Warpcast developer tools?\n' + + message: + 'Would you like to test on mobile and/or test the app with Warpcast developer tools?\n' + `⚠️ ${yellow}${italic}Both mobile testing and the Warpcast debugger require setting up a tunnel to serve your app from localhost to the broader internet.\n${reset}` + 'Configure a tunnel for mobile testing and/or Warpcast developer tools?', - default: true - } + default: true, + }, ]); answers.useTunnel = hostingAnswer.useTunnel; @@ -320,9 +341,10 @@ export async function init(projectName = null, autoAcceptDefaults = false) { { type: 'confirm', name: 'enableAnalytics', - message: 'Would you like to help improve Neynar products by sharing usage data from your mini app?', - default: true - } + message: + 'Would you like to help improve Neynar products by sharing usage data from your mini app?', + default: true, + }, ]); answers.enableAnalytics = analyticsAnswer.enableAnalytics; } @@ -337,19 +359,19 @@ export async function init(projectName = null, autoAcceptDefaults = false) { try { console.log(`\nCloning repository from ${REPO_URL}...`); // Use separate commands for better cross-platform compatibility - execSync(`git clone ${REPO_URL} "${projectPath}"`, { + execSync(`git clone ${REPO_URL} "${projectPath}"`, { stdio: 'inherit', - shell: process.platform === 'win32' + shell: process.platform === 'win32', }); - execSync('git fetch origin main', { - cwd: projectPath, + execSync('git fetch origin main', { + cwd: projectPath, stdio: 'inherit', - shell: process.platform === 'win32' + shell: process.platform === 'win32', }); - execSync('git reset --hard origin/main', { - cwd: projectPath, + execSync('git reset --hard origin/main', { + cwd: projectPath, stdio: 'inherit', - shell: process.platform === 'win32' + shell: process.platform === 'win32', }); } catch (error) { console.error('\n❌ Error: Failed to create project directory.'); @@ -386,47 +408,48 @@ export async function init(projectName = null, autoAcceptDefaults = false) { // Add dependencies packageJson.dependencies = { - "@farcaster/auth-client": ">=0.3.0 <1.0.0", - "@farcaster/auth-kit": ">=0.6.0 <1.0.0", - "@farcaster/frame-core": ">=0.0.29 <1.0.0", - "@farcaster/frame-node": ">=0.0.18 <1.0.0", - "@farcaster/frame-sdk": ">=0.0.31 <1.0.0", - "@farcaster/frame-wagmi-connector": ">=0.0.19 <1.0.0", - "@farcaster/mini-app-solana": ">=0.0.17 <1.0.0", - "@neynar/react": "^1.2.5", - "@radix-ui/react-label": "^2.1.1", - "@solana/wallet-adapter-react": "^0.15.38", - "@tanstack/react-query": "^5.61.0", - "@upstash/redis": "^1.34.3", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", - "dotenv": "^16.4.7", - "lucide-react": "^0.469.0", - "mipd": "^0.0.7", - "next": "^15", - "next-auth": "^4.24.11", - "react": "^19", - "react-dom": "^19", - "tailwind-merge": "^2.6.0", - "tailwindcss-animate": "^1.0.7", - "viem": "^2.23.6", - "wagmi": "^2.14.12", - "zod": "^3.24.2" + '@farcaster/auth-client': '>=0.3.0 <1.0.0', + '@farcaster/auth-kit': '>=0.6.0 <1.0.0', + '@farcaster/frame-core': '>=0.0.29 <1.0.0', + '@farcaster/frame-node': '>=0.0.18 <1.0.0', + '@farcaster/frame-sdk': '>=0.0.31 <1.0.0', + '@farcaster/frame-wagmi-connector': '>=0.0.19 <1.0.0', + '@farcaster/mini-app-solana': '>=0.0.17 <1.0.0', + '@neynar/react': '^1.2.5', + '@radix-ui/react-label': '^2.1.1', + '@solana/wallet-adapter-react': '^0.15.38', + '@tanstack/react-query': '^5.61.0', + '@upstash/redis': '^1.34.3', + 'class-variance-authority': '^0.7.1', + clsx: '^2.1.1', + dotenv: '^16.4.7', + 'lucide-react': '^0.469.0', + mipd: '^0.0.7', + next: '^15', + 'next-auth': '^4.24.11', + react: '^19', + 'react-dom': '^19', + 'tailwind-merge': '^2.6.0', + 'tailwindcss-animate': '^1.0.7', + viem: '^2.23.6', + wagmi: '^2.14.12', + zod: '^3.24.2', + siwe: '^3.0.0', }; packageJson.devDependencies = { - "@types/node": "^20", - "@types/react": "^19", - "@types/react-dom": "^19", - "@vercel/sdk": "^1.9.0", - "crypto": "^1.0.1", - "eslint": "^8", - "eslint-config-next": "15.0.3", - "localtunnel": "^2.0.2", - "pino-pretty": "^13.0.0", - "postcss": "^8", - "tailwindcss": "^3.4.1", - "typescript": "^5" + '@types/node': '^20', + '@types/react': '^19', + '@types/react-dom': '^19', + '@vercel/sdk': '^1.9.0', + crypto: '^1.0.1', + eslint: '^8', + 'eslint-config-next': '15.0.3', + localtunnel: '^2.0.2', + 'pino-pretty': '^13.0.0', + postcss: '^8', + tailwindcss: '^3.4.1', + typescript: '^5', }; // Add Neynar SDK if selected @@ -452,35 +475,46 @@ export async function init(projectName = null, autoAcceptDefaults = false) { const constantsPath = path.join(projectPath, 'src', 'lib', 'constants.ts'); if (fs.existsSync(constantsPath)) { let constantsContent = fs.readFileSync(constantsPath, 'utf8'); - + // Helper function to escape single quotes in strings const escapeString = (str) => str.replace(/'/g, "\\'"); - + // Helper function to safely replace constants with validation const safeReplace = (content, pattern, replacement, constantName) => { const match = content.match(pattern); if (!match) { - console.log(`⚠️ Warning: Could not update ${constantName} in constants.ts. Pattern not found.`); + console.log( + `⚠️ Warning: Could not update ${constantName} in constants.ts. Pattern not found.` + ); console.log(`Pattern: ${pattern}`); - console.log(`Expected to match in: ${content.split('\n').find(line => line.includes(constantName)) || 'Not found'}`); + console.log( + `Expected to match in: ${ + content.split('\n').find((line) => line.includes(constantName)) || + 'Not found' + }` + ); } else { const newContent = content.replace(pattern, replacement); return newContent; } return content; }; - + // Regex patterns that match whole lines with export const const patterns = { APP_NAME: /^export const APP_NAME\s*=\s*['"`][^'"`]*['"`];$/m, - APP_DESCRIPTION: /^export const APP_DESCRIPTION\s*=\s*['"`][^'"`]*['"`];$/m, - APP_PRIMARY_CATEGORY: /^export const APP_PRIMARY_CATEGORY\s*=\s*['"`][^'"`]*['"`];$/m, + APP_DESCRIPTION: + /^export const APP_DESCRIPTION\s*=\s*['"`][^'"`]*['"`];$/m, + APP_PRIMARY_CATEGORY: + /^export const APP_PRIMARY_CATEGORY\s*=\s*['"`][^'"`]*['"`];$/m, APP_TAGS: /^export const APP_TAGS\s*=\s*\[[^\]]*\];$/m, - APP_BUTTON_TEXT: /^export const APP_BUTTON_TEXT\s*=\s*['"`][^'"`]*['"`];$/m, + APP_BUTTON_TEXT: + /^export const APP_BUTTON_TEXT\s*=\s*['"`][^'"`]*['"`];$/m, USE_WALLET: /^export const USE_WALLET\s*=\s*(true|false);$/m, - ANALYTICS_ENABLED: /^export const ANALYTICS_ENABLED\s*=\s*(true|false);$/m + ANALYTICS_ENABLED: + /^export const ANALYTICS_ENABLED\s*=\s*(true|false);$/m, }; - + // Update APP_NAME constantsContent = safeReplace( constantsContent, @@ -488,42 +522,49 @@ export async function init(projectName = null, autoAcceptDefaults = false) { `export const APP_NAME = '${escapeString(answers.projectName)}';`, 'APP_NAME' ); - + // Update APP_DESCRIPTION constantsContent = safeReplace( constantsContent, patterns.APP_DESCRIPTION, - `export const APP_DESCRIPTION = '${escapeString(answers.description)}';`, + `export const APP_DESCRIPTION = '${escapeString( + answers.description + )}';`, 'APP_DESCRIPTION' ); - + // Update APP_PRIMARY_CATEGORY (always update, null becomes empty string) constantsContent = safeReplace( constantsContent, patterns.APP_PRIMARY_CATEGORY, - `export const APP_PRIMARY_CATEGORY = '${escapeString(answers.primaryCategory || '')}';`, + `export const APP_PRIMARY_CATEGORY = '${escapeString( + answers.primaryCategory || '' + )}';`, 'APP_PRIMARY_CATEGORY' ); - + // Update APP_TAGS - const tagsString = answers.tags.length > 0 - ? `['${answers.tags.map(tag => escapeString(tag)).join("', '")}']` - : "['neynar', 'starter-kit', 'demo']"; + const tagsString = + answers.tags.length > 0 + ? `['${answers.tags.map((tag) => escapeString(tag)).join("', '")}']` + : "['neynar', 'starter-kit', 'demo']"; constantsContent = safeReplace( constantsContent, patterns.APP_TAGS, `export const APP_TAGS = ${tagsString};`, 'APP_TAGS' ); - + // Update APP_BUTTON_TEXT (always update, use answers value) constantsContent = safeReplace( constantsContent, patterns.APP_BUTTON_TEXT, - `export const APP_BUTTON_TEXT = '${escapeString(answers.buttonText || '')}';`, + `export const APP_BUTTON_TEXT = '${escapeString( + answers.buttonText || '' + )}';`, 'APP_BUTTON_TEXT' ); - + // Update USE_WALLET constantsContent = safeReplace( constantsContent, @@ -531,7 +572,7 @@ export async function init(projectName = null, autoAcceptDefaults = false) { `export const USE_WALLET = ${answers.useWallet};`, 'USE_WALLET' ); - + // Update ANALYTICS_ENABLED constantsContent = safeReplace( constantsContent, @@ -539,24 +580,31 @@ export async function init(projectName = null, autoAcceptDefaults = false) { `export const ANALYTICS_ENABLED = ${answers.enableAnalytics};`, 'ANALYTICS_ENABLED' ); - + fs.writeFileSync(constantsPath, constantsContent); } else { console.log('⚠️ constants.ts not found, skipping constants update'); } - fs.appendFileSync(envPath, `\nNEXTAUTH_SECRET="${crypto.randomBytes(32).toString('hex')}"`); + fs.appendFileSync( + envPath, + `\nNEXTAUTH_SECRET="${crypto.randomBytes(32).toString('hex')}"` + ); if (useNeynar && neynarApiKey && neynarClientId) { fs.appendFileSync(envPath, `\nNEYNAR_API_KEY="${neynarApiKey}"`); fs.appendFileSync(envPath, `\nNEYNAR_CLIENT_ID="${neynarClientId}"`); } else if (useNeynar) { - console.log('\n⚠️ Could not find a Neynar client ID and/or API key. Please configure Neynar manually in .env.local with NEYNAR_API_KEY and NEYNAR_CLIENT_ID'); + console.log( + '\n⚠️ Could not find a Neynar client ID and/or API key. Please configure Neynar manually in .env.local with NEYNAR_API_KEY and NEYNAR_CLIENT_ID' + ); } fs.appendFileSync(envPath, `\nUSE_TUNNEL="${answers.useTunnel}"`); - + fs.unlinkSync(envExamplePath); } else { - console.log('\n.env.example does not exist, skipping copy and remove operations'); + console.log( + '\n.env.example does not exist, skipping copy and remove operations' + ); } // Update README @@ -564,7 +612,9 @@ export async function init(projectName = null, autoAcceptDefaults = false) { const readmePath = path.join(projectPath, 'README.md'); const prependText = `\n\n`; if (fs.existsSync(readmePath)) { - const originalReadmeContent = fs.readFileSync(readmePath, { encoding: 'utf8' }); + const originalReadmeContent = fs.readFileSync(readmePath, { + encoding: 'utf8', + }); const updatedReadmeContent = prependText + originalReadmeContent; fs.writeFileSync(readmePath, updatedReadmeContent); } else { @@ -574,15 +624,15 @@ export async function init(projectName = null, autoAcceptDefaults = false) { // Install dependencies console.log('\nInstalling dependencies...'); - execSync('npm cache clean --force', { - cwd: projectPath, + execSync('npm cache clean --force', { + cwd: projectPath, stdio: 'inherit', - shell: process.platform === 'win32' + shell: process.platform === 'win32', }); - execSync('npm install', { - cwd: projectPath, + execSync('npm install', { + cwd: projectPath, stdio: 'inherit', - shell: process.platform === 'win32' + shell: process.platform === 'win32', }); // Remove the bin directory @@ -596,12 +646,15 @@ export async function init(projectName = null, autoAcceptDefaults = false) { console.log('\nInitializing git repository...'); execSync('git init', { cwd: projectPath }); execSync('git add .', { cwd: projectPath }); - execSync('git commit -m "initial commit from @neynar/create-farcaster-mini-app"', { cwd: projectPath }); + execSync( + 'git commit -m "initial commit from @neynar/create-farcaster-mini-app"', + { cwd: projectPath } + ); // Calculate border length based on message length const message = `✨🪐 Successfully created mini app ${finalProjectName} with git and dependencies installed! 🪐✨`; const borderLength = message.length; - const borderStars = '✨'.repeat((borderLength / 2) + 1); + const borderStars = '✨'.repeat(borderLength / 2 + 1); console.log(`\n${borderStars}`); console.log(`${message}`); diff --git a/src/app/api/auth/nonce/route.ts b/src/app/api/auth/nonce/route.ts index 0d4bb6a..a1f25ea 100644 --- a/src/app/api/auth/nonce/route.ts +++ b/src/app/api/auth/nonce/route.ts @@ -2,9 +2,15 @@ import { NextResponse } from 'next/server'; import { getNeynarClient } from '~/lib/neynar'; export async function GET() { - const client = getNeynarClient(); - - const response = await client.fetchNonce(); - - return NextResponse.json(response); + try { + const client = getNeynarClient(); + const response = await client.fetchNonce(); + return NextResponse.json(response); + } catch (error) { + console.error('Error fetching nonce:', error); + return NextResponse.json( + { error: 'Failed to fetch nonce' }, + { status: 500 } + ); + } } diff --git a/src/app/api/auth/signer/route.ts b/src/app/api/auth/signer/route.ts index 9a1edd1..f793d0e 100644 --- a/src/app/api/auth/signer/route.ts +++ b/src/app/api/auth/signer/route.ts @@ -1,39 +1,42 @@ import { NextResponse } from 'next/server'; import { getNeynarClient } from '~/lib/neynar'; +export async function POST() { + try { + const neynarClient = getNeynarClient(); + const signer = await neynarClient.createSigner(); + return NextResponse.json(signer); + } catch (error) { + console.error('Error fetching signer:', error); + return NextResponse.json( + { error: 'Failed to fetch signer' }, + { status: 500 } + ); + } +} + export async function GET(request: Request) { const { searchParams } = new URL(request.url); - const message = searchParams.get('message'); - const signature = searchParams.get('signature'); + const signerUuid = searchParams.get('signerUuid'); - if (!message) { + if (!signerUuid) { return NextResponse.json( - { error: 'Message parameter is required' }, + { error: 'signerUuid is required' }, { status: 400 } ); } - if (!signature) { - return NextResponse.json( - { error: 'Signature parameter is required' }, - { status: 400 } - ); - } - - const client = getNeynarClient(); - - let signers; - try { - const data = await client.fetchSigners({ message, signature }); - signers = data.signers; + const neynarClient = getNeynarClient(); + const signer = await neynarClient.lookupSigner({ + signerUuid, + }); + return NextResponse.json(signer); } catch (error) { - console.error('Error fetching signers:', error?.response?.data); - throw new Error('Failed to fetch signers'); + console.error('Error fetching signed key:', error); + return NextResponse.json( + { error: 'Failed to fetch signed key' }, + { status: 500 } + ); } - console.log('signers =>', signers); - - return NextResponse.json({ - signers, - }); } diff --git a/src/app/api/auth/signer/signed_key/route.ts b/src/app/api/auth/signer/signed_key/route.ts new file mode 100644 index 0000000..de4c414 --- /dev/null +++ b/src/app/api/auth/signer/signed_key/route.ts @@ -0,0 +1,101 @@ +import { NextResponse } from 'next/server'; +import { getNeynarClient } from '~/lib/neynar'; +import { mnemonicToAccount } from 'viem/accounts'; + +const postRequiredFields = ['signerUuid', 'publicKey']; + +const SIGNED_KEY_REQUEST_VALIDATOR_EIP_712_DOMAIN = { + name: 'Farcaster SignedKeyRequestValidator', + version: '1', + chainId: 10, + verifyingContract: + '0x00000000fc700472606ed4fa22623acf62c60553' as `0x${string}`, +}; + +const SIGNED_KEY_REQUEST_TYPE = [ + { name: 'requestFid', type: 'uint256' }, + { name: 'key', type: 'bytes' }, + { name: 'deadline', type: 'uint256' }, +]; + +export async function POST(request: Request) { + const body = await request.json(); + + // Validate required fields + for (const field of postRequiredFields) { + if (!body[field]) { + return NextResponse.json( + { error: `${field} is required` }, + { status: 400 } + ); + } + } + + const { signerUuid, publicKey, redirectUrl } = body; + + if (redirectUrl && typeof redirectUrl !== 'string') { + return NextResponse.json( + { error: 'redirectUrl must be a string' }, + { status: 400 } + ); + } + + try { + // Get the app's account from seed phrase + const seedPhrase = process.env.SEED_PHRASE; + const shouldSponsor = process.env.SPONSOR_SIGNER === 'true'; + + if (!seedPhrase) { + return NextResponse.json( + { error: 'App configuration missing (SEED_PHRASE or FID)' }, + { status: 500 } + ); + } + + const neynarClient = getNeynarClient(); + + const account = mnemonicToAccount(seedPhrase); + + const { + user: { fid }, + } = await neynarClient.lookupUserByCustodyAddress({ + custodyAddress: account.address, + }); + + const appFid = fid; + + // Generate deadline (24 hours from now) + const deadline = Math.floor(Date.now() / 1000) + 86400; + + // Generate EIP-712 signature + const signature = await account.signTypedData({ + domain: SIGNED_KEY_REQUEST_VALIDATOR_EIP_712_DOMAIN, + types: { + SignedKeyRequest: SIGNED_KEY_REQUEST_TYPE, + }, + primaryType: 'SignedKeyRequest', + message: { + requestFid: BigInt(appFid), + key: publicKey, + deadline: BigInt(deadline), + }, + }); + + const signer = await neynarClient.registerSignedKey({ + appFid, + deadline, + signature, + signerUuid, + ...(redirectUrl && { redirectUrl }), + ...(shouldSponsor && { sponsor: { sponsored_by_neynar: true } }), + }); + + return NextResponse.json(signer); + } catch (error) { + console.error('Error registering signed key:', error); + return NextResponse.json( + { error: 'Failed to register signed key' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/auth/signers/route.ts b/src/app/api/auth/signers/route.ts new file mode 100644 index 0000000..1c89acf --- /dev/null +++ b/src/app/api/auth/signers/route.ts @@ -0,0 +1,38 @@ +import { NextResponse } from 'next/server'; +import { getNeynarClient } from '~/lib/neynar'; + +const requiredParams = ['message', 'signature']; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const params: Record = {}; + for (const param of requiredParams) { + params[param] = searchParams.get(param); + if (!params[param]) { + return NextResponse.json( + { + error: `${param} parameter is required`, + }, + { status: 400 } + ); + } + } + + const message = params.message as string; + const signature = params.signature as string; + + try { + const client = getNeynarClient(); + const data = await client.fetchSigners({ message, signature }); + const signers = data.signers; + return NextResponse.json({ + signers, + }); + } catch (error) { + console.error('Error fetching signers:', error); + return NextResponse.json( + { error: 'Failed to fetch signers' }, + { status: 500 } + ); + } +} diff --git a/src/components/ui/NeynarAuthButton.tsx b/src/components/ui/NeynarAuthButton.tsx index 82e9307..7a1afad 100644 --- a/src/components/ui/NeynarAuthButton.tsx +++ b/src/components/ui/NeynarAuthButton.tsx @@ -64,6 +64,13 @@ interface StoredAuthState { username?: string; }; lastSignInTime?: number; + signers?: { + object: 'signer'; + signer_uuid: string; + public_key: string; + status: 'approved'; + fid: number; + }[]; // Store the list of signers } function saveAuthState(state: StoredAuthState) { @@ -92,28 +99,156 @@ function clearAuthState() { } } -// QR Code Dialog Component -function QRCodeDialog({ +function updateSignersInAuthState( + signers: StoredAuthState['signers'] +): StoredAuthState | null { + try { + const stored = loadAuthState(); + if (stored) { + const updatedState = { ...stored, signers }; + saveAuthState(updatedState); + return updatedState; + } + } catch (error) { + console.warn('Failed to update signers in auth state:', error); + } + return null; +} + +export function getStoredSigners(): unknown[] { + try { + const stored = loadAuthState(); + return stored?.signers || []; + } catch (error) { + console.warn('Failed to get stored signers:', error); + return []; + } +} + +// Enhanced QR Code Dialog Component with multiple steps +function AuthDialog({ open, onClose, url, isError, error, + step, + isLoading, + signerApprovalUrl, }: { open: boolean; onClose: () => void; url: string; isError: boolean; error?: Error | null; + step: 'signin' | 'access' | 'loading'; + isLoading?: boolean; + signerApprovalUrl?: string | null; }) { if (!open) return null; + const getStepContent = () => { + switch (step) { + case 'signin': + return { + title: 'Signin', + description: + "To signin, scan the code below with your phone's camera.", + showQR: true, + qrUrl: url, + showOpenButton: true, + }; + + case 'loading': + return { + title: 'Setting up access...', + description: + 'Checking your account permissions and setting up secure access.', + showQR: false, + qrUrl: '', + showOpenButton: false, + }; + + case 'access': + return { + title: 'Grant Access', + description: ( +
+

+ Allow this app to access your Farcaster account: +

+
+
+
+ + + +
+
+
+ Read Access +
+
+ View your profile and public information +
+
+
+
+
+ + + +
+
+
+ Write Access +
+
+ Post casts, likes, and update your profile +
+
+
+
+
+ ), + // Show QR code if we have signer approval URL, otherwise show loading + showQR: !!signerApprovalUrl, + qrUrl: signerApprovalUrl || '', + showOpenButton: !!signerApprovalUrl, + }; + + default: + return { + title: 'Sign in', + description: + "To signin, scan the code below with your phone's camera.", + showQR: true, + qrUrl: url, + showOpenButton: true, + }; + } + }; + + const content = getStepContent(); + return (
-
+

- {isError ? 'Error' : 'Sign in with Farcaster'} + {isError ? 'Error' : content.title}

+ + + + + I'm using my phone → + + )}
)}
@@ -281,8 +438,176 @@ function ProfileButton({ // Main Custom SignInButton Component export function NeynarAuthButton() { const [nonce, setNonce] = useState(null); - const [showDialog, setShowDialog] = useState(false); const [storedAuth, setStoredAuth] = useState(null); + const [signersLoading, setSignersLoading] = useState(false); + + // New state for unified dialog flow + const [showDialog, setShowDialog] = useState(false); + const [dialogStep, setDialogStep] = useState<'signin' | 'access' | 'loading'>( + 'loading' + ); + const [signerApprovalUrl, setSignerApprovalUrl] = useState( + null + ); + const [pollingInterval, setPollingInterval] = useState( + null + ); + + // Helper function to create a signer + const createSigner = useCallback(async () => { + try { + console.log('🔧 Creating new signer...'); + + const response = await fetch('/api/auth/signer', { + method: 'POST', + }); + + if (!response.ok) { + throw new Error('Failed to create signer'); + } + + const signerData = await response.json(); + console.log('✅ Signer created:', signerData); + + return signerData; + } catch (error) { + console.error('❌ Error creating signer:', error); + throw error; + } + }, []); + + // Helper function to generate signed key request + const generateSignedKeyRequest = useCallback( + async (signerUuid: string, publicKey: string) => { + try { + console.log('🔑 Generating signed key request...'); + + // Prepare request body + const requestBody: { + signerUuid: string; + publicKey: string; + sponsor?: { sponsored_by_neynar: boolean }; + } = { + signerUuid, + publicKey, + }; + + const response = await fetch('/api/auth/signer/signed_key', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error( + `Failed to generate signed key request: ${errorData.error}` + ); + } + + const data = await response.json(); + console.log('✅ Signed key request generated:', data); + + return data; + } catch (error) { + console.error('❌ Error generating signed key request:', error); + throw error; + } + }, + [] + ); + + // Helper function to fetch all signers + const fetchAllSigners = useCallback( + async (message: string, signature: string) => { + try { + console.log('� Fetching all signers...'); + setSignersLoading(true); + + const response = await fetch( + `/api/auth/signers?message=${encodeURIComponent( + message + )}&signature=${signature}` + ); + + const signerData = await response.json(); + console.log('� Signer response:', signerData); + + if (response.ok) { + console.log('✅ Signers fetched successfully:', signerData.signers); + + // Store signers in localStorage + const updatedState = updateSignersInAuthState( + signerData.signers || [] + ); + if (updatedState) { + setStoredAuth(updatedState); + } + + return signerData.signers; + } else { + console.error('❌ Failed to fetch signers'); + throw new Error('Failed to fetch signers'); + } + } catch (error) { + console.error('❌ Error fetching signers:', error); + throw error; + } finally { + setSignersLoading(false); + } + }, + [] + ); + + // Helper function to poll signer status + const startPolling = useCallback( + (signerUuid: string, message: string, signature: string) => { + console.log('� Starting polling for signer:', signerUuid); + + const interval = setInterval(async () => { + try { + const response = await fetch( + `/api/auth/signer?signerUuid=${signerUuid}` + ); + + if (!response.ok) { + throw new Error('Failed to poll signer status'); + } + + const signerData = await response.json(); + console.log('� Signer status:', signerData.status); + + if (signerData.status === 'approved') { + console.log('🎉 Signer approved!'); + clearInterval(interval); + setPollingInterval(null); + setShowDialog(false); + setDialogStep('signin'); + setSignerApprovalUrl(null); + + // Refetch all signers + await fetchAllSigners(message, signature); + } + } catch (error) { + console.error('❌ Error polling signer:', error); + } + }, 1000); // Poll every 1 second + + setPollingInterval(interval); + }, + [fetchAllSigners] + ); + + // Cleanup polling on unmount + useEffect(() => { + return () => { + if (pollingInterval) { + clearInterval(pollingInterval); + } + }; + }, [pollingInterval]); // Generate nonce useEffect(() => { @@ -308,6 +633,9 @@ export function NeynarAuthButton() { const stored = loadAuthState(); if (stored && stored.isAuthenticated) { setStoredAuth(stored); + if (stored.signers && stored.signers.length > 0) { + console.log('📂 Loaded stored signers:', stored.signers); + } } }, []); @@ -321,7 +649,7 @@ export function NeynarAuthButton() { }; saveAuthState(authState); setStoredAuth(authState); - setShowDialog(false); + // setShowDialog(false); }, []); // Status response callback @@ -383,31 +711,68 @@ export function NeynarAuthButton() { message: data.message, signature: data.signature, }); - - const fetchSigners = async () => { + const handleSignerFlow = async () => { try { - const response = await fetch( - `/api/auth/signer?message=${encodeURIComponent( - data.message || '' - )}&signature=${data.signature}` - ); + // Ensure we have message and signature + if (!data.message || !data.signature) { + console.error('❌ Missing message or signature'); + return; + } - const signerData = await response.json(); - console.log('🔐 Signer response:', signerData); + // Step 1: Change to loading state + setDialogStep('loading'); + setSignersLoading(true); - if (response.ok) { - console.log('✅ Signers fetched successfully:', signerData.signers); + // First, fetch existing signers + const signers = await fetchAllSigners(data.message, data.signature); + + // Check if no signers exist + if (!signers || signers.length === 0) { + console.log('� No signers found, creating new signer...'); + + // Step 1: Create a signer + const newSigner = await createSigner(); + + // Step 2: Generate signed key request + const signedKeyData = await generateSignedKeyRequest( + newSigner.signer_uuid, + newSigner.public_key + ); + + // Step 3: Show QR code in access dialog for signer approval + if (signedKeyData.signer_approval_url) { + setSignerApprovalUrl(signedKeyData.signer_approval_url); + setSignersLoading(false); // Stop loading, show QR code + setDialogStep('access'); // Switch to access step to show QR + + // Step 4: Start polling for signer approval + startPolling(newSigner.signer_uuid, data.message, data.signature); + } } else { - console.error('❌ Failed to fetch signers'); + // If signers exist, close the dialog + console.log('✅ Signers already exist, closing dialog'); + setSignersLoading(false); + setShowDialog(false); + setDialogStep('signin'); } } catch (error) { - console.error('❌ Error fetching signers:', error); + console.error('❌ Error in signer flow:', error); + // On error, reset to signin step + setDialogStep('signin'); + setSignersLoading(false); } }; - fetchSigners(); + handleSignerFlow(); } - }, [data?.message, data?.signature]); + }, [ + data?.message, + data?.signature, + fetchAllSigners, + createSigner, + generateSignedKeyRequest, + startPolling, + ]); const handleSignIn = useCallback(() => { console.log('🚀 Starting sign in flow...'); @@ -415,6 +780,7 @@ export function NeynarAuthButton() { console.log('🔄 Reconnecting due to error...'); reconnect(); } + setDialogStep('signin'); setShowDialog(true); signIn(); @@ -435,11 +801,12 @@ export function NeynarAuthButton() { // The key fix: match the original library's authentication logic exactly const authenticated = - (isSuccess && validSignature) || storedAuth?.isAuthenticated; + ((isSuccess && validSignature) || storedAuth?.isAuthenticated) && + !!(storedAuth?.signers && storedAuth.signers.length > 0); const userData = data || storedAuth?.userData; - // Show loading state while nonce is being fetched - if (!nonce) { + // Show loading state while nonce is being fetched or signers are loading + if (!nonce || signersLoading) { return (
@@ -463,7 +830,7 @@ export function NeynarAuthButton() { className={cn( 'btn btn-primary flex items-center gap-3', 'disabled:opacity-50 disabled:cursor-not-allowed', - 'transform transition-all duration-200 hover:scale-[1.02] active:scale-[0.98]', + 'transform transition-all duration-200 active:scale-[0.98]', !url && 'cursor-not-allowed' )} > @@ -480,14 +847,25 @@ export function NeynarAuthButton() { )} - {/* QR Code Dialog for desktop */} + {/* Unified Auth Dialog */} {url && ( - setShowDialog(false)} + onClose={() => { + setShowDialog(false); + setDialogStep('signin'); + setSignerApprovalUrl(null); + if (pollingInterval) { + clearInterval(pollingInterval); + setPollingInterval(null); + } + }} url={url} isError={isError} error={error} + step={dialogStep} + isLoading={signersLoading} + signerApprovalUrl={signerApprovalUrl} /> )}