diff --git a/bin/init.js b/bin/init.js index 167969a..73a7ed9 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,9 @@ export async function init(projectName = null, autoAcceptDefaults = false) { buttonText: 'Launch Mini App', useWallet: true, useTunnel: true, - enableAnalytics: true + enableAnalytics: true, + seedPhrase: null, + sponsorSigner: false, }; } else { // If autoAcceptDefaults is false but we have a projectName, we still need to ask for other options @@ -218,21 +237,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 +269,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 +298,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 +310,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 +319,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 +329,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 +343,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 +361,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,46 +410,47 @@ 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/miniapp-node": ">=0.1.5 <1.0.0", - "@farcaster/miniapp-sdk": ">=0.1.6 <1.0.0", - "@farcaster/miniapp-wagmi-connector": "^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/miniapp-node': '>=0.1.5 <1.0.0', + '@farcaster/miniapp-sdk': '>=0.1.6 <1.0.0', + '@farcaster/miniapp-wagmi-connector': '^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 @@ -451,35 +476,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, @@ -487,42 +523,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, @@ -530,7 +573,7 @@ export async function init(projectName = null, autoAcceptDefaults = false) { `export const USE_WALLET = ${answers.useWallet};`, 'USE_WALLET' ); - + // Update ANALYTICS_ENABLED constantsContent = safeReplace( constantsContent, @@ -538,24 +581,35 @@ 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' + ); + } + if (answers.seedPhrase) { + fs.appendFileSync(envPath, `\nSEED_PHRASE="${answers.seedPhrase}"`); + fs.appendFileSync(envPath, `\nSPONSOR_SIGNER="${answers.sponsorSigner}"`); } 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 @@ -563,7 +617,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 { @@ -573,15 +629,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 @@ -595,12 +651,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/package.json b/package.json index b5a2f78..775601a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@neynar/create-farcaster-mini-app", - "version": "1.5.6", + "version": "1.5.7", "type": "module", "private": false, "access": "public", diff --git a/scripts/build.js b/scripts/build.js index 6d6bccb..ac8ed77 100755 --- a/scripts/build.js +++ b/scripts/build.js @@ -46,6 +46,9 @@ async function loadEnvLocal() { fs.writeFileSync(".env", newEnvContent); console.log("āœ… Values from .env.local have been written to .env"); } + if (localEnv.SPONSOR_SIGNER) { + process.env.SPONSOR_SIGNER = localEnv.SPONSOR_SIGNER; + } } } catch (error) { // Error reading .env.local, which is fine @@ -290,6 +293,8 @@ async function main() { ? [`NEYNAR_API_KEY="${process.env.NEYNAR_API_KEY}"`] : []), ...(neynarClientId ? [`NEYNAR_CLIENT_ID="${neynarClientId}"`] : []), + ...(process.env.SPONSOR_SIGNER ? + [`SPONSOR_SIGNER="${process.env.SPONSOR_SIGNER}"`] : []), // FID (if it exists in current env) ...(process.env.FID ? [`FID="${process.env.FID}"`] : []), diff --git a/scripts/deploy.js b/scripts/deploy.js index 23d0d77..9c4ca4d 100755 --- a/scripts/deploy.js +++ b/scripts/deploy.js @@ -1,33 +1,33 @@ -import { execSync, spawn } from "child_process"; -import fs from "fs"; -import path from "path"; -import os from "os"; -import { fileURLToPath } from "url"; -import inquirer from "inquirer"; -import dotenv from "dotenv"; -import crypto from "crypto"; -import { Vercel } from "@vercel/sdk"; +import { execSync, spawn } from 'child_process'; +import fs from 'fs'; +import path from 'path'; +import os from 'os'; +import { fileURLToPath } from 'url'; +import inquirer from 'inquirer'; +import dotenv from 'dotenv'; +import crypto from 'crypto'; +import { Vercel } from '@vercel/sdk'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const projectRoot = path.join(__dirname, ".."); +const projectRoot = path.join(__dirname, '..'); // Load environment variables in specific order -dotenv.config({ path: ".env" }); +dotenv.config({ path: '.env' }); async function generateFarcasterMetadata(domain, webhookUrl) { const trimmedDomain = domain.trim(); - const tags = process.env.NEXT_PUBLIC_MINI_APP_TAGS?.split(","); + const tags = process.env.NEXT_PUBLIC_MINI_APP_TAGS?.split(','); return { frame: { - version: "1", + version: '1', name: process.env.NEXT_PUBLIC_MINI_APP_NAME, iconUrl: `https://${trimmedDomain}/icon.png`, homeUrl: `https://${trimmedDomain}`, imageUrl: `https://${trimmedDomain}/api/opengraph-image`, buttonTitle: process.env.NEXT_PUBLIC_MINI_APP_BUTTON_TEXT, splashImageUrl: `https://${trimmedDomain}/splash.png`, - splashBackgroundColor: "#f7f7f7", + splashBackgroundColor: '#f7f7f7', webhookUrl: webhookUrl?.trim(), description: process.env.NEXT_PUBLIC_MINI_APP_DESCRIPTION, primaryCategory: process.env.NEXT_PUBLIC_MINI_APP_PRIMARY_CATEGORY, @@ -38,35 +38,36 @@ async function generateFarcasterMetadata(domain, webhookUrl) { async function loadEnvLocal() { try { - if (fs.existsSync(".env.local")) { + if (fs.existsSync('.env.local')) { const { loadLocal } = await inquirer.prompt([ { - type: "confirm", - name: "loadLocal", + type: 'confirm', + name: 'loadLocal', message: - "Found .env.local - would you like to load its values in addition to .env values?", + 'Found .env.local - would you like to load its values in addition to .env values?', default: true, }, ]); if (loadLocal) { - console.log("Loading values from .env.local..."); - const localEnv = dotenv.parse(fs.readFileSync(".env.local")); + console.log('Loading values from .env.local...'); + const localEnv = dotenv.parse(fs.readFileSync('.env.local')); const allowedVars = [ - "NEXT_PUBLIC_MINI_APP_NAME", - "NEXT_PUBLIC_MINI_APP_DESCRIPTION", - "NEXT_PUBLIC_MINI_APP_PRIMARY_CATEGORY", - "NEXT_PUBLIC_MINI_APP_TAGS", - "NEXT_PUBLIC_MINI_APP_BUTTON_TEXT", - "NEXT_PUBLIC_ANALYTICS_ENABLED", - "NEYNAR_API_KEY", - "NEYNAR_CLIENT_ID", + 'NEXT_PUBLIC_MINI_APP_NAME', + 'NEXT_PUBLIC_MINI_APP_DESCRIPTION', + 'NEXT_PUBLIC_MINI_APP_PRIMARY_CATEGORY', + 'NEXT_PUBLIC_MINI_APP_TAGS', + 'NEXT_PUBLIC_MINI_APP_BUTTON_TEXT', + 'NEXT_PUBLIC_ANALYTICS_ENABLED', + 'NEYNAR_API_KEY', + 'NEYNAR_CLIENT_ID', + 'SPONSOR_SIGNER', ]; - const envContent = fs.existsSync(".env") - ? fs.readFileSync(".env", "utf8") + "\n" - : ""; + const envContent = fs.existsSync('.env') + ? fs.readFileSync('.env', 'utf8') + '\n' + : ''; let newEnvContent = envContent; for (const [key, value] of Object.entries(localEnv)) { @@ -78,35 +79,35 @@ async function loadEnvLocal() { } } - fs.writeFileSync(".env", newEnvContent); - console.log("āœ… Values from .env.local have been written to .env"); + fs.writeFileSync('.env', newEnvContent); + console.log('āœ… Values from .env.local have been written to .env'); } } } catch (error) { - console.log("Note: No .env.local file found"); + console.log('Note: No .env.local file found'); } } async function checkRequiredEnvVars() { - console.log("\nšŸ“ Checking environment variables..."); - console.log("Loading values from .env..."); + console.log('\nšŸ“ Checking environment variables...'); + console.log('Loading values from .env...'); await loadEnvLocal(); const requiredVars = [ { - name: "NEXT_PUBLIC_MINI_APP_NAME", - message: "Enter the name for your frame (e.g., My Cool Mini App):", + name: 'NEXT_PUBLIC_MINI_APP_NAME', + message: 'Enter the name for your frame (e.g., My Cool Mini App):', default: process.env.NEXT_PUBLIC_MINI_APP_NAME, validate: (input) => - input.trim() !== "" || "Mini app name cannot be empty", + input.trim() !== '' || 'Mini app name cannot be empty', }, { - name: "NEXT_PUBLIC_MINI_APP_BUTTON_TEXT", - message: "Enter the text for your frame button:", + name: 'NEXT_PUBLIC_MINI_APP_BUTTON_TEXT', + message: 'Enter the text for your frame button:', default: - process.env.NEXT_PUBLIC_MINI_APP_BUTTON_TEXT ?? "Launch Mini App", - validate: (input) => input.trim() !== "" || "Button text cannot be empty", + process.env.NEXT_PUBLIC_MINI_APP_BUTTON_TEXT ?? 'Launch Mini App', + validate: (input) => input.trim() !== '' || 'Button text cannot be empty', }, ]; @@ -119,8 +120,8 @@ async function checkRequiredEnvVars() { for (const varConfig of missingVars) { const { value } = await inquirer.prompt([ { - type: "input", - name: "value", + type: 'input', + name: 'value', message: varConfig.message, default: varConfig.default, validate: varConfig.validate, @@ -129,26 +130,63 @@ async function checkRequiredEnvVars() { process.env[varConfig.name] = value; - const envContent = fs.existsSync(".env") - ? fs.readFileSync(".env", "utf8") - : ""; + const envContent = fs.existsSync('.env') + ? fs.readFileSync('.env', 'utf8') + : ''; if (!envContent.includes(`${varConfig.name}=`)) { - const newLine = envContent ? "\n" : ""; + const newLine = envContent ? '\n' : ''; fs.appendFileSync( - ".env", + '.env', `${newLine}${varConfig.name}="${value.trim()}"` ); } + + // Ask about sponsor signer if SEED_PHRASE is provided + if (!process.env.SPONSOR_SIGNER) { + const { sponsorSigner } = await inquirer.prompt([ + { + type: 'confirm', + name: 'sponsorSigner', + message: + 'Do you want to sponsor the signer? (This will be used in Sign In With Neynar)\n' + + 'Note: If you choose to sponsor the signer, Neynar will sponsor it for you and you will be charged in CUs.\n' + + 'For more information, see https://docs.neynar.com/docs/two-ways-to-sponsor-a-farcaster-signer-via-neynar#sponsor-signers', + default: false, + }, + ]); + + process.env.SPONSOR_SIGNER = sponsorSigner.toString(); + + if (storeSeedPhrase) { + fs.appendFileSync( + '.env.local', + `\nSPONSOR_SIGNER="${sponsorSigner}"` + ); + console.log('āœ… Sponsor signer preference stored in .env.local'); + } + } + } + } + + // Load SPONSOR_SIGNER from .env.local if SEED_PHRASE exists but SPONSOR_SIGNER doesn't + if ( + process.env.SEED_PHRASE && + !process.env.SPONSOR_SIGNER && + fs.existsSync('.env.local') + ) { + const localEnv = dotenv.parse(fs.readFileSync('.env.local')); + if (localEnv.SPONSOR_SIGNER) { + process.env.SPONSOR_SIGNER = localEnv.SPONSOR_SIGNER; } } } async function getGitRemote() { try { - const remoteUrl = execSync("git remote get-url origin", { + const remoteUrl = execSync('git remote get-url origin', { cwd: projectRoot, - encoding: "utf8", + encoding: 'utf8', }).trim(); return remoteUrl; } catch (error) { @@ -158,9 +196,9 @@ async function getGitRemote() { async function checkVercelCLI() { try { - execSync("vercel --version", { - stdio: "ignore", - shell: process.platform === "win32", + execSync('vercel --version', { + stdio: 'ignore', + shell: process.platform === 'win32', }); return true; } catch (error) { @@ -169,23 +207,23 @@ async function checkVercelCLI() { } async function installVercelCLI() { - console.log("Installing Vercel CLI..."); - execSync("npm install -g vercel", { - stdio: "inherit", - shell: process.platform === "win32", + console.log('Installing Vercel CLI...'); + execSync('npm install -g vercel', { + stdio: 'inherit', + shell: process.platform === 'win32', }); } async function getVercelToken() { try { // Try to get token from Vercel CLI config - const configPath = path.join(os.homedir(), ".vercel", "auth.json"); + const configPath = path.join(os.homedir(), '.vercel', 'auth.json'); if (fs.existsSync(configPath)) { - const authConfig = JSON.parse(fs.readFileSync(configPath, "utf8")); + const authConfig = JSON.parse(fs.readFileSync(configPath, 'utf8')); return authConfig.token; } } catch (error) { - console.warn("Could not read Vercel token from config file"); + console.warn('Could not read Vercel token from config file'); } // Try environment variable @@ -195,75 +233,75 @@ async function getVercelToken() { // Try to extract from vercel whoami try { - const whoamiOutput = execSync("vercel whoami", { - encoding: "utf8", - stdio: "pipe", + const whoamiOutput = execSync('vercel whoami', { + encoding: 'utf8', + stdio: 'pipe', }); // If we can get whoami, we're logged in, but we need the actual token // The token isn't directly exposed, so we'll need to use CLI for some operations - console.log("āœ… Verified Vercel CLI authentication"); + console.log('āœ… Verified Vercel CLI authentication'); return null; // We'll fall back to CLI operations } catch (error) { throw new Error( - "Not logged in to Vercel CLI. Please run this script again to login." + 'Not logged in to Vercel CLI. Please run this script again to login.' ); } } async function loginToVercel() { - console.log("\nšŸ”‘ Vercel Login"); - console.log("You can either:"); - console.log("1. Log in to an existing Vercel account"); - console.log("2. Create a new Vercel account during login\n"); - console.log("If creating a new account:"); + console.log('\nšŸ”‘ Vercel Login'); + console.log('You can either:'); + console.log('1. Log in to an existing Vercel account'); + console.log('2. Create a new Vercel account during login\n'); + console.log('If creating a new account:'); console.log('1. Click "Continue with GitHub"'); - console.log("2. Authorize GitHub access"); - console.log("3. Complete the Vercel account setup in your browser"); - console.log("4. Return here once your Vercel account is created\n"); + console.log('2. Authorize GitHub access'); + console.log('3. Complete the Vercel account setup in your browser'); + console.log('4. Return here once your Vercel account is created\n'); console.log( - "\nNote: you may need to cancel this script with ctrl+c and run it again if creating a new vercel account" + '\nNote: you may need to cancel this script with ctrl+c and run it again if creating a new vercel account' ); - const child = spawn("vercel", ["login"], { - stdio: "inherit", + const child = spawn('vercel', ['login'], { + stdio: 'inherit', }); await new Promise((resolve, reject) => { - child.on("close", (code) => { + child.on('close', (code) => { resolve(); }); }); - console.log("\nšŸ“± Waiting for login to complete..."); + console.log('\nšŸ“± Waiting for login to complete...'); console.log( "If you're creating a new account, please complete the Vercel account setup in your browser first." ); for (let i = 0; i < 150; i++) { try { - execSync("vercel whoami", { stdio: "ignore" }); - console.log("āœ… Successfully logged in to Vercel!"); + execSync('vercel whoami', { stdio: 'ignore' }); + console.log('āœ… Successfully logged in to Vercel!'); return true; } catch (error) { - if (error.message.includes("Account not found")) { - console.log("ā„¹ļø Waiting for Vercel account setup to complete..."); + if (error.message.includes('Account not found')) { + console.log('ā„¹ļø Waiting for Vercel account setup to complete...'); } await new Promise((resolve) => setTimeout(resolve, 2000)); } } - console.error("\nāŒ Login timed out. Please ensure you have:"); - console.error("1. Completed the Vercel account setup in your browser"); - console.error("2. Authorized the GitHub integration"); - console.error("Then try running this script again."); + console.error('\nāŒ Login timed out. Please ensure you have:'); + console.error('1. Completed the Vercel account setup in your browser'); + console.error('2. Authorized the GitHub integration'); + console.error('Then try running this script again.'); return false; } async function setVercelEnvVarSDK(vercelClient, projectId, key, value) { try { let processedValue; - if (typeof value === "object") { + if (typeof value === 'object') { processedValue = JSON.stringify(value); } else { processedValue = value.toString(); @@ -275,7 +313,7 @@ async function setVercelEnvVarSDK(vercelClient, projectId, key, value) { }); const existingVar = existingVars.envs?.find( - (env) => env.key === key && env.target?.includes("production") + (env) => env.key === key && env.target?.includes('production') ); if (existingVar) { @@ -285,7 +323,7 @@ async function setVercelEnvVarSDK(vercelClient, projectId, key, value) { id: existingVar.id, requestBody: { value: processedValue, - target: ["production"], + target: ['production'], }, }); console.log(`āœ… Updated environment variable: ${key}`); @@ -296,8 +334,8 @@ async function setVercelEnvVarSDK(vercelClient, projectId, key, value) { requestBody: { key: key, value: processedValue, - type: "encrypted", - target: ["production"], + type: 'encrypted', + target: ['production'], }, }); console.log(`āœ… Created environment variable: ${key}`); @@ -319,7 +357,7 @@ async function setVercelEnvVarCLI(key, value, projectRoot) { try { execSync(`vercel env rm ${key} production -y`, { cwd: projectRoot, - stdio: "ignore", + stdio: 'ignore', env: process.env, }); } catch (error) { @@ -327,7 +365,7 @@ async function setVercelEnvVarCLI(key, value, projectRoot) { } let processedValue; - if (typeof value === "object") { + if (typeof value === 'object') { processedValue = JSON.stringify(value); } else { processedValue = value.toString(); @@ -335,11 +373,11 @@ async function setVercelEnvVarCLI(key, value, projectRoot) { // Create temporary file const tempFilePath = path.join(projectRoot, `${key}_temp.txt`); - fs.writeFileSync(tempFilePath, processedValue, "utf8"); + fs.writeFileSync(tempFilePath, processedValue, 'utf8'); // Use appropriate command based on platform let command; - if (process.platform === "win32") { + if (process.platform === 'win32') { command = `type "${tempFilePath}" | vercel env add ${key} production`; } else { command = `cat "${tempFilePath}" | vercel env add ${key} production`; @@ -347,7 +385,7 @@ async function setVercelEnvVarCLI(key, value, projectRoot) { execSync(command, { cwd: projectRoot, - stdio: "pipe", // Changed from 'inherit' to avoid interactive prompts + stdio: 'pipe', // Changed from 'inherit' to avoid interactive prompts shell: true, env: process.env, }); @@ -374,7 +412,7 @@ async function setEnvironmentVariables( envVars, projectRoot ) { - console.log("\nšŸ“ Setting up environment variables..."); + console.log('\nšŸ“ Setting up environment variables...'); const results = []; @@ -402,28 +440,33 @@ async function setEnvironmentVariables( console.warn(`\nāš ļø Failed to set ${failed.length} environment variables:`); failed.forEach((r) => console.warn(` - ${r.key}`)); console.warn( - "\nYou may need to set these manually in the Vercel dashboard." + '\nYou may need to set these manually in the Vercel dashboard.' ); } return results; } -async function waitForDeployment(vercelClient, projectId, maxWaitTime = 300000) { // 5 minutes +async function waitForDeployment( + vercelClient, + projectId, + maxWaitTime = 300000 +) { + // 5 minutes console.log('\nā³ Waiting for deployment to complete...'); const startTime = Date.now(); - + while (Date.now() - startTime < maxWaitTime) { try { const deployments = await vercelClient.deployments.list({ projectId: projectId, - limit: 1 + limit: 1, }); - + if (deployments.deployments?.[0]) { const deployment = deployments.deployments[0]; console.log(`šŸ“Š Deployment status: ${deployment.state}`); - + if (deployment.state === 'READY') { console.log('āœ… Deployment completed successfully!'); return deployment; @@ -432,36 +475,36 @@ async function waitForDeployment(vercelClient, projectId, maxWaitTime = 300000) } else if (deployment.state === 'CANCELED') { throw new Error('Deployment was canceled'); } - + // Still building, wait and check again - await new Promise(resolve => setTimeout(resolve, 5000)); // Wait 5 seconds + await new Promise((resolve) => setTimeout(resolve, 5000)); // Wait 5 seconds } else { console.log('ā³ No deployment found yet, waiting...'); - await new Promise(resolve => setTimeout(resolve, 5000)); + await new Promise((resolve) => setTimeout(resolve, 5000)); } } catch (error) { console.warn('āš ļø Could not check deployment status:', error.message); - await new Promise(resolve => setTimeout(resolve, 5000)); + await new Promise((resolve) => setTimeout(resolve, 5000)); } } - + throw new Error('Deployment timed out after 5 minutes'); } async function deployToVercel(useGitHub = false) { try { - console.log("\nšŸš€ Deploying to Vercel..."); + console.log('\nšŸš€ Deploying to Vercel...'); // Ensure vercel.json exists - const vercelConfigPath = path.join(projectRoot, "vercel.json"); + const vercelConfigPath = path.join(projectRoot, 'vercel.json'); if (!fs.existsSync(vercelConfigPath)) { - console.log("šŸ“ Creating vercel.json configuration..."); + console.log('šŸ“ Creating vercel.json configuration...'); fs.writeFileSync( vercelConfigPath, JSON.stringify( { - buildCommand: "next build", - framework: "nextjs", + buildCommand: 'next build', + framework: 'nextjs', }, null, 2 @@ -471,15 +514,19 @@ async function deployToVercel(useGitHub = false) { // Set up Vercel project console.log('\nšŸ“¦ Setting up Vercel project...'); - console.log('An initial deployment is required to get an assigned domain that can be used in the mini app manifest\n'); - console.log('\nāš ļø Note: choosing a longer, more unique project name will help avoid conflicts with other existing domains\n'); - + console.log( + 'An initial deployment is required to get an assigned domain that can be used in the mini app manifest\n' + ); + console.log( + '\nāš ļø Note: choosing a longer, more unique project name will help avoid conflicts with other existing domains\n' + ); + // Use spawn instead of execSync for better error handling const { spawn } = await import('child_process'); - const vercelSetup = spawn('vercel', [], { + const vercelSetup = spawn('vercel', [], { cwd: projectRoot, - stdio: "inherit", - shell: process.platform === "win32", + stdio: 'inherit', + shell: process.platform === 'win32', }); await new Promise((resolve, reject) => { @@ -492,7 +539,7 @@ async function deployToVercel(useGitHub = false) { resolve(); // Don't reject, as this is often expected } }); - + vercelSetup.on('error', (error) => { console.log('āš ļø Vercel setup command completed (this is normal)'); resolve(); // Don't reject, as this is often expected @@ -500,15 +547,19 @@ async function deployToVercel(useGitHub = false) { }); // Wait a moment for project files to be written - await new Promise(resolve => setTimeout(resolve, 2000)); + await new Promise((resolve) => setTimeout(resolve, 2000)); // Load project info let projectId; try { - const projectJson = JSON.parse(fs.readFileSync('.vercel/project.json', 'utf8')); + const projectJson = JSON.parse( + fs.readFileSync('.vercel/project.json', 'utf8') + ); projectId = projectJson.projectId; } catch (error) { - throw new Error('Failed to load project info. Please ensure the Vercel project was created successfully.'); + throw new Error( + 'Failed to load project info. Please ensure the Vercel project was created successfully.' + ); } // Get Vercel token and initialize SDK client @@ -519,16 +570,16 @@ async function deployToVercel(useGitHub = false) { vercelClient = new Vercel({ bearerToken: token, }); - console.log("āœ… Initialized Vercel SDK client"); + console.log('āœ… Initialized Vercel SDK client'); } } catch (error) { console.warn( - "āš ļø Could not initialize Vercel SDK, falling back to CLI operations" + 'āš ļø Could not initialize Vercel SDK, falling back to CLI operations' ); } // Get project details - console.log("\nšŸ” Getting project details..."); + console.log('\nšŸ” Getting project details...'); let domain; let projectName; @@ -539,10 +590,10 @@ async function deployToVercel(useGitHub = false) { }); projectName = project.name; domain = `${projectName}.vercel.app`; - console.log("🌐 Using project name for domain:", domain); + console.log('🌐 Using project name for domain:', domain); } catch (error) { console.warn( - "āš ļø Could not get project details via SDK, using CLI fallback" + 'āš ļø Could not get project details via SDK, using CLI fallback' ); } } @@ -550,16 +601,19 @@ async function deployToVercel(useGitHub = false) { // Fallback to CLI method if SDK failed if (!domain) { try { - const inspectOutput = execSync(`vercel project inspect ${projectId} 2>&1`, { - cwd: projectRoot, - encoding: 'utf8' - }); + const inspectOutput = execSync( + `vercel project inspect ${projectId} 2>&1`, + { + cwd: projectRoot, + encoding: 'utf8', + } + ); const nameMatch = inspectOutput.match(/Name\s+([^\n]+)/); if (nameMatch) { projectName = nameMatch[1].trim(); domain = `${projectName}.vercel.app`; - console.log("🌐 Using project name for domain:", domain); + console.log('🌐 Using project name for domain:', domain); } else { const altMatch = inspectOutput.match(/Found Project [^/]+\/([^\n]+)/); if (altMatch) { @@ -567,7 +621,9 @@ async function deployToVercel(useGitHub = false) { domain = `${projectName}.vercel.app`; console.log('🌐 Using project name for domain:', domain); } else { - console.warn('āš ļø Could not determine project name from inspection, using fallback'); + console.warn( + 'āš ļø Could not determine project name from inspection, using fallback' + ); // Use a fallback domain based on project ID domain = `project-${projectId.slice(-8)}.vercel.app`; console.log('🌐 Using fallback domain:', domain); @@ -582,7 +638,7 @@ async function deployToVercel(useGitHub = false) { } // Generate mini app metadata - console.log("\nšŸ”Ø Generating mini app metadata..."); + console.log('\nšŸ”Ø Generating mini app metadata...'); const webhookUrl = process.env.NEYNAR_API_KEY && process.env.NEYNAR_CLIENT_ID @@ -590,11 +646,11 @@ async function deployToVercel(useGitHub = false) { : `https://${domain}/api/webhook`; const miniAppMetadata = await generateFarcasterMetadata(domain, webhookUrl); - console.log("āœ… Mini app metadata generated"); + console.log('āœ… Mini app metadata generated'); // Prepare environment variables const nextAuthSecret = - process.env.NEXTAUTH_SECRET || crypto.randomBytes(32).toString("hex"); + process.env.NEXTAUTH_SECRET || crypto.randomBytes(32).toString('hex'); const vercelEnv = { NEXTAUTH_SECRET: nextAuthSecret, AUTH_SECRET: nextAuthSecret, @@ -607,11 +663,14 @@ async function deployToVercel(useGitHub = false) { ...(process.env.NEYNAR_CLIENT_ID && { NEYNAR_CLIENT_ID: process.env.NEYNAR_CLIENT_ID, }), + ...(process.env.SPONSOR_SIGNER && { + SPONSOR_SIGNER: process.env.SPONSOR_SIGNER, + }), ...(miniAppMetadata && { MINI_APP_METADATA: miniAppMetadata }), ...Object.fromEntries( Object.entries(process.env).filter(([key]) => - key.startsWith("NEXT_PUBLIC_") + key.startsWith('NEXT_PUBLIC_') ) ), }; @@ -626,21 +685,21 @@ async function deployToVercel(useGitHub = false) { // Deploy the project if (useGitHub) { - console.log("\nSetting up GitHub integration..."); - execSync("vercel link", { + console.log('\nSetting up GitHub integration...'); + execSync('vercel link', { cwd: projectRoot, - stdio: "inherit", + stdio: 'inherit', env: process.env, }); - console.log("\nšŸ“¦ Deploying with GitHub integration..."); + console.log('\nšŸ“¦ Deploying with GitHub integration...'); } else { - console.log("\nšŸ“¦ Deploying local code directly..."); + console.log('\nšŸ“¦ Deploying local code directly...'); } // Use spawn for better control over the deployment process - const vercelDeploy = spawn('vercel', ['deploy', '--prod'], { + const vercelDeploy = spawn('vercel', ['deploy', '--prod'], { cwd: projectRoot, - stdio: "inherit", + stdio: 'inherit', env: process.env, }); @@ -654,7 +713,7 @@ async function deployToVercel(useGitHub = false) { reject(new Error(`Vercel deployment failed with exit code: ${code}`)); } }); - + vercelDeploy.on('error', (error) => { console.error('āŒ Vercel deployment error:', error.message); reject(error); @@ -667,13 +726,16 @@ async function deployToVercel(useGitHub = false) { try { deployment = await waitForDeployment(vercelClient, projectId); } catch (error) { - console.warn('āš ļø Could not verify deployment completion:', error.message); + console.warn( + 'āš ļø Could not verify deployment completion:', + error.message + ); console.log('ā„¹ļø Proceeding with domain verification...'); } } // Verify actual domain after deployment - console.log("\nšŸ” Verifying deployment domain..."); + console.log('\nšŸ” Verifying deployment domain...'); let actualDomain = domain; if (vercelClient && deployment) { @@ -682,14 +744,14 @@ async function deployToVercel(useGitHub = false) { console.log('🌐 Verified actual domain:', actualDomain); } catch (error) { console.warn( - "āš ļø Could not verify domain via SDK, using assumed domain" + 'āš ļø Could not verify domain via SDK, using assumed domain' ); } } // Update environment variables if domain changed if (actualDomain !== domain) { - console.log("šŸ”„ Updating environment variables with correct domain..."); + console.log('šŸ”„ Updating environment variables with correct domain...'); const webhookUrl = process.env.NEYNAR_API_KEY && process.env.NEYNAR_CLIENT_ID @@ -702,16 +764,27 @@ async function deployToVercel(useGitHub = false) { }; if (miniAppMetadata) { - const updatedMetadata = await generateFarcasterMetadata(actualDomain, fid, await validateSeedPhrase(process.env.SEED_PHRASE), process.env.SEED_PHRASE, webhookUrl); + const updatedMetadata = await generateFarcasterMetadata( + actualDomain, + fid, + await validateSeedPhrase(process.env.SEED_PHRASE), + process.env.SEED_PHRASE, + webhookUrl + ); updatedEnv.MINI_APP_METADATA = updatedMetadata; } - await setEnvironmentVariables(vercelClient, projectId, updatedEnv, projectRoot); + await setEnvironmentVariables( + vercelClient, + projectId, + updatedEnv, + projectRoot + ); console.log('\nšŸ“¦ Redeploying with correct domain...'); - const vercelRedeploy = spawn('vercel', ['deploy', '--prod'], { + const vercelRedeploy = spawn('vercel', ['deploy', '--prod'], { cwd: projectRoot, - stdio: "inherit", + stdio: 'inherit', env: process.env, }); @@ -725,49 +798,49 @@ async function deployToVercel(useGitHub = false) { reject(new Error(`Redeployment failed with exit code: ${code}`)); } }); - + vercelRedeploy.on('error', (error) => { console.error('āŒ Redeployment error:', error.message); reject(error); }); }); - + domain = actualDomain; } - console.log("\n✨ Deployment complete! Your mini app is now live at:"); + console.log('\n✨ Deployment complete! Your mini app is now live at:'); console.log(`🌐 https://${domain}`); console.log( - "\nšŸ“ You can manage your project at https://vercel.com/dashboard" + '\nšŸ“ You can manage your project at https://vercel.com/dashboard' ); } catch (error) { - console.error("\nāŒ Deployment failed:", error.message); + console.error('\nāŒ Deployment failed:', error.message); process.exit(1); } } async function main() { try { - console.log("šŸš€ Vercel Mini App Deployment (SDK Edition)"); + console.log('šŸš€ Vercel Mini App Deployment (SDK Edition)'); console.log( - "This script will deploy your mini app to Vercel using the Vercel SDK." + 'This script will deploy your mini app to Vercel using the Vercel SDK.' ); - console.log("\nThe script will:"); - console.log("1. Check for required environment variables"); - console.log("2. Set up a Vercel project (new or existing)"); - console.log("3. Configure environment variables in Vercel using SDK"); - console.log("4. Deploy and build your mini app\n"); + console.log('\nThe script will:'); + console.log('1. Check for required environment variables'); + console.log('2. Set up a Vercel project (new or existing)'); + console.log('3. Configure environment variables in Vercel using SDK'); + console.log('4. Deploy and build your mini app\n'); // Check if @vercel/sdk is installed try { - await import("@vercel/sdk"); + await import('@vercel/sdk'); } catch (error) { - console.log("šŸ“¦ Installing @vercel/sdk..."); - execSync("npm install @vercel/sdk", { + console.log('šŸ“¦ Installing @vercel/sdk...'); + execSync('npm install @vercel/sdk', { cwd: projectRoot, - stdio: "inherit", + stdio: 'inherit', }); - console.log("āœ… @vercel/sdk installed successfully"); + console.log('āœ… @vercel/sdk installed successfully'); } await checkRequiredEnvVars(); @@ -776,55 +849,55 @@ async function main() { let useGitHub = false; if (remoteUrl) { - console.log("\nšŸ“¦ Found GitHub repository:", remoteUrl); + console.log('\nšŸ“¦ Found GitHub repository:', remoteUrl); const { useGitHubDeploy } = await inquirer.prompt([ { - type: "confirm", - name: "useGitHubDeploy", - message: "Would you like to deploy from the GitHub repository?", + type: 'confirm', + name: 'useGitHubDeploy', + message: 'Would you like to deploy from the GitHub repository?', default: true, }, ]); useGitHub = useGitHubDeploy; } else { - console.log("\nāš ļø No GitHub repository found."); + console.log('\nāš ļø No GitHub repository found.'); const { action } = await inquirer.prompt([ { - type: "list", - name: "action", - message: "What would you like to do?", + type: 'list', + name: 'action', + message: 'What would you like to do?', choices: [ - { name: "Deploy local code directly", value: "deploy" }, - { name: "Set up GitHub repository first", value: "setup" }, + { name: 'Deploy local code directly', value: 'deploy' }, + { name: 'Set up GitHub repository first', value: 'setup' }, ], - default: "deploy", + default: 'deploy', }, ]); - if (action === "setup") { - console.log("\nšŸ‘‹ Please set up your GitHub repository first:"); - console.log("1. Create a new repository on GitHub"); - console.log("2. Run these commands:"); - console.log(" git remote add origin "); - console.log(" git push -u origin main"); - console.log("\nThen run this script again to deploy."); + if (action === 'setup') { + console.log('\nšŸ‘‹ Please set up your GitHub repository first:'); + console.log('1. Create a new repository on GitHub'); + console.log('2. Run these commands:'); + console.log(' git remote add origin '); + console.log(' git push -u origin main'); + console.log('\nThen run this script again to deploy.'); process.exit(0); } } if (!(await checkVercelCLI())) { - console.log("Vercel CLI not found. Installing..."); + console.log('Vercel CLI not found. Installing...'); await installVercelCLI(); } if (!(await loginToVercel())) { - console.error("\nāŒ Failed to log in to Vercel. Please try again."); + console.error('\nāŒ Failed to log in to Vercel. Please try again.'); process.exit(1); } await deployToVercel(useGitHub); } catch (error) { - console.error("\nāŒ Error:", error.message); + console.error('\nāŒ Error:', error.message); process.exit(1); } } diff --git a/src/app/api/auth/nonce/route.ts b/src/app/api/auth/nonce/route.ts new file mode 100644 index 0000000..a1f25ea --- /dev/null +++ b/src/app/api/auth/nonce/route.ts @@ -0,0 +1,16 @@ +import { NextResponse } from 'next/server'; +import { getNeynarClient } from '~/lib/neynar'; + +export async function GET() { + 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/session-signers/route.ts b/src/app/api/auth/session-signers/route.ts new file mode 100644 index 0000000..630ef3b --- /dev/null +++ b/src/app/api/auth/session-signers/route.ts @@ -0,0 +1,43 @@ +import { NextResponse } from 'next/server'; +import { getNeynarClient } from '~/lib/neynar'; + +export async function GET(request: Request) { + try { + const { searchParams } = new URL(request.url); + const message = searchParams.get('message'); + const signature = searchParams.get('signature'); + + if (!message || !signature) { + return NextResponse.json( + { error: 'Message and signature are required' }, + { status: 400 } + ); + } + + const client = getNeynarClient(); + const data = await client.fetchSigners({ message, signature }); + const signers = data.signers; + + // Fetch user data if signers exist + let user = null; + if (signers && signers.length > 0 && signers[0].fid) { + const { + users: [fetchedUser], + } = await client.fetchBulkUsers({ + fids: [signers[0].fid], + }); + user = fetchedUser; + } + + return NextResponse.json({ + signers, + user, + }); + } catch (error) { + console.error('Error in session-signers API:', error); + return NextResponse.json( + { error: 'Failed to fetch signers' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/auth/signer/route.ts b/src/app/api/auth/signer/route.ts new file mode 100644 index 0000000..f793d0e --- /dev/null +++ b/src/app/api/auth/signer/route.ts @@ -0,0 +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 signerUuid = searchParams.get('signerUuid'); + + if (!signerUuid) { + return NextResponse.json( + { error: 'signerUuid is required' }, + { status: 400 } + ); + } + + try { + const neynarClient = getNeynarClient(); + const signer = await neynarClient.lookupSigner({ + signerUuid, + }); + return NextResponse.json(signer); + } catch (error) { + console.error('Error fetching signed key:', error); + return NextResponse.json( + { error: 'Failed to fetch signed key' }, + { status: 500 } + ); + } +} 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..d7a3df8 --- /dev/null +++ b/src/app/api/auth/signer/signed_key/route.ts @@ -0,0 +1,91 @@ +import { NextResponse } from 'next/server'; +import { getNeynarClient } from '~/lib/neynar'; +import { mnemonicToAccount } from 'viem/accounts'; +import { + SIGNED_KEY_REQUEST_TYPE, + SIGNED_KEY_REQUEST_VALIDATOR_EIP_712_DOMAIN, +} from '~/lib/constants'; + +const postRequiredFields = ['signerUuid', 'publicKey']; + +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/app/api/auth/update-session/route.ts b/src/app/api/auth/update-session/route.ts new file mode 100644 index 0000000..db4b4fc --- /dev/null +++ b/src/app/api/auth/update-session/route.ts @@ -0,0 +1,46 @@ +import { NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '~/auth'; + +export async function POST(request: Request) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user?.fid) { + return NextResponse.json( + { error: 'No authenticated session found' }, + { status: 401 } + ); + } + + const body = await request.json(); + const { signers, user } = body; + + if (!signers || !user) { + return NextResponse.json( + { error: 'Signers and user are required' }, + { status: 400 } + ); + } + + // For NextAuth to update the session, we need to trigger the JWT callback + // This is typically done by calling the session endpoint with updated data + // However, we can't directly modify the session token from here + + // Instead, we'll store the data temporarily and let the client refresh the session + // The session will be updated when the JWT callback is triggered + + return NextResponse.json({ + success: true, + message: 'Session update prepared', + signers, + user, + }); + } catch (error) { + console.error('Error preparing session update:', error); + return NextResponse.json( + { error: 'Failed to prepare session update' }, + { status: 500 } + ); + } +} diff --git a/src/app/providers.tsx b/src/app/providers.tsx index 959cf90..90584eb 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -1,27 +1,38 @@ -"use client"; +'use client'; -import dynamic from "next/dynamic"; -import type { Session } from "next-auth"; -import { SessionProvider } from "next-auth/react"; -import { MiniAppProvider } from "@neynar/react"; -import { SafeFarcasterSolanaProvider } from "~/components/providers/SafeFarcasterSolanaProvider"; -import { ANALYTICS_ENABLED } from "~/lib/constants"; +import dynamic from 'next/dynamic'; +import type { Session } from 'next-auth'; +import { SessionProvider } from 'next-auth/react'; +import { MiniAppProvider } from '@neynar/react'; +import { SafeFarcasterSolanaProvider } from '~/components/providers/SafeFarcasterSolanaProvider'; +import { ANALYTICS_ENABLED } from '~/lib/constants'; +import { AuthKitProvider } from '@farcaster/auth-kit'; const WagmiProvider = dynamic( - () => import("~/components/providers/WagmiProvider"), + () => import('~/components/providers/WagmiProvider'), { ssr: false, } ); -export function Providers({ session, children }: { session: Session | null, children: React.ReactNode }) { - const solanaEndpoint = process.env.SOLANA_RPC_ENDPOINT || "https://solana-rpc.publicnode.com"; +export function Providers({ + session, + children, +}: { + session: Session | null; + children: React.ReactNode; +}) { + const solanaEndpoint = + process.env.SOLANA_RPC_ENDPOINT || 'https://solana-rpc.publicnode.com'; return ( - + - {children} + {children} diff --git a/src/auth.ts b/src/auth.ts index 8c39468..c3345fb 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -1,11 +1,200 @@ -import { AuthOptions, getServerSession } from "next-auth" -import CredentialsProvider from "next-auth/providers/credentials"; -import { createAppClient, viemConnector } from "@farcaster/auth-client"; +import { AuthOptions, getServerSession } from 'next-auth'; +import CredentialsProvider from 'next-auth/providers/credentials'; +import { createAppClient, viemConnector } from '@farcaster/auth-client'; -declare module "next-auth" { +declare module 'next-auth' { interface Session { - user: { + provider?: string; + user?: { fid: number; + object?: 'user'; + username?: string; + display_name?: string; + pfp_url?: string; + custody_address?: string; + profile?: { + bio: { + text: string; + mentioned_profiles?: Array<{ + object: 'user_dehydrated'; + fid: number; + username: string; + display_name: string; + pfp_url: string; + custody_address: string; + }>; + mentioned_profiles_ranges?: Array<{ + start: number; + end: number; + }>; + }; + location?: { + latitude: number; + longitude: number; + address: { + city: string; + state: string; + country: string; + country_code: string; + }; + }; + }; + follower_count?: number; + following_count?: number; + verifications?: string[]; + verified_addresses?: { + eth_addresses: string[]; + sol_addresses: string[]; + primary: { + eth_address: string; + sol_address: string; + }; + }; + verified_accounts?: Array>; + power_badge?: boolean; + url?: string; + experimental?: { + neynar_user_score: number; + deprecation_notice: string; + }; + score?: number; + }; + signers?: { + object: 'signer'; + signer_uuid: string; + public_key: string; + status: 'approved'; + fid: number; + }[]; + } + + interface User { + provider?: string; + signers?: Array<{ + object: 'signer'; + signer_uuid: string; + public_key: string; + status: 'approved'; + fid: number; + }>; + user?: { + object: 'user'; + fid: number; + username: string; + display_name: string; + pfp_url: string; + custody_address: string; + profile: { + bio: { + text: string; + mentioned_profiles?: Array<{ + object: 'user_dehydrated'; + fid: number; + username: string; + display_name: string; + pfp_url: string; + custody_address: string; + }>; + mentioned_profiles_ranges?: Array<{ + start: number; + end: number; + }>; + }; + location?: { + latitude: number; + longitude: number; + address: { + city: string; + state: string; + country: string; + country_code: string; + }; + }; + }; + follower_count: number; + following_count: number; + verifications: string[]; + verified_addresses: { + eth_addresses: string[]; + sol_addresses: string[]; + primary: { + eth_address: string; + sol_address: string; + }; + }; + verified_accounts: Array>; + power_badge: boolean; + url?: string; + experimental?: { + neynar_user_score: number; + deprecation_notice: string; + }; + score: number; + }; + } + + interface JWT { + provider?: string; + signers?: Array<{ + object: 'signer'; + signer_uuid: string; + public_key: string; + status: 'approved'; + fid: number; + }>; + user?: { + object: 'user'; + fid: number; + username: string; + display_name: string; + pfp_url: string; + custody_address: string; + profile: { + bio: { + text: string; + mentioned_profiles?: Array<{ + object: 'user_dehydrated'; + fid: number; + username: string; + display_name: string; + pfp_url: string; + custody_address: string; + }>; + mentioned_profiles_ranges?: Array<{ + start: number; + end: number; + }>; + }; + location?: { + latitude: number; + longitude: number; + address: { + city: string; + state: string; + country: string; + country_code: string; + }; + }; + }; + follower_count: number; + following_count: number; + verifications: string[]; + verified_addresses: { + eth_addresses: string[]; + sol_addresses: string[]; + primary: { + eth_address: string; + sol_address: string; + }; + }; + verified_accounts?: Array>; + power_badge?: boolean; + url?: string; + experimental?: { + neynar_user_score: number; + deprecation_notice: string; + }; + score?: number; }; } } @@ -26,43 +215,49 @@ function getDomainFromUrl(urlString: string | undefined): string { } export const authOptions: AuthOptions = { - // Configure one or more authentication providers + // Configure one or more authentication providers providers: [ CredentialsProvider({ - name: "Sign in with Farcaster", + id: 'farcaster', + name: 'Sign in with Farcaster', credentials: { message: { - label: "Message", - type: "text", - placeholder: "0x0", + label: 'Message', + type: 'text', + placeholder: '0x0', }, signature: { - label: "Signature", - type: "text", - placeholder: "0x0", + label: 'Signature', + type: 'text', + placeholder: '0x0', + }, + nonce: { + label: 'Nonce', + type: 'text', + placeholder: 'Custom nonce (optional)', }, // In a production app with a server, these should be fetched from // your Farcaster data indexer rather than have them accepted as part // of credentials. // question: should these natively use the Neynar API? name: { - label: "Name", - type: "text", - placeholder: "0x0", + label: 'Name', + type: 'text', + placeholder: '0x0', }, pfp: { - label: "Pfp", - type: "text", - placeholder: "0x0", + label: 'Pfp', + type: 'text', + placeholder: '0x0', }, }, async authorize(credentials, req) { - const csrfToken = req?.body?.csrfToken; - if (!csrfToken) { - console.error('CSRF token is missing from request'); + const nonce = req?.body?.csrfToken; + + if (!nonce) { + console.error('No nonce or CSRF token provided'); return null; } - const appClient = createAppClient({ ethereum: viemConnector(), }); @@ -73,8 +268,9 @@ export const authOptions: AuthOptions = { message: credentials?.message as string, signature: credentials?.signature as `0x${string}`, domain, - nonce: csrfToken, + nonce, }); + const { success, fid } = verifyResponse; if (!success) { @@ -83,47 +279,155 @@ export const authOptions: AuthOptions = { return { id: fid.toString(), + name: credentials?.name || `User ${fid}`, + image: credentials?.pfp || null, + provider: 'farcaster', }; }, }), + CredentialsProvider({ + id: 'neynar', + name: 'Sign in with Neynar', + credentials: { + message: { + label: 'Message', + type: 'text', + placeholder: '0x0', + }, + signature: { + label: 'Signature', + type: 'text', + placeholder: '0x0', + }, + nonce: { + label: 'Nonce', + type: 'text', + placeholder: 'Custom nonce (optional)', + }, + fid: { + label: 'FID', + type: 'text', + placeholder: '0', + }, + signers: { + label: 'Signers', + type: 'text', + placeholder: 'JSON string of signers', + }, + user: { + label: 'User Data', + type: 'text', + placeholder: 'JSON string of user data', + }, + }, + async authorize(credentials) { + const nonce = credentials?.nonce; + + if (!nonce) { + console.error('No nonce or CSRF token provided for Neynar auth'); + return null; + } + + // For Neynar, we can use a different validation approach + // This could involve validating against Neynar's API or using their SDK + try { + // Validate the signature using Farcaster's auth client (same as Farcaster provider) + const appClient = createAppClient({ + ethereum: viemConnector(), + }); + + const domain = getDomainFromUrl(process.env.NEXTAUTH_URL); + + const verifyResponse = await appClient.verifySignInMessage({ + message: credentials?.message as string, + signature: credentials?.signature as `0x${string}`, + domain, + nonce, + }); + + const { success, fid } = verifyResponse; + + if (!success) { + return null; + } + + // Validate that the provided FID matches the verified FID + if (credentials?.fid && parseInt(credentials.fid) !== fid) { + console.error('FID mismatch in Neynar auth'); + return null; + } + + return { + id: fid.toString(), + provider: 'neynar', + signers: credentials?.signers + ? JSON.parse(credentials.signers) + : undefined, + user: credentials?.user ? JSON.parse(credentials.user) : undefined, + }; + } catch (error) { + console.error('Error in Neynar auth:', error); + return null; + } + }, + }), ], callbacks: { session: async ({ session, token }) => { - if (session?.user) { - session.user.fid = parseInt(token.sub ?? ''); + // Set provider at the root level + session.provider = token.provider as string; + + if (token.provider === 'farcaster') { + // For Farcaster, simple structure + session.user = { + fid: parseInt(token.sub ?? ''), + }; + } else if (token.provider === 'neynar') { + // For Neynar, use full user data structure from user + session.user = token.user as typeof session.user; + session.signers = token.signers as typeof session.signers; } + return session; }, + jwt: async ({ token, user }) => { + if (user) { + token.provider = user.provider; + token.signers = user.signers; + token.user = user.user; + } + return token; + }, }, cookies: { sessionToken: { name: `next-auth.session-token`, options: { httpOnly: true, - sameSite: "none", - path: "/", - secure: true - } + sameSite: 'none', + path: '/', + secure: true, + }, }, callbackUrl: { name: `next-auth.callback-url`, options: { - sameSite: "none", - path: "/", - secure: true - } + sameSite: 'none', + path: '/', + secure: true, + }, }, csrfToken: { name: `next-auth.csrf-token`, options: { httpOnly: true, - sameSite: "none", - path: "/", - secure: true - } - } - } -} + sameSite: 'none', + path: '/', + secure: true, + }, + }, + }, +}; export const getSession = async () => { try { @@ -132,4 +436,4 @@ export const getSession = async () => { console.error('Error getting server session:', error); return null; } -} +}; diff --git a/src/components/ui/NeynarAuthButton/AuthDialog.tsx b/src/components/ui/NeynarAuthButton/AuthDialog.tsx new file mode 100644 index 0000000..436efb0 --- /dev/null +++ b/src/components/ui/NeynarAuthButton/AuthDialog.tsx @@ -0,0 +1,219 @@ +'use client'; + +export 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: 'Sign in', + description: + "To sign in, 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' : content.title} +

+ +
+ +
+ {isError ? ( +
+
+ {error?.message || 'Unknown error, please try again.'} +
+
+ ) : ( +
+
+ {typeof content.description === 'string' ? ( +

+ {content.description} +

+ ) : ( + content.description + )} +
+ +
+ {content.showQR && content.qrUrl ? ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + QR Code +
+ ) : step === 'loading' || isLoading ? ( +
+
+
+ + {step === 'loading' + ? 'Setting up access...' + : 'Loading...'} + +
+
+ ) : null} +
+ + {content.showOpenButton && content.qrUrl && ( + + )} +
+ )} +
+
+
+ ); +} diff --git a/src/components/ui/NeynarAuthButton/ProfileButton.tsx b/src/components/ui/NeynarAuthButton/ProfileButton.tsx new file mode 100644 index 0000000..bcf1ca7 --- /dev/null +++ b/src/components/ui/NeynarAuthButton/ProfileButton.tsx @@ -0,0 +1,92 @@ +'use client'; + +import { useRef, useState } from 'react'; +import { useDetectClickOutside } from '~/hooks/useDetectClickOutside'; +import { cn } from '~/lib/utils'; + +export function ProfileButton({ + userData, + onSignOut, +}: { + userData?: { fid?: number; pfpUrl?: string; username?: string }; + onSignOut: () => void; +}) { + const [showDropdown, setShowDropdown] = useState(false); + const ref = useRef(null); + + useDetectClickOutside(ref, () => setShowDropdown(false)); + + const name = userData?.username ?? `!${userData?.fid}`; + const pfpUrl = userData?.pfpUrl ?? 'https://farcaster.xyz/avatar.png'; + + return ( +
+ + + {showDropdown && ( +
+ +
+ )} +
+ ); +} diff --git a/src/components/ui/NeynarAuthButton/index.tsx b/src/components/ui/NeynarAuthButton/index.tsx new file mode 100644 index 0000000..c78516e --- /dev/null +++ b/src/components/ui/NeynarAuthButton/index.tsx @@ -0,0 +1,666 @@ +'use client'; + +import '@farcaster/auth-kit/styles.css'; +import { useSignIn } from '@farcaster/auth-kit'; +import { useCallback, useEffect, useState } from 'react'; +import { cn } from '~/lib/utils'; +import { Button } from '~/components/ui/Button'; +import { isMobile } from '~/lib/devices'; +import { ProfileButton } from '~/components/ui/NeynarAuthButton/ProfileButton'; +import { AuthDialog } from '~/components/ui/NeynarAuthButton/AuthDialog'; +import { getItem, removeItem, setItem } from '~/lib/localStorage'; +import { useMiniApp } from '@neynar/react'; +import { + signIn as backendSignIn, + signOut as backendSignOut, + useSession, +} from 'next-auth/react'; +import sdk, { SignIn as SignInCore } from '@farcaster/frame-sdk'; + +type User = { + fid: number; + username: string; + display_name: string; + pfp_url: string; + // Add other user properties as needed +}; + +const STORAGE_KEY = 'neynar_authenticated_user'; +const FARCASTER_FID = 9152; + +interface StoredAuthState { + isAuthenticated: boolean; + user: { + object: 'user'; + fid: number; + username: string; + display_name: string; + pfp_url: string; + custody_address: string; + profile: { + bio: { + text: string; + mentioned_profiles?: Array<{ + object: 'user_dehydrated'; + fid: number; + username: string; + display_name: string; + pfp_url: string; + custody_address: string; + }>; + mentioned_profiles_ranges?: Array<{ + start: number; + end: number; + }>; + }; + location?: { + latitude: number; + longitude: number; + address: { + city: string; + state: string; + country: string; + country_code: string; + }; + }; + }; + follower_count: number; + following_count: number; + verifications: string[]; + verified_addresses: { + eth_addresses: string[]; + sol_addresses: string[]; + primary: { + eth_address: string; + sol_address: string; + }; + }; + verified_accounts: Array>; + power_badge: boolean; + url?: string; + experimental?: { + neynar_user_score: number; + deprecation_notice: string; + }; + score: number; + } | null; + signers: { + object: 'signer'; + signer_uuid: string; + public_key: string; + status: 'approved'; + fid: number; + }[]; +} + +// Main Custom SignInButton Component +export function NeynarAuthButton() { + const [nonce, setNonce] = useState(null); + const [storedAuth, setStoredAuth] = useState(null); + const [signersLoading, setSignersLoading] = useState(false); + const { context } = useMiniApp(); + const { data: session } = useSession(); + // 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 + ); + const [message, setMessage] = useState(null); + const [signature, setSignature] = useState(null); + + // Determine which flow to use based on context + const useBackendFlow = context !== undefined; + + // Helper function to create a signer + const createSigner = useCallback(async () => { + try { + const response = await fetch('/api/auth/signer', { + method: 'POST', + }); + + if (!response.ok) { + throw new Error('Failed to create signer'); + } + + const signerData = await response.json(); + return signerData; + } catch (error) { + console.error('āŒ Error creating signer:', error); + // throw error; + } + }, []); + + // Helper function to update session with signers (backend flow only) + const updateSessionWithSigners = useCallback( + async ( + signers: StoredAuthState['signers'], + user: StoredAuthState['user'] + ) => { + if (!useBackendFlow) return; + + try { + // For backend flow, we need to sign in again with the additional data + if (message && signature) { + const signInData = { + message, + signature, + redirect: false, + nonce: nonce || '', + fid: user?.fid?.toString() || '', + signers: JSON.stringify(signers), + user: JSON.stringify(user), + }; + + await backendSignIn('neynar', signInData); + } + } catch (error) { + console.error('āŒ Error updating session with signers:', error); + } + }, + [useBackendFlow, message, signature, nonce] + ); + + // Helper function to fetch user data from Neynar API + const fetchUserData = useCallback( + async (fid: number): Promise => { + try { + const response = await fetch(`/api/users?fids=${fid}`); + if (response.ok) { + const data = await response.json(); + return data.users?.[0] || null; + } + return null; + } catch (error) { + console.error('Error fetching user data:', error); + return null; + } + }, + [] + ); + + // Helper function to generate signed key request + const generateSignedKeyRequest = useCallback( + async (signerUuid: string, publicKey: string) => { + try { + // 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(); + + 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 { + setSignersLoading(true); + + const endpoint = useBackendFlow + ? `/api/auth/session-signers?message=${encodeURIComponent( + message + )}&signature=${signature}` + : `/api/auth/signers?message=${encodeURIComponent( + message + )}&signature=${signature}`; + + const response = await fetch(endpoint); + const signerData = await response.json(); + + if (response.ok) { + if (useBackendFlow) { + // For backend flow, update session with signers + if (signerData.signers && signerData.signers.length > 0) { + const user = + signerData.user || + (await fetchUserData(signerData.signers[0].fid)); + await updateSessionWithSigners(signerData.signers, user); + } + return signerData.signers; + } else { + // For frontend flow, store in localStorage + let user: StoredAuthState['user'] | null = null; + + if (signerData.signers && signerData.signers.length > 0) { + const fetchedUser = (await fetchUserData( + signerData.signers[0].fid + )) as StoredAuthState['user']; + user = fetchedUser; + } + + // Store signers in localStorage, preserving existing auth data + const updatedState: StoredAuthState = { + isAuthenticated: !!user, + signers: signerData.signers || [], + user, + }; + setItem(STORAGE_KEY, 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); + } + }, + [useBackendFlow, fetchUserData, updateSessionWithSigners] + ); + + // Helper function to poll signer status + const startPolling = useCallback( + (signerUuid: string, message: string, signature: string) => { + 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(); + + if (signerData.status === '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); + } + }, 2000); // Poll every 2 second + + setPollingInterval(interval); + }, + [fetchAllSigners] + ); + + // Cleanup polling on unmount + useEffect(() => { + return () => { + if (pollingInterval) { + clearInterval(pollingInterval); + } + }; + }, [pollingInterval]); + + // Generate nonce + useEffect(() => { + const generateNonce = async () => { + try { + const response = await fetch('/api/auth/nonce'); + if (response.ok) { + const data = await response.json(); + setNonce(data.nonce); + } else { + console.error('Failed to fetch nonce'); + } + } catch (error) { + console.error('Error generating nonce:', error); + } + }; + + generateNonce(); + }, []); + + // Load stored auth state on mount (only for frontend flow) + useEffect(() => { + if (!useBackendFlow) { + const stored = getItem(STORAGE_KEY); + if (stored && stored.isAuthenticated) { + setStoredAuth(stored); + } + } + }, [useBackendFlow]); + + // Success callback - this is critical! + const onSuccessCallback = useCallback( + async (res: unknown) => { + if (!useBackendFlow) { + // Only handle localStorage for frontend flow + const existingAuth = getItem(STORAGE_KEY); + const user = await fetchUserData(res.fid); + const authState: StoredAuthState = { + ...existingAuth, + isAuthenticated: true, + user: user as StoredAuthState['user'], + signers: existingAuth?.signers || [], // Preserve existing signers + }; + setItem(STORAGE_KEY, authState); + setStoredAuth(authState); + } + // For backend flow, the session will be handled by NextAuth + }, + [useBackendFlow, fetchUserData] + ); + + // Error callback + const onErrorCallback = useCallback((error?: Error | null) => { + console.error('āŒ Sign in error:', error); + }, []); + + const signInState = useSignIn({ + nonce: nonce || undefined, + onSuccess: onSuccessCallback, + onError: onErrorCallback, + }); + + const { + signIn: frontendSignIn, + signOut: frontendSignOut, + connect, + reconnect, + isSuccess, + isError, + error, + channelToken, + url, + data, + validSignature, + } = signInState; + + useEffect(() => { + setMessage(data?.message || null); + setSignature(data?.signature || null); + }, [data?.message, data?.signature]); + + // Connect for frontend flow when nonce is available + useEffect(() => { + if (!useBackendFlow && nonce && !channelToken) { + connect(); + } + }, [useBackendFlow, nonce, channelToken, connect]); + + // Handle fetching signers after successful authentication + useEffect(() => { + if (message && signature) { + const handleSignerFlow = async () => { + try { + const clientContext = context?.client as Record; + const isMobileContext = + clientContext?.platformType === 'mobile' && + clientContext?.clientFid === FARCASTER_FID; + + // Step 1: Change to loading state + setDialogStep('loading'); + + // Show dialog if not using backend flow or in browser farcaster + if ((useBackendFlow && !isMobileContext) || !useBackendFlow) + setShowDialog(true); + + // First, fetch existing signers + const signers = await fetchAllSigners(message, signature); + if (useBackendFlow && isMobileContext) setSignersLoading(true); + + // Check if no signers exist or if we have empty signers + if (!signers || signers.length === 0) { + // 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 + setSignerApprovalUrl(signedKeyData.signer_approval_url); + + if (isMobileContext) { + setShowDialog(false); + await sdk.actions.openUrl( + signedKeyData.signer_approval_url.replace( + 'https://client.farcaster.xyz/deeplinks/', + 'farcaster://' + ) + ); + } else { + setShowDialog(true); // Ensure dialog is shown during loading + setDialogStep('access'); + } + + // Step 4: Start polling for signer approval + startPolling(newSigner.signer_uuid, message, signature); + } else { + // If signers exist, close the dialog + setSignersLoading(false); + setShowDialog(false); + setDialogStep('signin'); + } + } catch (error) { + console.error('āŒ Error in signer flow:', error); + // On error, reset to signin step and hide dialog + setDialogStep('signin'); + setSignersLoading(false); + setShowDialog(false); + setSignerApprovalUrl(null); + } + }; + + handleSignerFlow(); + } + }, [ + message, + signature, + fetchAllSigners, + createSigner, + generateSignedKeyRequest, + startPolling, + context, + useBackendFlow, + ]); + + // Backend flow using NextAuth + const handleBackendSignIn = useCallback(async () => { + if (!nonce) { + console.error('āŒ No nonce available for backend sign-in'); + return; + } + + try { + setSignersLoading(true); + const result = await sdk.actions.signIn({ nonce }); + + const signInData = { + message: result.message, + signature: result.signature, + redirect: false, + nonce: nonce, + }; + + const nextAuthResult = await backendSignIn('neynar', signInData); + if (nextAuthResult?.ok) { + setMessage(result.message); + setSignature(result.signature); + } else { + console.error('āŒ NextAuth sign-in failed:', nextAuthResult); + } + } catch (e) { + if (e instanceof SignInCore.RejectedByUser) { + console.log('ā„¹ļø Sign-in rejected by user'); + } else { + console.error('āŒ Backend sign-in error:', e); + } + } + }, [nonce]); + + const handleFrontEndSignIn = useCallback(() => { + if (isError) { + reconnect(); + } + setDialogStep('signin'); + setShowDialog(true); + frontendSignIn(); + }, [isError, reconnect, frontendSignIn]); + + const handleSignOut = useCallback(async () => { + try { + setSignersLoading(true); + + if (useBackendFlow) { + // Only sign out from NextAuth if the current session is from Neynar provider + if (session?.provider === 'neynar') { + await backendSignOut({ redirect: false }); + } + } else { + // Frontend flow sign out + frontendSignOut(); + removeItem(STORAGE_KEY); + setStoredAuth(null); + } + + // Common cleanup for both flows + setShowDialog(false); + setDialogStep('signin'); + setSignerApprovalUrl(null); + setMessage(null); + setSignature(null); + + // Reset polling interval + if (pollingInterval) { + clearInterval(pollingInterval); + setPollingInterval(null); + } + } catch (error) { + console.error('āŒ Error during sign out:', error); + // Optionally handle error state + } finally { + setSignersLoading(false); + } + }, [useBackendFlow, frontendSignOut, pollingInterval, session]); + + const authenticated = useBackendFlow + ? !!( + session?.provider === 'neynar' && + session?.user?.fid && + session?.signers && + session.signers.length > 0 + ) + : ((isSuccess && validSignature) || storedAuth?.isAuthenticated) && + !!(storedAuth?.signers && storedAuth.signers.length > 0); + + const userData = useBackendFlow + ? { + fid: session?.user?.fid, + username: session?.user?.username || '', + pfpUrl: session?.user?.pfp_url || '', + } + : { + fid: storedAuth?.user?.fid, + username: storedAuth?.user?.username || '', + pfpUrl: storedAuth?.user?.pfp_url || '', + }; + + // Show loading state while nonce is being fetched or signers are loading + if (!nonce || signersLoading) { + return ( +
+
+
+ + Loading... + +
+
+ ); + } + + return ( + <> + {authenticated ? ( + + ) : ( + + )} + + {/* Unified Auth Dialog */} + { + { + setShowDialog(false); + setDialogStep('signin'); + setSignerApprovalUrl(null); + if (pollingInterval) { + clearInterval(pollingInterval); + setPollingInterval(null); + } + }} + url={url} + isError={isError} + error={error} + step={dialogStep} + isLoading={signersLoading} + signerApprovalUrl={signerApprovalUrl} + /> + } + + ); +} diff --git a/src/components/ui/tabs/ActionsTab.tsx b/src/components/ui/tabs/ActionsTab.tsx index 2f8af39..789c5b8 100644 --- a/src/components/ui/tabs/ActionsTab.tsx +++ b/src/components/ui/tabs/ActionsTab.tsx @@ -1,4 +1,4 @@ -"use client"; +'use client'; import { useCallback, useState } from "react"; import { useMiniApp } from "@neynar/react"; @@ -6,10 +6,11 @@ import { ShareButton } from "../Share"; import { Button } from "../Button"; import { SignIn } from "../wallet/SignIn"; import { type Haptics } from "@farcaster/miniapp-sdk"; +import { NeynarAuthButton } from '../NeynarAuthButton/index'; /** * ActionsTab component handles mini app actions like sharing, notifications, and haptic feedback. - * + * * This component provides the main interaction interface for users to: * - Share the mini app with others * - Sign in with Farcaster @@ -17,10 +18,10 @@ import { type Haptics } from "@farcaster/miniapp-sdk"; * - Trigger haptic feedback * - Add the mini app to their client * - Copy share URLs - * + * * The component uses the useMiniApp hook to access Farcaster context and actions. * All state is managed locally within this component. - * + * * @example * ```tsx * @@ -28,63 +29,68 @@ import { type Haptics } from "@farcaster/miniapp-sdk"; */ export function ActionsTab() { // --- Hooks --- - const { - actions, - added, - notificationDetails, - haptics, - context, - } = useMiniApp(); - + const { actions, added, notificationDetails, haptics, context } = + useMiniApp(); + // --- State --- const [notificationState, setNotificationState] = useState({ - sendStatus: "", + sendStatus: '', shareUrlCopied: false, }); - const [selectedHapticIntensity, setSelectedHapticIntensity] = useState('medium'); + const [selectedHapticIntensity, setSelectedHapticIntensity] = + useState('medium'); // --- Handlers --- /** * Sends a notification to the current user's Farcaster account. - * + * * This function makes a POST request to the /api/send-notification endpoint * with the user's FID and notification details. It handles different response * statuses including success (200), rate limiting (429), and errors. - * + * * @returns Promise that resolves when the notification is sent or fails */ const sendFarcasterNotification = useCallback(async () => { - setNotificationState((prev) => ({ ...prev, sendStatus: "" })); + setNotificationState((prev) => ({ ...prev, sendStatus: '' })); if (!notificationDetails || !context) { return; } try { - const response = await fetch("/api/send-notification", { - method: "POST", - mode: "same-origin", - headers: { "Content-Type": "application/json" }, + const response = await fetch('/api/send-notification', { + method: 'POST', + mode: 'same-origin', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fid: context.user.fid, notificationDetails, }), }); if (response.status === 200) { - setNotificationState((prev) => ({ ...prev, sendStatus: "Success" })); + setNotificationState((prev) => ({ ...prev, sendStatus: 'Success' })); return; } else if (response.status === 429) { - setNotificationState((prev) => ({ ...prev, sendStatus: "Rate limited" })); + setNotificationState((prev) => ({ + ...prev, + sendStatus: 'Rate limited', + })); return; } const responseText = await response.text(); - setNotificationState((prev) => ({ ...prev, sendStatus: `Error: ${responseText}` })); + setNotificationState((prev) => ({ + ...prev, + sendStatus: `Error: ${responseText}`, + })); } catch (error) { - setNotificationState((prev) => ({ ...prev, sendStatus: `Error: ${error}` })); + setNotificationState((prev) => ({ + ...prev, + sendStatus: `Error: ${error}`, + })); } }, [context, notificationDetails]); /** * Copies the share URL for the current user to the clipboard. - * + * * This function generates a share URL using the user's FID and copies it * to the clipboard. It shows a temporary "Copied!" message for 2 seconds. */ @@ -93,13 +99,17 @@ export function ActionsTab() { const userShareUrl = `${process.env.NEXT_PUBLIC_URL}/share/${context.user.fid}`; await navigator.clipboard.writeText(userShareUrl); setNotificationState((prev) => ({ ...prev, shareUrlCopied: true })); - setTimeout(() => setNotificationState((prev) => ({ ...prev, shareUrlCopied: false })), 2000); + setTimeout( + () => + setNotificationState((prev) => ({ ...prev, shareUrlCopied: false })), + 2000 + ); } }, [context?.user?.fid]); /** * Triggers haptic feedback with the selected intensity. - * + * * This function calls the haptics.impactOccurred method with the current * selectedHapticIntensity setting. It handles errors gracefully by logging them. */ @@ -113,56 +123,76 @@ export function ActionsTab() { // --- Render --- return ( -
+
{/* Share functionality */} - {/* Authentication */} - {/* Mini app actions */} - + {/* Neynar Authentication */} + - + + {/* Notification functionality */} {notificationState.sendStatus && ( -
+
Send notification result: {notificationState.sendStatus}
)} - {/* Share URL copying */} - {/* Haptic feedback controls */} -
-
); -} \ No newline at end of file +} diff --git a/src/components/ui/wallet/SignIn.tsx b/src/components/ui/wallet/SignIn.tsx index 0f2dda5..9ca825c 100644 --- a/src/components/ui/wallet/SignIn.tsx +++ b/src/components/ui/wallet/SignIn.tsx @@ -1,4 +1,4 @@ -"use client"; +'use client'; import { useCallback, useState } from "react"; import { signIn, signOut, getCsrfToken } from "next-auth/react"; @@ -8,17 +8,17 @@ import { Button } from "../Button"; /** * SignIn component handles Farcaster authentication using Sign-In with Farcaster (SIWF). - * + * * This component provides a complete authentication flow for Farcaster users: * - Generates nonces for secure authentication * - Handles the SIWF flow using the Farcaster SDK * - Manages NextAuth session state * - Provides sign-out functionality * - Displays authentication status and results - * + * * The component integrates with both the Farcaster Frame SDK and NextAuth * to provide seamless authentication within mini apps. - * + * * @example * ```tsx * @@ -45,29 +45,29 @@ export function SignIn() { // --- Handlers --- /** * Generates a nonce for the sign-in process. - * + * * This function retrieves a CSRF token from NextAuth to use as a nonce * for the SIWF authentication flow. The nonce ensures the authentication * request is fresh and prevents replay attacks. - * + * * @returns Promise - The generated nonce token * @throws Error if unable to generate nonce */ const getNonce = useCallback(async () => { const nonce = await getCsrfToken(); - if (!nonce) throw new Error("Unable to generate nonce"); + if (!nonce) throw new Error('Unable to generate nonce'); return nonce; }, []); /** * Handles the sign-in process using Farcaster SDK. - * + * * This function orchestrates the complete SIWF flow: * 1. Generates a nonce for security * 2. Calls the Farcaster SDK to initiate sign-in * 3. Submits the result to NextAuth for session management * 4. Handles various error conditions including user rejection - * + * * @returns Promise */ const handleSignIn = useCallback(async () => { @@ -77,17 +77,17 @@ export function SignIn() { const nonce = await getNonce(); const result = await sdk.actions.signIn({ nonce }); setSignInResult(result); - await signIn("credentials", { + await signIn('farcaster', { message: result.message, signature: result.signature, redirect: false, }); } catch (e) { if (e instanceof SignInCore.RejectedByUser) { - setSignInFailure("Rejected by user"); + setSignInFailure('Rejected by user'); return; } - setSignInFailure("Unknown error"); + setSignInFailure('Unknown error'); } finally { setAuthState((prev) => ({ ...prev, signingIn: false })); } @@ -95,32 +95,35 @@ export function SignIn() { /** * Handles the sign-out process. - * - * This function clears the NextAuth session and resets the local - * sign-in result state to complete the sign-out flow. - * + * + * This function clears the NextAuth session only if the current session + * is using the Farcaster provider, and resets the local sign-in result state. + * * @returns Promise */ const handleSignOut = useCallback(async () => { try { setAuthState((prev) => ({ ...prev, signingOut: true })); - await signOut({ redirect: false }); + // Only sign out if the current session is from Farcaster provider + if (session?.provider === 'farcaster') { + await signOut({ redirect: false }); + } setSignInResult(undefined); } finally { setAuthState((prev) => ({ ...prev, signingOut: false })); } - }, []); + }, [session]); // --- Render --- return ( <> {/* Authentication Buttons */} - {status !== "authenticated" && ( + {(status !== 'authenticated' || session?.provider !== 'farcaster') && ( )} - {status === "authenticated" && ( + {status === 'authenticated' && session?.provider === 'farcaster' && ( @@ -155,4 +158,4 @@ export function SignIn() { )} ); -} \ No newline at end of file +} diff --git a/src/hooks/useDetectClickOutside.ts b/src/hooks/useDetectClickOutside.ts new file mode 100644 index 0000000..e6b1533 --- /dev/null +++ b/src/hooks/useDetectClickOutside.ts @@ -0,0 +1,18 @@ +import { useEffect } from 'react'; + +export function useDetectClickOutside( + ref: React.RefObject, + callback: () => void +) { + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (ref.current && !ref.current.contains(event.target as Node)) { + callback(); + } + } + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [ref, callback]); +} diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 6c980be..7a7661b 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -1,10 +1,10 @@ /** * Application constants and configuration values. - * + * * This file contains all the configuration constants used throughout the mini app. * These values are either sourced from environment variables or hardcoded and provide * configuration for the app's appearance, behavior, and integration settings. - * + * * NOTE: This file is automatically updated by the init script. * Manual changes may be overwritten during project initialization. */ @@ -20,19 +20,19 @@ export const APP_URL = process.env.NEXT_PUBLIC_URL!; * The name of the mini app as displayed to users. * Used in titles, headers, and app store listings. */ -export const APP_NAME = 'Starter Kit'; +export const APP_NAME = 'shreyas-testing-mini-app'; /** * A brief description of the mini app's functionality. * Used in app store listings and metadata. */ -export const APP_DESCRIPTION = 'A demo of the Neynar Starter Kit'; +export const APP_DESCRIPTION = 'A Farcaster mini app created with Neynar'; /** * The primary category for the mini app. * Used for app store categorization and discovery. */ -export const APP_PRIMARY_CATEGORY = 'developer-tools'; +export const APP_PRIMARY_CATEGORY = ''; /** * Tags associated with the mini app. @@ -63,30 +63,31 @@ export const APP_SPLASH_URL = `${APP_URL}/splash.png`; * Background color for the splash screen. * Used as fallback when splash image is loading. */ -export const APP_SPLASH_BACKGROUND_COLOR = "#f7f7f7"; +export const APP_SPLASH_BACKGROUND_COLOR = '#f7f7f7'; // --- UI Configuration --- /** * Text displayed on the main action button. * Used for the primary call-to-action in the mini app. */ -export const APP_BUTTON_TEXT = 'Launch NSK'; +export const APP_BUTTON_TEXT = 'Launch Mini App'; // --- Integration Configuration --- /** * Webhook URL for receiving events from Neynar. - * + * * If Neynar API key and client ID are configured, uses the official * Neynar webhook endpoint. Otherwise, falls back to a local webhook * endpoint for development and testing. */ -export const APP_WEBHOOK_URL = process.env.NEYNAR_API_KEY && process.env.NEYNAR_CLIENT_ID +export const APP_WEBHOOK_URL = + process.env.NEYNAR_API_KEY && process.env.NEYNAR_CLIENT_ID ? `https://api.neynar.com/f/app/${process.env.NEYNAR_CLIENT_ID}/event` : `${APP_URL}/api/webhook`; /** * Flag to enable/disable wallet functionality. - * + * * When true, wallet-related components and features are rendered. * When false, wallet functionality is completely hidden from the UI. * Useful for mini apps that don't require wallet integration. @@ -95,9 +96,25 @@ export const USE_WALLET = true; /** * Flag to enable/disable analytics tracking. - * + * * When true, usage analytics are collected and sent to Neynar. * When false, analytics collection is disabled. * Useful for privacy-conscious users or development environments. */ export const ANALYTICS_ENABLED = true; + +// PLEASE DO NOT UPDATE THIS +export const SIGNED_KEY_REQUEST_VALIDATOR_EIP_712_DOMAIN = { + name: 'Farcaster SignedKeyRequestValidator', + version: '1', + chainId: 10, + verifyingContract: + '0x00000000fc700472606ed4fa22623acf62c60553' as `0x${string}`, +}; + +// PLEASE DO NOT UPDATE THIS +export const SIGNED_KEY_REQUEST_TYPE = [ + { name: 'requestFid', type: 'uint256' }, + { name: 'key', type: 'bytes' }, + { name: 'deadline', type: 'uint256' }, +]; diff --git a/src/lib/devices.ts b/src/lib/devices.ts new file mode 100644 index 0000000..f6757ec --- /dev/null +++ b/src/lib/devices.ts @@ -0,0 +1,27 @@ +function isAndroid(): boolean { + return ( + typeof navigator !== 'undefined' && /android/i.test(navigator.userAgent) + ); +} + +function isSmallIOS(): boolean { + return ( + typeof navigator !== 'undefined' && /iPhone|iPod/.test(navigator.userAgent) + ); +} + +function isLargeIOS(): boolean { + return ( + typeof navigator !== 'undefined' && + (/iPad/.test(navigator.userAgent) || + (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)) + ); +} + +function isIOS(): boolean { + return isSmallIOS() || isLargeIOS(); +} + +export function isMobile(): boolean { + return isAndroid() || isIOS(); +} diff --git a/src/lib/localStorage.ts b/src/lib/localStorage.ts new file mode 100644 index 0000000..0d86b65 --- /dev/null +++ b/src/lib/localStorage.ts @@ -0,0 +1,25 @@ +export function setItem(key: string, value: T) { + try { + localStorage.setItem(key, JSON.stringify(value)); + } catch (error) { + console.warn('Failed to save item:', error); + } +} + +export function getItem(key: string): T | null { + try { + const stored = localStorage.getItem(key); + return stored ? JSON.parse(stored) : null; + } catch (error) { + console.warn('Failed to load item:', error); + return null; + } +} + +export function removeItem(key: string) { + try { + localStorage.removeItem(key); + } catch (error) { + console.warn('Failed to remove item:', error); + } +}