Merge pull request #13 from neynarxyz/shreyas/neyn-5735-sign-in-connect-farcaster-using-developer-branded-signer

Shreyas/neyn 5735 sign in connect farcaster using developer branded signer
This commit is contained in:
Manan 2025-07-11 16:26:45 -07:00 committed by GitHub
commit 36d2b5d0f7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 2287 additions and 465 deletions

View File

@ -12,7 +12,9 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename); const __dirname = dirname(__filename);
const REPO_URL = 'https://github.com/neynarxyz/create-farcaster-mini-app.git'; 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 // ANSI color codes
const purple = '\x1b[35m'; const purple = '\x1b[35m';
@ -48,8 +50,8 @@ async function queryNeynarApp(apiKey) {
`https://api.neynar.com/portal/app_by_api_key?starter_kit=true`, `https://api.neynar.com/portal/app_by_api_key?starter_kit=true`,
{ {
headers: { headers: {
'x-api-key': apiKey 'x-api-key': apiKey,
} },
} }
); );
const data = await response.json(); const data = await response.json();
@ -80,16 +82,17 @@ export async function init(projectName = null, autoAcceptDefaults = false) {
{ {
type: 'confirm', type: 'confirm',
name: 'useNeynar', name: 'useNeynar',
message: `🪐 ${purple}${bright}${italic}Neynar is an API that makes it easy to build on Farcaster.${reset}\n\n` + message:
'Benefits of using Neynar in your mini app:\n' + `🪐 ${purple}${bright}${italic}Neynar is an API that makes it easy to build on Farcaster.${reset}\n\n` +
'- Pre-configured webhook handling (no setup required)\n' + 'Benefits of using Neynar in your mini app:\n' +
'- Automatic mini app analytics in your dev portal\n' + '- Pre-configured webhook handling (no setup required)\n' +
'- Send manual notifications from dev.neynar.com\n' + '- Automatic mini app analytics in your dev portal\n' +
'- Built-in rate limiting and error handling\n\n' + '- Send manual notifications from dev.neynar.com\n' +
`${purple}${bright}${italic}A demo API key is included if you would like to try out Neynar before signing up!${reset}\n\n` + '- Built-in rate limiting and error handling\n\n' +
'Would you like to use Neynar in your mini app?', `${purple}${bright}${italic}A demo API key is included if you would like to try out Neynar before signing up!${reset}\n\n` +
default: true '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; 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; let neynarKeyAnswer;
if (autoAcceptDefaults) { if (autoAcceptDefaults) {
neynarKeyAnswer = { neynarApiKey: null }; neynarKeyAnswer = { neynarApiKey: null };
@ -109,8 +114,8 @@ export async function init(projectName = null, autoAcceptDefaults = false) {
type: 'password', type: 'password',
name: 'neynarApiKey', name: 'neynarApiKey',
message: 'Enter your Neynar API key (or press enter to skip):', 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', type: 'confirm',
name: 'useDemo', name: 'useDemo',
message: 'Would you like to try the demo Neynar API key?', message: 'Would you like to try the demo Neynar API key?',
default: true default: true,
} },
]); ]);
} }
if (useDemoKey.useDemo) { if (useDemoKey.useDemo) {
console.warn('\n⚠ Note: the demo key is for development purposes only and is aggressively rate limited.'); console.warn(
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.'); '\n⚠ Note: the demo key is for development purposes only and is aggressively rate limited.'
console.log(`\n${purple}${bright}${italic}Neynar now has a free tier! See https://neynar.com/#pricing for details.\n${reset}`); );
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'; neynarApiKey = 'FARCASTER_V2_FRAMES_DEMO';
} }
} }
@ -144,14 +155,16 @@ export async function init(projectName = null, autoAcceptDefaults = false) {
useNeynar = false; useNeynar = false;
break; 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([ const { retry } = await inquirer.prompt([
{ {
type: 'confirm', type: 'confirm',
name: 'retry', name: 'retry',
message: 'Try configuring Neynar again?', message: 'Try configuring Neynar again?',
default: true default: true,
} },
]); ]);
if (!retry) { if (!retry) {
useNeynar = false; useNeynar = false;
@ -176,9 +189,10 @@ export async function init(projectName = null, autoAcceptDefaults = false) {
{ {
type: 'confirm', type: 'confirm',
name: 'retry', name: 'retry',
message: '⚠️ Could not find a client ID for this API key. Would you like to try configuring Neynar again?', message:
default: true '⚠️ Could not find a client ID for this API key. Would you like to try configuring Neynar again?',
} default: true,
},
]); ]);
if (!retry) { if (!retry) {
useNeynar = false; useNeynar = false;
@ -191,7 +205,10 @@ export async function init(projectName = null, autoAcceptDefaults = false) {
break; break;
} }
const defaultMiniAppName = (neynarAppName && !neynarAppName.toLowerCase().includes('demo')) ? neynarAppName : undefined; const defaultMiniAppName =
neynarAppName && !neynarAppName.toLowerCase().includes('demo')
? neynarAppName
: undefined;
let answers; let answers;
if (autoAcceptDefaults) { if (autoAcceptDefaults) {
@ -203,7 +220,9 @@ export async function init(projectName = null, autoAcceptDefaults = false) {
buttonText: 'Launch Mini App', buttonText: 'Launch Mini App',
useWallet: true, useWallet: true,
useTunnel: true, useTunnel: true,
enableAnalytics: true enableAnalytics: true,
seedPhrase: null,
sponsorSigner: false,
}; };
} else { } else {
// If autoAcceptDefaults is false but we have a projectName, we still need to ask for other options // 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 'Project name cannot be empty';
} }
return true; return true;
} },
} },
]); ]);
answers = await inquirer.prompt([ answers = await inquirer.prompt([
{ {
type: 'input', type: 'input',
name: 'description', name: 'description',
message: 'Give a one-line description of your mini app (optional):', 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', type: 'list',
name: 'primaryCategory', 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: [ choices: [
new inquirer.Separator(), new inquirer.Separator(),
{ name: 'Skip (not recommended)', value: null }, { name: 'Skip (not recommended)', value: null },
@ -249,23 +269,24 @@ export async function init(projectName = null, autoAcceptDefaults = false) {
{ name: 'Education', value: 'education' }, { name: 'Education', value: 'education' },
{ name: 'Developer Tools', value: 'developer-tools' }, { name: 'Developer Tools', value: 'developer-tools' },
{ name: 'Entertainment', value: 'entertainment' }, { name: 'Entertainment', value: 'entertainment' },
{ name: 'Art & Creativity', value: 'art-creativity' } { name: 'Art & Creativity', value: 'art-creativity' },
], ],
default: null default: null,
}, },
{ {
type: 'input', type: 'input',
name: 'tags', 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: '', default: '',
filter: (input) => { filter: (input) => {
if (!input.trim()) return []; if (!input.trim()) return [];
// Split by both spaces and commas, trim whitespace, and filter out empty strings // Split by both spaces and commas, trim whitespace, and filter out empty strings
return input return input
.split(/[,\s]+/) .split(/[,\s]+/)
.map(tag => tag.trim()) .map((tag) => tag.trim())
.filter(tag => tag.length > 0); .filter((tag) => tag.length > 0);
} },
}, },
{ {
type: 'input', type: 'input',
@ -277,8 +298,8 @@ export async function init(projectName = null, autoAcceptDefaults = false) {
return 'Button text cannot be empty'; return 'Button text cannot be empty';
} }
return true; return true;
} },
} },
]); ]);
// Merge project name from the first prompt // Merge project name from the first prompt
@ -289,7 +310,8 @@ export async function init(projectName = null, autoAcceptDefaults = false) {
{ {
type: 'confirm', type: 'confirm',
name: 'useWallet', 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' + 'This includes:\n' +
'- EVM wallet connection\n' + '- EVM wallet connection\n' +
'- Transaction signing\n' + '- Transaction signing\n' +
@ -297,8 +319,8 @@ export async function init(projectName = null, autoAcceptDefaults = false) {
'- Chain switching\n' + '- Chain switching\n' +
'- Solana support\n\n' + '- Solana support\n\n' +
'Include wallet and transaction features?', 'Include wallet and transaction features?',
default: true default: true,
} },
]); ]);
answers.useWallet = walletAnswer.useWallet; answers.useWallet = walletAnswer.useWallet;
@ -307,11 +329,12 @@ export async function init(projectName = null, autoAcceptDefaults = false) {
{ {
type: 'confirm', type: 'confirm',
name: 'useTunnel', 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}` + `⚠️ ${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?', 'Configure a tunnel for mobile testing and/or Warpcast developer tools?',
default: true default: true,
} },
]); ]);
answers.useTunnel = hostingAnswer.useTunnel; answers.useTunnel = hostingAnswer.useTunnel;
@ -320,9 +343,10 @@ export async function init(projectName = null, autoAcceptDefaults = false) {
{ {
type: 'confirm', type: 'confirm',
name: 'enableAnalytics', name: 'enableAnalytics',
message: 'Would you like to help improve Neynar products by sharing usage data from your mini app?', message:
default: true 'Would you like to help improve Neynar products by sharing usage data from your mini app?',
} default: true,
},
]); ]);
answers.enableAnalytics = analyticsAnswer.enableAnalytics; answers.enableAnalytics = analyticsAnswer.enableAnalytics;
} }
@ -337,19 +361,19 @@ export async function init(projectName = null, autoAcceptDefaults = false) {
try { try {
console.log(`\nCloning repository from ${REPO_URL}...`); console.log(`\nCloning repository from ${REPO_URL}...`);
// Use separate commands for better cross-platform compatibility // Use separate commands for better cross-platform compatibility
execSync(`git clone ${REPO_URL} "${projectPath}"`, { execSync(`git clone ${REPO_URL} "${projectPath}"`, {
stdio: 'inherit', stdio: 'inherit',
shell: process.platform === 'win32' shell: process.platform === 'win32',
}); });
execSync('git fetch origin main', { execSync('git fetch origin main', {
cwd: projectPath, cwd: projectPath,
stdio: 'inherit', stdio: 'inherit',
shell: process.platform === 'win32' shell: process.platform === 'win32',
}); });
execSync('git reset --hard origin/main', { execSync('git reset --hard origin/main', {
cwd: projectPath, cwd: projectPath,
stdio: 'inherit', stdio: 'inherit',
shell: process.platform === 'win32' shell: process.platform === 'win32',
}); });
} catch (error) { } catch (error) {
console.error('\n❌ Error: Failed to create project directory.'); console.error('\n❌ Error: Failed to create project directory.');
@ -386,46 +410,47 @@ export async function init(projectName = null, autoAcceptDefaults = false) {
// Add dependencies // Add dependencies
packageJson.dependencies = { packageJson.dependencies = {
"@farcaster/auth-client": ">=0.3.0 <1.0.0", '@farcaster/auth-client': '>=0.3.0 <1.0.0',
"@farcaster/auth-kit": ">=0.6.0 <1.0.0", '@farcaster/auth-kit': '>=0.6.0 <1.0.0',
"@farcaster/miniapp-node": ">=0.1.5 <1.0.0", '@farcaster/miniapp-node': '>=0.1.5 <1.0.0',
"@farcaster/miniapp-sdk": ">=0.1.6 <1.0.0", '@farcaster/miniapp-sdk': '>=0.1.6 <1.0.0',
"@farcaster/miniapp-wagmi-connector": "^1.0.0", '@farcaster/miniapp-wagmi-connector': '^1.0.0',
"@farcaster/mini-app-solana": ">=0.0.17 <1.0.0", '@farcaster/mini-app-solana': '>=0.0.17 <1.0.0',
"@neynar/react": "^1.2.5", '@neynar/react': '^1.2.5',
"@radix-ui/react-label": "^2.1.1", '@radix-ui/react-label': '^2.1.1',
"@solana/wallet-adapter-react": "^0.15.38", '@solana/wallet-adapter-react': '^0.15.38',
"@tanstack/react-query": "^5.61.0", '@tanstack/react-query': '^5.61.0',
"@upstash/redis": "^1.34.3", '@upstash/redis': '^1.34.3',
"class-variance-authority": "^0.7.1", 'class-variance-authority': '^0.7.1',
"clsx": "^2.1.1", clsx: '^2.1.1',
"dotenv": "^16.4.7", dotenv: '^16.4.7',
"lucide-react": "^0.469.0", 'lucide-react': '^0.469.0',
"mipd": "^0.0.7", mipd: '^0.0.7',
"next": "^15", next: '^15',
"next-auth": "^4.24.11", 'next-auth': '^4.24.11',
"react": "^19", react: '^19',
"react-dom": "^19", 'react-dom': '^19',
"tailwind-merge": "^2.6.0", 'tailwind-merge': '^2.6.0',
"tailwindcss-animate": "^1.0.7", 'tailwindcss-animate': '^1.0.7',
"viem": "^2.23.6", viem: '^2.23.6',
"wagmi": "^2.14.12", wagmi: '^2.14.12',
"zod": "^3.24.2" zod: '^3.24.2',
siwe: '^3.0.0',
}; };
packageJson.devDependencies = { packageJson.devDependencies = {
"@types/node": "^20", '@types/node': '^20',
"@types/react": "^19", '@types/react': '^19',
"@types/react-dom": "^19", '@types/react-dom': '^19',
"@vercel/sdk": "^1.9.0", '@vercel/sdk': '^1.9.0',
"crypto": "^1.0.1", crypto: '^1.0.1',
"eslint": "^8", eslint: '^8',
"eslint-config-next": "15.0.3", 'eslint-config-next': '15.0.3',
"localtunnel": "^2.0.2", localtunnel: '^2.0.2',
"pino-pretty": "^13.0.0", 'pino-pretty': '^13.0.0',
"postcss": "^8", postcss: '^8',
"tailwindcss": "^3.4.1", tailwindcss: '^3.4.1',
"typescript": "^5" typescript: '^5',
}; };
// Add Neynar SDK if selected // 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'); const constantsPath = path.join(projectPath, 'src', 'lib', 'constants.ts');
if (fs.existsSync(constantsPath)) { if (fs.existsSync(constantsPath)) {
let constantsContent = fs.readFileSync(constantsPath, 'utf8'); let constantsContent = fs.readFileSync(constantsPath, 'utf8');
// Helper function to escape single quotes in strings // Helper function to escape single quotes in strings
const escapeString = (str) => str.replace(/'/g, "\\'"); const escapeString = (str) => str.replace(/'/g, "\\'");
// Helper function to safely replace constants with validation // Helper function to safely replace constants with validation
const safeReplace = (content, pattern, replacement, constantName) => { const safeReplace = (content, pattern, replacement, constantName) => {
const match = content.match(pattern); const match = content.match(pattern);
if (!match) { 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(`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 { } else {
const newContent = content.replace(pattern, replacement); const newContent = content.replace(pattern, replacement);
return newContent; return newContent;
} }
return content; return content;
}; };
// Regex patterns that match whole lines with export const // Regex patterns that match whole lines with export const
const patterns = { const patterns = {
APP_NAME: /^export const APP_NAME\s*=\s*['"`][^'"`]*['"`];$/m, APP_NAME: /^export const APP_NAME\s*=\s*['"`][^'"`]*['"`];$/m,
APP_DESCRIPTION: /^export const APP_DESCRIPTION\s*=\s*['"`][^'"`]*['"`];$/m, APP_DESCRIPTION:
APP_PRIMARY_CATEGORY: /^export const APP_PRIMARY_CATEGORY\s*=\s*['"`][^'"`]*['"`];$/m, /^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_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, 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 // Update APP_NAME
constantsContent = safeReplace( constantsContent = safeReplace(
constantsContent, constantsContent,
@ -487,42 +523,49 @@ export async function init(projectName = null, autoAcceptDefaults = false) {
`export const APP_NAME = '${escapeString(answers.projectName)}';`, `export const APP_NAME = '${escapeString(answers.projectName)}';`,
'APP_NAME' 'APP_NAME'
); );
// Update APP_DESCRIPTION // Update APP_DESCRIPTION
constantsContent = safeReplace( constantsContent = safeReplace(
constantsContent, constantsContent,
patterns.APP_DESCRIPTION, patterns.APP_DESCRIPTION,
`export const APP_DESCRIPTION = '${escapeString(answers.description)}';`, `export const APP_DESCRIPTION = '${escapeString(
answers.description
)}';`,
'APP_DESCRIPTION' 'APP_DESCRIPTION'
); );
// Update APP_PRIMARY_CATEGORY (always update, null becomes empty string) // Update APP_PRIMARY_CATEGORY (always update, null becomes empty string)
constantsContent = safeReplace( constantsContent = safeReplace(
constantsContent, constantsContent,
patterns.APP_PRIMARY_CATEGORY, patterns.APP_PRIMARY_CATEGORY,
`export const APP_PRIMARY_CATEGORY = '${escapeString(answers.primaryCategory || '')}';`, `export const APP_PRIMARY_CATEGORY = '${escapeString(
answers.primaryCategory || ''
)}';`,
'APP_PRIMARY_CATEGORY' 'APP_PRIMARY_CATEGORY'
); );
// Update APP_TAGS // Update APP_TAGS
const tagsString = answers.tags.length > 0 const tagsString =
? `['${answers.tags.map(tag => escapeString(tag)).join("', '")}']` answers.tags.length > 0
: "['neynar', 'starter-kit', 'demo']"; ? `['${answers.tags.map((tag) => escapeString(tag)).join("', '")}']`
: "['neynar', 'starter-kit', 'demo']";
constantsContent = safeReplace( constantsContent = safeReplace(
constantsContent, constantsContent,
patterns.APP_TAGS, patterns.APP_TAGS,
`export const APP_TAGS = ${tagsString};`, `export const APP_TAGS = ${tagsString};`,
'APP_TAGS' 'APP_TAGS'
); );
// Update APP_BUTTON_TEXT (always update, use answers value) // Update APP_BUTTON_TEXT (always update, use answers value)
constantsContent = safeReplace( constantsContent = safeReplace(
constantsContent, constantsContent,
patterns.APP_BUTTON_TEXT, patterns.APP_BUTTON_TEXT,
`export const APP_BUTTON_TEXT = '${escapeString(answers.buttonText || '')}';`, `export const APP_BUTTON_TEXT = '${escapeString(
answers.buttonText || ''
)}';`,
'APP_BUTTON_TEXT' 'APP_BUTTON_TEXT'
); );
// Update USE_WALLET // Update USE_WALLET
constantsContent = safeReplace( constantsContent = safeReplace(
constantsContent, constantsContent,
@ -530,7 +573,7 @@ export async function init(projectName = null, autoAcceptDefaults = false) {
`export const USE_WALLET = ${answers.useWallet};`, `export const USE_WALLET = ${answers.useWallet};`,
'USE_WALLET' 'USE_WALLET'
); );
// Update ANALYTICS_ENABLED // Update ANALYTICS_ENABLED
constantsContent = safeReplace( constantsContent = safeReplace(
constantsContent, constantsContent,
@ -538,24 +581,35 @@ export async function init(projectName = null, autoAcceptDefaults = false) {
`export const ANALYTICS_ENABLED = ${answers.enableAnalytics};`, `export const ANALYTICS_ENABLED = ${answers.enableAnalytics};`,
'ANALYTICS_ENABLED' 'ANALYTICS_ENABLED'
); );
fs.writeFileSync(constantsPath, constantsContent); fs.writeFileSync(constantsPath, constantsContent);
} else { } else {
console.log('⚠️ constants.ts not found, skipping constants update'); 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) { if (useNeynar && neynarApiKey && neynarClientId) {
fs.appendFileSync(envPath, `\nNEYNAR_API_KEY="${neynarApiKey}"`); fs.appendFileSync(envPath, `\nNEYNAR_API_KEY="${neynarApiKey}"`);
fs.appendFileSync(envPath, `\nNEYNAR_CLIENT_ID="${neynarClientId}"`); fs.appendFileSync(envPath, `\nNEYNAR_CLIENT_ID="${neynarClientId}"`);
} else if (useNeynar) { } 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.appendFileSync(envPath, `\nUSE_TUNNEL="${answers.useTunnel}"`);
fs.unlinkSync(envExamplePath); fs.unlinkSync(envExamplePath);
} else { } 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 // Update README
@ -563,7 +617,9 @@ export async function init(projectName = null, autoAcceptDefaults = false) {
const readmePath = path.join(projectPath, 'README.md'); const readmePath = path.join(projectPath, 'README.md');
const prependText = `<!-- generated by @neynar/create-farcaster-mini-app version ${SCRIPT_VERSION} -->\n\n`; const prependText = `<!-- generated by @neynar/create-farcaster-mini-app version ${SCRIPT_VERSION} -->\n\n`;
if (fs.existsSync(readmePath)) { if (fs.existsSync(readmePath)) {
const originalReadmeContent = fs.readFileSync(readmePath, { encoding: 'utf8' }); const originalReadmeContent = fs.readFileSync(readmePath, {
encoding: 'utf8',
});
const updatedReadmeContent = prependText + originalReadmeContent; const updatedReadmeContent = prependText + originalReadmeContent;
fs.writeFileSync(readmePath, updatedReadmeContent); fs.writeFileSync(readmePath, updatedReadmeContent);
} else { } else {
@ -573,15 +629,15 @@ export async function init(projectName = null, autoAcceptDefaults = false) {
// Install dependencies // Install dependencies
console.log('\nInstalling dependencies...'); console.log('\nInstalling dependencies...');
execSync('npm cache clean --force', { execSync('npm cache clean --force', {
cwd: projectPath, cwd: projectPath,
stdio: 'inherit', stdio: 'inherit',
shell: process.platform === 'win32' shell: process.platform === 'win32',
}); });
execSync('npm install', { execSync('npm install', {
cwd: projectPath, cwd: projectPath,
stdio: 'inherit', stdio: 'inherit',
shell: process.platform === 'win32' shell: process.platform === 'win32',
}); });
// Remove the bin directory // Remove the bin directory
@ -595,12 +651,15 @@ export async function init(projectName = null, autoAcceptDefaults = false) {
console.log('\nInitializing git repository...'); console.log('\nInitializing git repository...');
execSync('git init', { cwd: projectPath }); execSync('git init', { cwd: projectPath });
execSync('git add .', { 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 // Calculate border length based on message length
const message = `✨🪐 Successfully created mini app ${finalProjectName} with git and dependencies installed! 🪐✨`; const message = `✨🪐 Successfully created mini app ${finalProjectName} with git and dependencies installed! 🪐✨`;
const borderLength = message.length; const borderLength = message.length;
const borderStars = '✨'.repeat((borderLength / 2) + 1); const borderStars = '✨'.repeat(borderLength / 2 + 1);
console.log(`\n${borderStars}`); console.log(`\n${borderStars}`);
console.log(`${message}`); console.log(`${message}`);

View File

@ -1,6 +1,6 @@
{ {
"name": "@neynar/create-farcaster-mini-app", "name": "@neynar/create-farcaster-mini-app",
"version": "1.5.6", "version": "1.5.7",
"type": "module", "type": "module",
"private": false, "private": false,
"access": "public", "access": "public",

View File

@ -46,6 +46,9 @@ async function loadEnvLocal() {
fs.writeFileSync(".env", newEnvContent); fs.writeFileSync(".env", newEnvContent);
console.log("✅ Values from .env.local have been written to .env"); console.log("✅ Values from .env.local have been written to .env");
} }
if (localEnv.SPONSOR_SIGNER) {
process.env.SPONSOR_SIGNER = localEnv.SPONSOR_SIGNER;
}
} }
} catch (error) { } catch (error) {
// Error reading .env.local, which is fine // Error reading .env.local, which is fine
@ -290,6 +293,8 @@ async function main() {
? [`NEYNAR_API_KEY="${process.env.NEYNAR_API_KEY}"`] ? [`NEYNAR_API_KEY="${process.env.NEYNAR_API_KEY}"`]
: []), : []),
...(neynarClientId ? [`NEYNAR_CLIENT_ID="${neynarClientId}"`] : []), ...(neynarClientId ? [`NEYNAR_CLIENT_ID="${neynarClientId}"`] : []),
...(process.env.SPONSOR_SIGNER ?
[`SPONSOR_SIGNER="${process.env.SPONSOR_SIGNER}"`] : []),
// FID (if it exists in current env) // FID (if it exists in current env)
...(process.env.FID ? [`FID="${process.env.FID}"`] : []), ...(process.env.FID ? [`FID="${process.env.FID}"`] : []),

View File

@ -1,33 +1,33 @@
import { execSync, spawn } from "child_process"; import { execSync, spawn } from 'child_process';
import fs from "fs"; import fs from 'fs';
import path from "path"; import path from 'path';
import os from "os"; import os from 'os';
import { fileURLToPath } from "url"; import { fileURLToPath } from 'url';
import inquirer from "inquirer"; import inquirer from 'inquirer';
import dotenv from "dotenv"; import dotenv from 'dotenv';
import crypto from "crypto"; import crypto from 'crypto';
import { Vercel } from "@vercel/sdk"; import { Vercel } from '@vercel/sdk';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.join(__dirname, ".."); const projectRoot = path.join(__dirname, '..');
// Load environment variables in specific order // Load environment variables in specific order
dotenv.config({ path: ".env" }); dotenv.config({ path: '.env' });
async function generateFarcasterMetadata(domain, webhookUrl) { async function generateFarcasterMetadata(domain, webhookUrl) {
const trimmedDomain = domain.trim(); 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 { return {
frame: { frame: {
version: "1", version: '1',
name: process.env.NEXT_PUBLIC_MINI_APP_NAME, name: process.env.NEXT_PUBLIC_MINI_APP_NAME,
iconUrl: `https://${trimmedDomain}/icon.png`, iconUrl: `https://${trimmedDomain}/icon.png`,
homeUrl: `https://${trimmedDomain}`, homeUrl: `https://${trimmedDomain}`,
imageUrl: `https://${trimmedDomain}/api/opengraph-image`, imageUrl: `https://${trimmedDomain}/api/opengraph-image`,
buttonTitle: process.env.NEXT_PUBLIC_MINI_APP_BUTTON_TEXT, buttonTitle: process.env.NEXT_PUBLIC_MINI_APP_BUTTON_TEXT,
splashImageUrl: `https://${trimmedDomain}/splash.png`, splashImageUrl: `https://${trimmedDomain}/splash.png`,
splashBackgroundColor: "#f7f7f7", splashBackgroundColor: '#f7f7f7',
webhookUrl: webhookUrl?.trim(), webhookUrl: webhookUrl?.trim(),
description: process.env.NEXT_PUBLIC_MINI_APP_DESCRIPTION, description: process.env.NEXT_PUBLIC_MINI_APP_DESCRIPTION,
primaryCategory: process.env.NEXT_PUBLIC_MINI_APP_PRIMARY_CATEGORY, primaryCategory: process.env.NEXT_PUBLIC_MINI_APP_PRIMARY_CATEGORY,
@ -38,35 +38,36 @@ async function generateFarcasterMetadata(domain, webhookUrl) {
async function loadEnvLocal() { async function loadEnvLocal() {
try { try {
if (fs.existsSync(".env.local")) { if (fs.existsSync('.env.local')) {
const { loadLocal } = await inquirer.prompt([ const { loadLocal } = await inquirer.prompt([
{ {
type: "confirm", type: 'confirm',
name: "loadLocal", name: 'loadLocal',
message: 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, default: true,
}, },
]); ]);
if (loadLocal) { if (loadLocal) {
console.log("Loading values from .env.local..."); console.log('Loading values from .env.local...');
const localEnv = dotenv.parse(fs.readFileSync(".env.local")); const localEnv = dotenv.parse(fs.readFileSync('.env.local'));
const allowedVars = [ const allowedVars = [
"NEXT_PUBLIC_MINI_APP_NAME", 'NEXT_PUBLIC_MINI_APP_NAME',
"NEXT_PUBLIC_MINI_APP_DESCRIPTION", 'NEXT_PUBLIC_MINI_APP_DESCRIPTION',
"NEXT_PUBLIC_MINI_APP_PRIMARY_CATEGORY", 'NEXT_PUBLIC_MINI_APP_PRIMARY_CATEGORY',
"NEXT_PUBLIC_MINI_APP_TAGS", 'NEXT_PUBLIC_MINI_APP_TAGS',
"NEXT_PUBLIC_MINI_APP_BUTTON_TEXT", 'NEXT_PUBLIC_MINI_APP_BUTTON_TEXT',
"NEXT_PUBLIC_ANALYTICS_ENABLED", 'NEXT_PUBLIC_ANALYTICS_ENABLED',
"NEYNAR_API_KEY", 'NEYNAR_API_KEY',
"NEYNAR_CLIENT_ID", 'NEYNAR_CLIENT_ID',
'SPONSOR_SIGNER',
]; ];
const envContent = fs.existsSync(".env") const envContent = fs.existsSync('.env')
? fs.readFileSync(".env", "utf8") + "\n" ? fs.readFileSync('.env', 'utf8') + '\n'
: ""; : '';
let newEnvContent = envContent; let newEnvContent = envContent;
for (const [key, value] of Object.entries(localEnv)) { for (const [key, value] of Object.entries(localEnv)) {
@ -78,35 +79,35 @@ async function loadEnvLocal() {
} }
} }
fs.writeFileSync(".env", newEnvContent); fs.writeFileSync('.env', newEnvContent);
console.log("✅ Values from .env.local have been written to .env"); console.log('✅ Values from .env.local have been written to .env');
} }
} }
} catch (error) { } catch (error) {
console.log("Note: No .env.local file found"); console.log('Note: No .env.local file found');
} }
} }
async function checkRequiredEnvVars() { async function checkRequiredEnvVars() {
console.log("\n📝 Checking environment variables..."); console.log('\n📝 Checking environment variables...');
console.log("Loading values from .env..."); console.log('Loading values from .env...');
await loadEnvLocal(); await loadEnvLocal();
const requiredVars = [ const requiredVars = [
{ {
name: "NEXT_PUBLIC_MINI_APP_NAME", name: 'NEXT_PUBLIC_MINI_APP_NAME',
message: "Enter the name for your frame (e.g., My Cool Mini App):", message: 'Enter the name for your frame (e.g., My Cool Mini App):',
default: process.env.NEXT_PUBLIC_MINI_APP_NAME, default: process.env.NEXT_PUBLIC_MINI_APP_NAME,
validate: (input) => 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", name: 'NEXT_PUBLIC_MINI_APP_BUTTON_TEXT',
message: "Enter the text for your frame button:", message: 'Enter the text for your frame button:',
default: default:
process.env.NEXT_PUBLIC_MINI_APP_BUTTON_TEXT ?? "Launch Mini App", process.env.NEXT_PUBLIC_MINI_APP_BUTTON_TEXT ?? 'Launch Mini App',
validate: (input) => input.trim() !== "" || "Button text cannot be empty", validate: (input) => input.trim() !== '' || 'Button text cannot be empty',
}, },
]; ];
@ -119,8 +120,8 @@ async function checkRequiredEnvVars() {
for (const varConfig of missingVars) { for (const varConfig of missingVars) {
const { value } = await inquirer.prompt([ const { value } = await inquirer.prompt([
{ {
type: "input", type: 'input',
name: "value", name: 'value',
message: varConfig.message, message: varConfig.message,
default: varConfig.default, default: varConfig.default,
validate: varConfig.validate, validate: varConfig.validate,
@ -129,26 +130,63 @@ async function checkRequiredEnvVars() {
process.env[varConfig.name] = value; process.env[varConfig.name] = value;
const envContent = fs.existsSync(".env") const envContent = fs.existsSync('.env')
? fs.readFileSync(".env", "utf8") ? fs.readFileSync('.env', 'utf8')
: ""; : '';
if (!envContent.includes(`${varConfig.name}=`)) { if (!envContent.includes(`${varConfig.name}=`)) {
const newLine = envContent ? "\n" : ""; const newLine = envContent ? '\n' : '';
fs.appendFileSync( fs.appendFileSync(
".env", '.env',
`${newLine}${varConfig.name}="${value.trim()}"` `${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() { async function getGitRemote() {
try { try {
const remoteUrl = execSync("git remote get-url origin", { const remoteUrl = execSync('git remote get-url origin', {
cwd: projectRoot, cwd: projectRoot,
encoding: "utf8", encoding: 'utf8',
}).trim(); }).trim();
return remoteUrl; return remoteUrl;
} catch (error) { } catch (error) {
@ -158,9 +196,9 @@ async function getGitRemote() {
async function checkVercelCLI() { async function checkVercelCLI() {
try { try {
execSync("vercel --version", { execSync('vercel --version', {
stdio: "ignore", stdio: 'ignore',
shell: process.platform === "win32", shell: process.platform === 'win32',
}); });
return true; return true;
} catch (error) { } catch (error) {
@ -169,23 +207,23 @@ async function checkVercelCLI() {
} }
async function installVercelCLI() { async function installVercelCLI() {
console.log("Installing Vercel CLI..."); console.log('Installing Vercel CLI...');
execSync("npm install -g vercel", { execSync('npm install -g vercel', {
stdio: "inherit", stdio: 'inherit',
shell: process.platform === "win32", shell: process.platform === 'win32',
}); });
} }
async function getVercelToken() { async function getVercelToken() {
try { try {
// Try to get token from Vercel CLI config // 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)) { if (fs.existsSync(configPath)) {
const authConfig = JSON.parse(fs.readFileSync(configPath, "utf8")); const authConfig = JSON.parse(fs.readFileSync(configPath, 'utf8'));
return authConfig.token; return authConfig.token;
} }
} catch (error) { } 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 // Try environment variable
@ -195,75 +233,75 @@ async function getVercelToken() {
// Try to extract from vercel whoami // Try to extract from vercel whoami
try { try {
const whoamiOutput = execSync("vercel whoami", { const whoamiOutput = execSync('vercel whoami', {
encoding: "utf8", encoding: 'utf8',
stdio: "pipe", stdio: 'pipe',
}); });
// If we can get whoami, we're logged in, but we need the actual token // 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 // 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 return null; // We'll fall back to CLI operations
} catch (error) { } catch (error) {
throw new 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() { async function loginToVercel() {
console.log("\n🔑 Vercel Login"); console.log('\n🔑 Vercel Login');
console.log("You can either:"); console.log('You can either:');
console.log("1. Log in to an existing Vercel account"); console.log('1. Log in to an existing Vercel account');
console.log("2. Create a new Vercel account during login\n"); console.log('2. Create a new Vercel account during login\n');
console.log("If creating a new account:"); console.log('If creating a new account:');
console.log('1. Click "Continue with GitHub"'); console.log('1. Click "Continue with GitHub"');
console.log("2. Authorize GitHub access"); console.log('2. Authorize GitHub access');
console.log("3. Complete the Vercel account setup in your browser"); 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('4. Return here once your Vercel account is created\n');
console.log( 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"], { const child = spawn('vercel', ['login'], {
stdio: "inherit", stdio: 'inherit',
}); });
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
child.on("close", (code) => { child.on('close', (code) => {
resolve(); resolve();
}); });
}); });
console.log("\n📱 Waiting for login to complete..."); console.log('\n📱 Waiting for login to complete...');
console.log( console.log(
"If you're creating a new account, please complete the Vercel account setup in your browser first." "If you're creating a new account, please complete the Vercel account setup in your browser first."
); );
for (let i = 0; i < 150; i++) { for (let i = 0; i < 150; i++) {
try { try {
execSync("vercel whoami", { stdio: "ignore" }); execSync('vercel whoami', { stdio: 'ignore' });
console.log("✅ Successfully logged in to Vercel!"); console.log('✅ Successfully logged in to Vercel!');
return true; return true;
} catch (error) { } catch (error) {
if (error.message.includes("Account not found")) { if (error.message.includes('Account not found')) {
console.log(" Waiting for Vercel account setup to complete..."); console.log(' Waiting for Vercel account setup to complete...');
} }
await new Promise((resolve) => setTimeout(resolve, 2000)); await new Promise((resolve) => setTimeout(resolve, 2000));
} }
} }
console.error("\n❌ Login timed out. Please ensure you have:"); console.error('\n❌ Login timed out. Please ensure you have:');
console.error("1. Completed the Vercel account setup in your browser"); console.error('1. Completed the Vercel account setup in your browser');
console.error("2. Authorized the GitHub integration"); console.error('2. Authorized the GitHub integration');
console.error("Then try running this script again."); console.error('Then try running this script again.');
return false; return false;
} }
async function setVercelEnvVarSDK(vercelClient, projectId, key, value) { async function setVercelEnvVarSDK(vercelClient, projectId, key, value) {
try { try {
let processedValue; let processedValue;
if (typeof value === "object") { if (typeof value === 'object') {
processedValue = JSON.stringify(value); processedValue = JSON.stringify(value);
} else { } else {
processedValue = value.toString(); processedValue = value.toString();
@ -275,7 +313,7 @@ async function setVercelEnvVarSDK(vercelClient, projectId, key, value) {
}); });
const existingVar = existingVars.envs?.find( const existingVar = existingVars.envs?.find(
(env) => env.key === key && env.target?.includes("production") (env) => env.key === key && env.target?.includes('production')
); );
if (existingVar) { if (existingVar) {
@ -285,7 +323,7 @@ async function setVercelEnvVarSDK(vercelClient, projectId, key, value) {
id: existingVar.id, id: existingVar.id,
requestBody: { requestBody: {
value: processedValue, value: processedValue,
target: ["production"], target: ['production'],
}, },
}); });
console.log(`✅ Updated environment variable: ${key}`); console.log(`✅ Updated environment variable: ${key}`);
@ -296,8 +334,8 @@ async function setVercelEnvVarSDK(vercelClient, projectId, key, value) {
requestBody: { requestBody: {
key: key, key: key,
value: processedValue, value: processedValue,
type: "encrypted", type: 'encrypted',
target: ["production"], target: ['production'],
}, },
}); });
console.log(`✅ Created environment variable: ${key}`); console.log(`✅ Created environment variable: ${key}`);
@ -319,7 +357,7 @@ async function setVercelEnvVarCLI(key, value, projectRoot) {
try { try {
execSync(`vercel env rm ${key} production -y`, { execSync(`vercel env rm ${key} production -y`, {
cwd: projectRoot, cwd: projectRoot,
stdio: "ignore", stdio: 'ignore',
env: process.env, env: process.env,
}); });
} catch (error) { } catch (error) {
@ -327,7 +365,7 @@ async function setVercelEnvVarCLI(key, value, projectRoot) {
} }
let processedValue; let processedValue;
if (typeof value === "object") { if (typeof value === 'object') {
processedValue = JSON.stringify(value); processedValue = JSON.stringify(value);
} else { } else {
processedValue = value.toString(); processedValue = value.toString();
@ -335,11 +373,11 @@ async function setVercelEnvVarCLI(key, value, projectRoot) {
// Create temporary file // Create temporary file
const tempFilePath = path.join(projectRoot, `${key}_temp.txt`); const tempFilePath = path.join(projectRoot, `${key}_temp.txt`);
fs.writeFileSync(tempFilePath, processedValue, "utf8"); fs.writeFileSync(tempFilePath, processedValue, 'utf8');
// Use appropriate command based on platform // Use appropriate command based on platform
let command; let command;
if (process.platform === "win32") { if (process.platform === 'win32') {
command = `type "${tempFilePath}" | vercel env add ${key} production`; command = `type "${tempFilePath}" | vercel env add ${key} production`;
} else { } else {
command = `cat "${tempFilePath}" | vercel env add ${key} production`; command = `cat "${tempFilePath}" | vercel env add ${key} production`;
@ -347,7 +385,7 @@ async function setVercelEnvVarCLI(key, value, projectRoot) {
execSync(command, { execSync(command, {
cwd: projectRoot, cwd: projectRoot,
stdio: "pipe", // Changed from 'inherit' to avoid interactive prompts stdio: 'pipe', // Changed from 'inherit' to avoid interactive prompts
shell: true, shell: true,
env: process.env, env: process.env,
}); });
@ -374,7 +412,7 @@ async function setEnvironmentVariables(
envVars, envVars,
projectRoot projectRoot
) { ) {
console.log("\n📝 Setting up environment variables..."); console.log('\n📝 Setting up environment variables...');
const results = []; const results = [];
@ -402,28 +440,33 @@ async function setEnvironmentVariables(
console.warn(`\n⚠️ Failed to set ${failed.length} environment variables:`); console.warn(`\n⚠️ Failed to set ${failed.length} environment variables:`);
failed.forEach((r) => console.warn(` - ${r.key}`)); failed.forEach((r) => console.warn(` - ${r.key}`));
console.warn( 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; 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...'); console.log('\n⏳ Waiting for deployment to complete...');
const startTime = Date.now(); const startTime = Date.now();
while (Date.now() - startTime < maxWaitTime) { while (Date.now() - startTime < maxWaitTime) {
try { try {
const deployments = await vercelClient.deployments.list({ const deployments = await vercelClient.deployments.list({
projectId: projectId, projectId: projectId,
limit: 1 limit: 1,
}); });
if (deployments.deployments?.[0]) { if (deployments.deployments?.[0]) {
const deployment = deployments.deployments[0]; const deployment = deployments.deployments[0];
console.log(`📊 Deployment status: ${deployment.state}`); console.log(`📊 Deployment status: ${deployment.state}`);
if (deployment.state === 'READY') { if (deployment.state === 'READY') {
console.log('✅ Deployment completed successfully!'); console.log('✅ Deployment completed successfully!');
return deployment; return deployment;
@ -432,36 +475,36 @@ async function waitForDeployment(vercelClient, projectId, maxWaitTime = 300000)
} else if (deployment.state === 'CANCELED') { } else if (deployment.state === 'CANCELED') {
throw new Error('Deployment was canceled'); throw new Error('Deployment was canceled');
} }
// Still building, wait and check again // 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 { } else {
console.log('⏳ No deployment found yet, waiting...'); console.log('⏳ No deployment found yet, waiting...');
await new Promise(resolve => setTimeout(resolve, 5000)); await new Promise((resolve) => setTimeout(resolve, 5000));
} }
} catch (error) { } catch (error) {
console.warn('⚠️ Could not check deployment status:', error.message); 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'); throw new Error('Deployment timed out after 5 minutes');
} }
async function deployToVercel(useGitHub = false) { async function deployToVercel(useGitHub = false) {
try { try {
console.log("\n🚀 Deploying to Vercel..."); console.log('\n🚀 Deploying to Vercel...');
// Ensure vercel.json exists // Ensure vercel.json exists
const vercelConfigPath = path.join(projectRoot, "vercel.json"); const vercelConfigPath = path.join(projectRoot, 'vercel.json');
if (!fs.existsSync(vercelConfigPath)) { if (!fs.existsSync(vercelConfigPath)) {
console.log("📝 Creating vercel.json configuration..."); console.log('📝 Creating vercel.json configuration...');
fs.writeFileSync( fs.writeFileSync(
vercelConfigPath, vercelConfigPath,
JSON.stringify( JSON.stringify(
{ {
buildCommand: "next build", buildCommand: 'next build',
framework: "nextjs", framework: 'nextjs',
}, },
null, null,
2 2
@ -471,15 +514,19 @@ async function deployToVercel(useGitHub = false) {
// Set up Vercel project // Set up Vercel project
console.log('\n📦 Setting 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(
console.log('\n⚠ Note: choosing a longer, more unique project name will help avoid conflicts with other existing domains\n'); '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 // Use spawn instead of execSync for better error handling
const { spawn } = await import('child_process'); const { spawn } = await import('child_process');
const vercelSetup = spawn('vercel', [], { const vercelSetup = spawn('vercel', [], {
cwd: projectRoot, cwd: projectRoot,
stdio: "inherit", stdio: 'inherit',
shell: process.platform === "win32", shell: process.platform === 'win32',
}); });
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
@ -492,7 +539,7 @@ async function deployToVercel(useGitHub = false) {
resolve(); // Don't reject, as this is often expected resolve(); // Don't reject, as this is often expected
} }
}); });
vercelSetup.on('error', (error) => { vercelSetup.on('error', (error) => {
console.log('⚠️ Vercel setup command completed (this is normal)'); console.log('⚠️ Vercel setup command completed (this is normal)');
resolve(); // Don't reject, as this is often expected 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 // 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 // Load project info
let projectId; let projectId;
try { try {
const projectJson = JSON.parse(fs.readFileSync('.vercel/project.json', 'utf8')); const projectJson = JSON.parse(
fs.readFileSync('.vercel/project.json', 'utf8')
);
projectId = projectJson.projectId; projectId = projectJson.projectId;
} catch (error) { } 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 // Get Vercel token and initialize SDK client
@ -519,16 +570,16 @@ async function deployToVercel(useGitHub = false) {
vercelClient = new Vercel({ vercelClient = new Vercel({
bearerToken: token, bearerToken: token,
}); });
console.log("✅ Initialized Vercel SDK client"); console.log('✅ Initialized Vercel SDK client');
} }
} catch (error) { } catch (error) {
console.warn( 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 // Get project details
console.log("\n🔍 Getting project details..."); console.log('\n🔍 Getting project details...');
let domain; let domain;
let projectName; let projectName;
@ -539,10 +590,10 @@ async function deployToVercel(useGitHub = false) {
}); });
projectName = project.name; projectName = project.name;
domain = `${projectName}.vercel.app`; domain = `${projectName}.vercel.app`;
console.log("🌐 Using project name for domain:", domain); console.log('🌐 Using project name for domain:', domain);
} catch (error) { } catch (error) {
console.warn( 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 // Fallback to CLI method if SDK failed
if (!domain) { if (!domain) {
try { try {
const inspectOutput = execSync(`vercel project inspect ${projectId} 2>&1`, { const inspectOutput = execSync(
cwd: projectRoot, `vercel project inspect ${projectId} 2>&1`,
encoding: 'utf8' {
}); cwd: projectRoot,
encoding: 'utf8',
}
);
const nameMatch = inspectOutput.match(/Name\s+([^\n]+)/); const nameMatch = inspectOutput.match(/Name\s+([^\n]+)/);
if (nameMatch) { if (nameMatch) {
projectName = nameMatch[1].trim(); projectName = nameMatch[1].trim();
domain = `${projectName}.vercel.app`; domain = `${projectName}.vercel.app`;
console.log("🌐 Using project name for domain:", domain); console.log('🌐 Using project name for domain:', domain);
} else { } else {
const altMatch = inspectOutput.match(/Found Project [^/]+\/([^\n]+)/); const altMatch = inspectOutput.match(/Found Project [^/]+\/([^\n]+)/);
if (altMatch) { if (altMatch) {
@ -567,7 +621,9 @@ async function deployToVercel(useGitHub = false) {
domain = `${projectName}.vercel.app`; domain = `${projectName}.vercel.app`;
console.log('🌐 Using project name for domain:', domain); console.log('🌐 Using project name for domain:', domain);
} else { } 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 // Use a fallback domain based on project ID
domain = `project-${projectId.slice(-8)}.vercel.app`; domain = `project-${projectId.slice(-8)}.vercel.app`;
console.log('🌐 Using fallback domain:', domain); console.log('🌐 Using fallback domain:', domain);
@ -582,7 +638,7 @@ async function deployToVercel(useGitHub = false) {
} }
// Generate mini app metadata // Generate mini app metadata
console.log("\n🔨 Generating mini app metadata..."); console.log('\n🔨 Generating mini app metadata...');
const webhookUrl = const webhookUrl =
process.env.NEYNAR_API_KEY && process.env.NEYNAR_CLIENT_ID process.env.NEYNAR_API_KEY && process.env.NEYNAR_CLIENT_ID
@ -590,11 +646,11 @@ async function deployToVercel(useGitHub = false) {
: `https://${domain}/api/webhook`; : `https://${domain}/api/webhook`;
const miniAppMetadata = await generateFarcasterMetadata(domain, webhookUrl); const miniAppMetadata = await generateFarcasterMetadata(domain, webhookUrl);
console.log("✅ Mini app metadata generated"); console.log('✅ Mini app metadata generated');
// Prepare environment variables // Prepare environment variables
const nextAuthSecret = const nextAuthSecret =
process.env.NEXTAUTH_SECRET || crypto.randomBytes(32).toString("hex"); process.env.NEXTAUTH_SECRET || crypto.randomBytes(32).toString('hex');
const vercelEnv = { const vercelEnv = {
NEXTAUTH_SECRET: nextAuthSecret, NEXTAUTH_SECRET: nextAuthSecret,
AUTH_SECRET: nextAuthSecret, AUTH_SECRET: nextAuthSecret,
@ -607,11 +663,14 @@ async function deployToVercel(useGitHub = false) {
...(process.env.NEYNAR_CLIENT_ID && { ...(process.env.NEYNAR_CLIENT_ID && {
NEYNAR_CLIENT_ID: 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 }), ...(miniAppMetadata && { MINI_APP_METADATA: miniAppMetadata }),
...Object.fromEntries( ...Object.fromEntries(
Object.entries(process.env).filter(([key]) => 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 // Deploy the project
if (useGitHub) { if (useGitHub) {
console.log("\nSetting up GitHub integration..."); console.log('\nSetting up GitHub integration...');
execSync("vercel link", { execSync('vercel link', {
cwd: projectRoot, cwd: projectRoot,
stdio: "inherit", stdio: 'inherit',
env: process.env, env: process.env,
}); });
console.log("\n📦 Deploying with GitHub integration..."); console.log('\n📦 Deploying with GitHub integration...');
} else { } else {
console.log("\n📦 Deploying local code directly..."); console.log('\n📦 Deploying local code directly...');
} }
// Use spawn for better control over the deployment process // Use spawn for better control over the deployment process
const vercelDeploy = spawn('vercel', ['deploy', '--prod'], { const vercelDeploy = spawn('vercel', ['deploy', '--prod'], {
cwd: projectRoot, cwd: projectRoot,
stdio: "inherit", stdio: 'inherit',
env: process.env, env: process.env,
}); });
@ -654,7 +713,7 @@ async function deployToVercel(useGitHub = false) {
reject(new Error(`Vercel deployment failed with exit code: ${code}`)); reject(new Error(`Vercel deployment failed with exit code: ${code}`));
} }
}); });
vercelDeploy.on('error', (error) => { vercelDeploy.on('error', (error) => {
console.error('❌ Vercel deployment error:', error.message); console.error('❌ Vercel deployment error:', error.message);
reject(error); reject(error);
@ -667,13 +726,16 @@ async function deployToVercel(useGitHub = false) {
try { try {
deployment = await waitForDeployment(vercelClient, projectId); deployment = await waitForDeployment(vercelClient, projectId);
} catch (error) { } 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...'); console.log(' Proceeding with domain verification...');
} }
} }
// Verify actual domain after deployment // Verify actual domain after deployment
console.log("\n🔍 Verifying deployment domain..."); console.log('\n🔍 Verifying deployment domain...');
let actualDomain = domain; let actualDomain = domain;
if (vercelClient && deployment) { if (vercelClient && deployment) {
@ -682,14 +744,14 @@ async function deployToVercel(useGitHub = false) {
console.log('🌐 Verified actual domain:', actualDomain); console.log('🌐 Verified actual domain:', actualDomain);
} catch (error) { } catch (error) {
console.warn( 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 // Update environment variables if domain changed
if (actualDomain !== domain) { if (actualDomain !== domain) {
console.log("🔄 Updating environment variables with correct domain..."); console.log('🔄 Updating environment variables with correct domain...');
const webhookUrl = const webhookUrl =
process.env.NEYNAR_API_KEY && process.env.NEYNAR_CLIENT_ID process.env.NEYNAR_API_KEY && process.env.NEYNAR_CLIENT_ID
@ -702,16 +764,27 @@ async function deployToVercel(useGitHub = false) {
}; };
if (miniAppMetadata) { 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; updatedEnv.MINI_APP_METADATA = updatedMetadata;
} }
await setEnvironmentVariables(vercelClient, projectId, updatedEnv, projectRoot); await setEnvironmentVariables(
vercelClient,
projectId,
updatedEnv,
projectRoot
);
console.log('\n📦 Redeploying with correct domain...'); console.log('\n📦 Redeploying with correct domain...');
const vercelRedeploy = spawn('vercel', ['deploy', '--prod'], { const vercelRedeploy = spawn('vercel', ['deploy', '--prod'], {
cwd: projectRoot, cwd: projectRoot,
stdio: "inherit", stdio: 'inherit',
env: process.env, env: process.env,
}); });
@ -725,49 +798,49 @@ async function deployToVercel(useGitHub = false) {
reject(new Error(`Redeployment failed with exit code: ${code}`)); reject(new Error(`Redeployment failed with exit code: ${code}`));
} }
}); });
vercelRedeploy.on('error', (error) => { vercelRedeploy.on('error', (error) => {
console.error('❌ Redeployment error:', error.message); console.error('❌ Redeployment error:', error.message);
reject(error); reject(error);
}); });
}); });
domain = actualDomain; 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(`🌐 https://${domain}`);
console.log( 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) { } catch (error) {
console.error("\n❌ Deployment failed:", error.message); console.error('\n❌ Deployment failed:', error.message);
process.exit(1); process.exit(1);
} }
} }
async function main() { async function main() {
try { try {
console.log("🚀 Vercel Mini App Deployment (SDK Edition)"); console.log('🚀 Vercel Mini App Deployment (SDK Edition)');
console.log( 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('\nThe script will:');
console.log("1. Check for required environment variables"); console.log('1. Check for required environment variables');
console.log("2. Set up a Vercel project (new or existing)"); console.log('2. Set up a Vercel project (new or existing)');
console.log("3. Configure environment variables in Vercel using SDK"); console.log('3. Configure environment variables in Vercel using SDK');
console.log("4. Deploy and build your mini app\n"); console.log('4. Deploy and build your mini app\n');
// Check if @vercel/sdk is installed // Check if @vercel/sdk is installed
try { try {
await import("@vercel/sdk"); await import('@vercel/sdk');
} catch (error) { } catch (error) {
console.log("📦 Installing @vercel/sdk..."); console.log('📦 Installing @vercel/sdk...');
execSync("npm install @vercel/sdk", { execSync('npm install @vercel/sdk', {
cwd: projectRoot, cwd: projectRoot,
stdio: "inherit", stdio: 'inherit',
}); });
console.log("✅ @vercel/sdk installed successfully"); console.log('✅ @vercel/sdk installed successfully');
} }
await checkRequiredEnvVars(); await checkRequiredEnvVars();
@ -776,55 +849,55 @@ async function main() {
let useGitHub = false; let useGitHub = false;
if (remoteUrl) { if (remoteUrl) {
console.log("\n📦 Found GitHub repository:", remoteUrl); console.log('\n📦 Found GitHub repository:', remoteUrl);
const { useGitHubDeploy } = await inquirer.prompt([ const { useGitHubDeploy } = await inquirer.prompt([
{ {
type: "confirm", type: 'confirm',
name: "useGitHubDeploy", name: 'useGitHubDeploy',
message: "Would you like to deploy from the GitHub repository?", message: 'Would you like to deploy from the GitHub repository?',
default: true, default: true,
}, },
]); ]);
useGitHub = useGitHubDeploy; useGitHub = useGitHubDeploy;
} else { } else {
console.log("\n⚠ No GitHub repository found."); console.log('\n⚠ No GitHub repository found.');
const { action } = await inquirer.prompt([ const { action } = await inquirer.prompt([
{ {
type: "list", type: 'list',
name: "action", name: 'action',
message: "What would you like to do?", message: 'What would you like to do?',
choices: [ choices: [
{ name: "Deploy local code directly", value: "deploy" }, { name: 'Deploy local code directly', value: 'deploy' },
{ name: "Set up GitHub repository first", value: "setup" }, { name: 'Set up GitHub repository first', value: 'setup' },
], ],
default: "deploy", default: 'deploy',
}, },
]); ]);
if (action === "setup") { if (action === 'setup') {
console.log("\n👋 Please set up your GitHub repository first:"); console.log('\n👋 Please set up your GitHub repository first:');
console.log("1. Create a new repository on GitHub"); console.log('1. Create a new repository on GitHub');
console.log("2. Run these commands:"); console.log('2. Run these commands:');
console.log(" git remote add origin <your-repo-url>"); console.log(' git remote add origin <your-repo-url>');
console.log(" git push -u origin main"); console.log(' git push -u origin main');
console.log("\nThen run this script again to deploy."); console.log('\nThen run this script again to deploy.');
process.exit(0); process.exit(0);
} }
} }
if (!(await checkVercelCLI())) { if (!(await checkVercelCLI())) {
console.log("Vercel CLI not found. Installing..."); console.log('Vercel CLI not found. Installing...');
await installVercelCLI(); await installVercelCLI();
} }
if (!(await loginToVercel())) { 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); process.exit(1);
} }
await deployToVercel(useGitHub); await deployToVercel(useGitHub);
} catch (error) { } catch (error) {
console.error("\n❌ Error:", error.message); console.error('\n❌ Error:', error.message);
process.exit(1); process.exit(1);
} }
} }

View File

@ -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 }
);
}
}

View File

@ -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 }
);
}
}

View File

@ -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 }
);
}
}

View File

@ -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 }
);
}
}

View File

@ -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<string, string | null> = {};
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 }
);
}
}

View File

@ -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 }
);
}
}

View File

@ -1,27 +1,38 @@
"use client"; 'use client';
import dynamic from "next/dynamic"; import dynamic from 'next/dynamic';
import type { Session } from "next-auth"; import type { Session } from 'next-auth';
import { SessionProvider } from "next-auth/react"; import { SessionProvider } from 'next-auth/react';
import { MiniAppProvider } from "@neynar/react"; import { MiniAppProvider } from '@neynar/react';
import { SafeFarcasterSolanaProvider } from "~/components/providers/SafeFarcasterSolanaProvider"; import { SafeFarcasterSolanaProvider } from '~/components/providers/SafeFarcasterSolanaProvider';
import { ANALYTICS_ENABLED } from "~/lib/constants"; import { ANALYTICS_ENABLED } from '~/lib/constants';
import { AuthKitProvider } from '@farcaster/auth-kit';
const WagmiProvider = dynamic( const WagmiProvider = dynamic(
() => import("~/components/providers/WagmiProvider"), () => import('~/components/providers/WagmiProvider'),
{ {
ssr: false, ssr: false,
} }
); );
export function Providers({ session, children }: { session: Session | null, children: React.ReactNode }) { export function Providers({
const solanaEndpoint = process.env.SOLANA_RPC_ENDPOINT || "https://solana-rpc.publicnode.com"; session,
children,
}: {
session: Session | null;
children: React.ReactNode;
}) {
const solanaEndpoint =
process.env.SOLANA_RPC_ENDPOINT || 'https://solana-rpc.publicnode.com';
return ( return (
<SessionProvider session={session}> <SessionProvider session={session}>
<WagmiProvider> <WagmiProvider>
<MiniAppProvider analyticsEnabled={ANALYTICS_ENABLED} backButtonEnabled={true}> <MiniAppProvider
analyticsEnabled={ANALYTICS_ENABLED}
backButtonEnabled={true}
>
<SafeFarcasterSolanaProvider endpoint={solanaEndpoint}> <SafeFarcasterSolanaProvider endpoint={solanaEndpoint}>
{children} <AuthKitProvider config={{}}>{children}</AuthKitProvider>
</SafeFarcasterSolanaProvider> </SafeFarcasterSolanaProvider>
</MiniAppProvider> </MiniAppProvider>
</WagmiProvider> </WagmiProvider>

View File

@ -1,11 +1,200 @@
import { AuthOptions, getServerSession } from "next-auth" import { AuthOptions, getServerSession } from 'next-auth';
import CredentialsProvider from "next-auth/providers/credentials"; import CredentialsProvider from 'next-auth/providers/credentials';
import { createAppClient, viemConnector } from "@farcaster/auth-client"; import { createAppClient, viemConnector } from '@farcaster/auth-client';
declare module "next-auth" { declare module 'next-auth' {
interface Session { interface Session {
user: { provider?: string;
user?: {
fid: number; 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<Record<string, unknown>>;
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<Record<string, unknown>>;
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<Record<string, unknown>>;
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 = { export const authOptions: AuthOptions = {
// Configure one or more authentication providers // Configure one or more authentication providers
providers: [ providers: [
CredentialsProvider({ CredentialsProvider({
name: "Sign in with Farcaster", id: 'farcaster',
name: 'Sign in with Farcaster',
credentials: { credentials: {
message: { message: {
label: "Message", label: 'Message',
type: "text", type: 'text',
placeholder: "0x0", placeholder: '0x0',
}, },
signature: { signature: {
label: "Signature", label: 'Signature',
type: "text", type: 'text',
placeholder: "0x0", placeholder: '0x0',
},
nonce: {
label: 'Nonce',
type: 'text',
placeholder: 'Custom nonce (optional)',
}, },
// In a production app with a server, these should be fetched from // In a production app with a server, these should be fetched from
// your Farcaster data indexer rather than have them accepted as part // your Farcaster data indexer rather than have them accepted as part
// of credentials. // of credentials.
// question: should these natively use the Neynar API? // question: should these natively use the Neynar API?
name: { name: {
label: "Name", label: 'Name',
type: "text", type: 'text',
placeholder: "0x0", placeholder: '0x0',
}, },
pfp: { pfp: {
label: "Pfp", label: 'Pfp',
type: "text", type: 'text',
placeholder: "0x0", placeholder: '0x0',
}, },
}, },
async authorize(credentials, req) { async authorize(credentials, req) {
const csrfToken = req?.body?.csrfToken; const nonce = req?.body?.csrfToken;
if (!csrfToken) {
console.error('CSRF token is missing from request'); if (!nonce) {
console.error('No nonce or CSRF token provided');
return null; return null;
} }
const appClient = createAppClient({ const appClient = createAppClient({
ethereum: viemConnector(), ethereum: viemConnector(),
}); });
@ -73,8 +268,9 @@ export const authOptions: AuthOptions = {
message: credentials?.message as string, message: credentials?.message as string,
signature: credentials?.signature as `0x${string}`, signature: credentials?.signature as `0x${string}`,
domain, domain,
nonce: csrfToken, nonce,
}); });
const { success, fid } = verifyResponse; const { success, fid } = verifyResponse;
if (!success) { if (!success) {
@ -83,47 +279,155 @@ export const authOptions: AuthOptions = {
return { return {
id: fid.toString(), 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: { callbacks: {
session: async ({ session, token }) => { session: async ({ session, token }) => {
if (session?.user) { // Set provider at the root level
session.user.fid = parseInt(token.sub ?? ''); 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; return session;
}, },
jwt: async ({ token, user }) => {
if (user) {
token.provider = user.provider;
token.signers = user.signers;
token.user = user.user;
}
return token;
},
}, },
cookies: { cookies: {
sessionToken: { sessionToken: {
name: `next-auth.session-token`, name: `next-auth.session-token`,
options: { options: {
httpOnly: true, httpOnly: true,
sameSite: "none", sameSite: 'none',
path: "/", path: '/',
secure: true secure: true,
} },
}, },
callbackUrl: { callbackUrl: {
name: `next-auth.callback-url`, name: `next-auth.callback-url`,
options: { options: {
sameSite: "none", sameSite: 'none',
path: "/", path: '/',
secure: true secure: true,
} },
}, },
csrfToken: { csrfToken: {
name: `next-auth.csrf-token`, name: `next-auth.csrf-token`,
options: { options: {
httpOnly: true, httpOnly: true,
sameSite: "none", sameSite: 'none',
path: "/", path: '/',
secure: true secure: true,
} },
} },
} },
} };
export const getSession = async () => { export const getSession = async () => {
try { try {
@ -132,4 +436,4 @@ export const getSession = async () => {
console.error('Error getting server session:', error); console.error('Error getting server session:', error);
return null; return null;
} }
} };

View File

@ -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: (
<div className="space-y-3">
<p className="text-gray-600 dark:text-gray-400">
Allow this app to access your Farcaster account:
</p>
<div className="space-y-2 text-sm">
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<div className="w-6 h-6 bg-green-100 dark:bg-green-900 rounded-full flex items-center justify-center">
<svg
className="w-3 h-3 text-green-600 dark:text-green-400"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
</div>
<div>
<div className="font-medium text-gray-900 dark:text-gray-100">
Read Access
</div>
<div className="text-gray-500 dark:text-gray-400">
View your profile and public information
</div>
</div>
</div>
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<div className="w-6 h-6 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center">
<svg
className="w-3 h-3 text-blue-600 dark:text-blue-400"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
</svg>
</div>
<div>
<div className="font-medium text-gray-900 dark:text-gray-100">
Write Access
</div>
<div className="text-gray-500 dark:text-gray-400">
Post casts, likes, and update your profile
</div>
</div>
</div>
</div>
</div>
),
// 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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
<div className="bg-white dark:bg-gray-800 rounded-xl w-full max-w-md shadow-2xl border border-gray-200 dark:border-gray-700 max-h-[80vh] sm:max-h-[90vh] flex flex-col">
<div className="flex justify-between items-center p-4 sm:p-6 pb-3 sm:pb-4 border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{isError ? 'Error' : content.title}
</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
>
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<div className="flex-1 overflow-y-auto p-4 sm:p-6 pt-3 sm:pt-4 min-h-0">
{isError ? (
<div className="text-center">
<div className="text-red-600 dark:text-red-400 mb-4">
{error?.message || 'Unknown error, please try again.'}
</div>
</div>
) : (
<div className="text-center">
<div className="mb-6">
{typeof content.description === 'string' ? (
<p className="text-gray-600 dark:text-gray-400">
{content.description}
</p>
) : (
content.description
)}
</div>
<div className="mb-6 flex justify-center">
{content.showQR && content.qrUrl ? (
<div className="p-4 bg-white rounded-lg">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(
content.qrUrl
)}`}
alt="QR Code"
className="w-48 h-48"
/>
</div>
) : step === 'loading' || isLoading ? (
<div className="w-48 h-48 flex items-center justify-center bg-gray-50 dark:bg-gray-700 rounded-lg">
<div className="flex flex-col items-center gap-3">
<div className="spinner w-8 h-8" />
<span className="text-sm text-gray-500 dark:text-gray-400">
{step === 'loading'
? 'Setting up access...'
: 'Loading...'}
</span>
</div>
</div>
) : null}
</div>
{content.showOpenButton && content.qrUrl && (
<button
onClick={() =>
window.open(
content.qrUrl
.replace(
'https://farcaster.xyz/',
'https://client.farcaster.xyz/deeplinks/'
)
.replace(
'https://client.farcaster.xyz/deeplinks/',
'farcaster://'
),
'_blank'
)
}
className="btn btn-outline flex items-center justify-center gap-2 w-full"
>
I&apos;m using my phone
</button>
)}
</div>
)}
</div>
</div>
</div>
);
}

View File

@ -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<HTMLDivElement>(null);
useDetectClickOutside(ref, () => setShowDropdown(false));
const name = userData?.username ?? `!${userData?.fid}`;
const pfpUrl = userData?.pfpUrl ?? 'https://farcaster.xyz/avatar.png';
return (
<div className="relative" ref={ref}>
<button
onClick={() => setShowDropdown(!showDropdown)}
className={cn(
'flex items-center gap-3 px-4 py-2 min-w-0 rounded-lg',
'bg-transparent border border-gray-300 dark:border-gray-600 text-gray-900 dark:text-gray-100',
'hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors',
'focus:outline-none focus:ring-1 focus:ring-primary'
)}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={pfpUrl}
alt="Profile"
className="w-6 h-6 rounded-full object-cover flex-shrink-0"
onError={(e) => {
(e.target as HTMLImageElement).src =
'https://farcaster.xyz/avatar.png';
}}
/>
<span className="text-sm font-medium truncate max-w-[120px]">
{name ? name : '...'}
</span>
<svg
className={cn(
'w-4 h-4 transition-transform flex-shrink-0',
showDropdown && 'rotate-180'
)}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
{showDropdown && (
<div className="absolute top-full right-0 left-0 mt-1 w-48 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 z-50">
<button
onClick={() => {
onSignOut();
setShowDropdown(false);
}}
className="w-full px-4 py-3 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 flex items-center gap-3 rounded-lg transition-colors"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
/>
</svg>
Sign out
</button>
</div>
)}
</div>
);
}

View File

@ -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<Record<string, unknown>>;
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<string | null>(null);
const [storedAuth, setStoredAuth] = useState<StoredAuthState | null>(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<string | null>(
null
);
const [pollingInterval, setPollingInterval] = useState<NodeJS.Timeout | null>(
null
);
const [message, setMessage] = useState<string | null>(null);
const [signature, setSignature] = useState<string | null>(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<User | null> => {
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<StoredAuthState>(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<StoredAuthState>(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<StoredAuthState>(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<StoredAuthState>(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<string, unknown>;
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 (
<div className="flex items-center justify-center">
<div className="flex items-center gap-3 px-4 py-2 bg-gray-100 dark:bg-gray-800 rounded-lg">
<div className="spinner w-4 h-4" />
<span className="text-sm text-gray-600 dark:text-gray-400">
Loading...
</span>
</div>
</div>
);
}
return (
<>
{authenticated ? (
<ProfileButton userData={userData} onSignOut={handleSignOut} />
) : (
<Button
onClick={useBackendFlow ? handleBackendSignIn : handleFrontEndSignIn}
disabled={!useBackendFlow && !url}
className={cn(
'btn btn-primary flex items-center gap-3',
'disabled:opacity-50 disabled:cursor-not-allowed',
'transform transition-all duration-200 active:scale-[0.98]',
!url && !useBackendFlow && 'cursor-not-allowed'
)}
>
{!useBackendFlow && !url ? (
<>
<div className="spinner-primary w-5 h-5" />
<span>Initializing...</span>
</>
) : (
<>
<span>Sign in with Neynar</span>
</>
)}
</Button>
)}
{/* Unified Auth Dialog */}
{
<AuthDialog
open={showDialog}
onClose={() => {
setShowDialog(false);
setDialogStep('signin');
setSignerApprovalUrl(null);
if (pollingInterval) {
clearInterval(pollingInterval);
setPollingInterval(null);
}
}}
url={url}
isError={isError}
error={error}
step={dialogStep}
isLoading={signersLoading}
signerApprovalUrl={signerApprovalUrl}
/>
}
</>
);
}

View File

@ -1,4 +1,4 @@
"use client"; 'use client';
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { useMiniApp } from "@neynar/react"; import { useMiniApp } from "@neynar/react";
@ -6,10 +6,11 @@ import { ShareButton } from "../Share";
import { Button } from "../Button"; import { Button } from "../Button";
import { SignIn } from "../wallet/SignIn"; import { SignIn } from "../wallet/SignIn";
import { type Haptics } from "@farcaster/miniapp-sdk"; import { type Haptics } from "@farcaster/miniapp-sdk";
import { NeynarAuthButton } from '../NeynarAuthButton/index';
/** /**
* ActionsTab component handles mini app actions like sharing, notifications, and haptic feedback. * ActionsTab component handles mini app actions like sharing, notifications, and haptic feedback.
* *
* This component provides the main interaction interface for users to: * This component provides the main interaction interface for users to:
* - Share the mini app with others * - Share the mini app with others
* - Sign in with Farcaster * - Sign in with Farcaster
@ -17,10 +18,10 @@ import { type Haptics } from "@farcaster/miniapp-sdk";
* - Trigger haptic feedback * - Trigger haptic feedback
* - Add the mini app to their client * - Add the mini app to their client
* - Copy share URLs * - Copy share URLs
* *
* The component uses the useMiniApp hook to access Farcaster context and actions. * The component uses the useMiniApp hook to access Farcaster context and actions.
* All state is managed locally within this component. * All state is managed locally within this component.
* *
* @example * @example
* ```tsx * ```tsx
* <ActionsTab /> * <ActionsTab />
@ -28,63 +29,68 @@ import { type Haptics } from "@farcaster/miniapp-sdk";
*/ */
export function ActionsTab() { export function ActionsTab() {
// --- Hooks --- // --- Hooks ---
const { const { actions, added, notificationDetails, haptics, context } =
actions, useMiniApp();
added,
notificationDetails,
haptics,
context,
} = useMiniApp();
// --- State --- // --- State ---
const [notificationState, setNotificationState] = useState({ const [notificationState, setNotificationState] = useState({
sendStatus: "", sendStatus: '',
shareUrlCopied: false, shareUrlCopied: false,
}); });
const [selectedHapticIntensity, setSelectedHapticIntensity] = useState<Haptics.ImpactOccurredType>('medium'); const [selectedHapticIntensity, setSelectedHapticIntensity] =
useState<Haptics.ImpactOccurredType>('medium');
// --- Handlers --- // --- Handlers ---
/** /**
* Sends a notification to the current user's Farcaster account. * Sends a notification to the current user's Farcaster account.
* *
* This function makes a POST request to the /api/send-notification endpoint * This function makes a POST request to the /api/send-notification endpoint
* with the user's FID and notification details. It handles different response * with the user's FID and notification details. It handles different response
* statuses including success (200), rate limiting (429), and errors. * statuses including success (200), rate limiting (429), and errors.
* *
* @returns Promise that resolves when the notification is sent or fails * @returns Promise that resolves when the notification is sent or fails
*/ */
const sendFarcasterNotification = useCallback(async () => { const sendFarcasterNotification = useCallback(async () => {
setNotificationState((prev) => ({ ...prev, sendStatus: "" })); setNotificationState((prev) => ({ ...prev, sendStatus: '' }));
if (!notificationDetails || !context) { if (!notificationDetails || !context) {
return; return;
} }
try { try {
const response = await fetch("/api/send-notification", { const response = await fetch('/api/send-notification', {
method: "POST", method: 'POST',
mode: "same-origin", mode: 'same-origin',
headers: { "Content-Type": "application/json" }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
fid: context.user.fid, fid: context.user.fid,
notificationDetails, notificationDetails,
}), }),
}); });
if (response.status === 200) { if (response.status === 200) {
setNotificationState((prev) => ({ ...prev, sendStatus: "Success" })); setNotificationState((prev) => ({ ...prev, sendStatus: 'Success' }));
return; return;
} else if (response.status === 429) { } else if (response.status === 429) {
setNotificationState((prev) => ({ ...prev, sendStatus: "Rate limited" })); setNotificationState((prev) => ({
...prev,
sendStatus: 'Rate limited',
}));
return; return;
} }
const responseText = await response.text(); const responseText = await response.text();
setNotificationState((prev) => ({ ...prev, sendStatus: `Error: ${responseText}` })); setNotificationState((prev) => ({
...prev,
sendStatus: `Error: ${responseText}`,
}));
} catch (error) { } catch (error) {
setNotificationState((prev) => ({ ...prev, sendStatus: `Error: ${error}` })); setNotificationState((prev) => ({
...prev,
sendStatus: `Error: ${error}`,
}));
} }
}, [context, notificationDetails]); }, [context, notificationDetails]);
/** /**
* Copies the share URL for the current user to the clipboard. * 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 * 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. * 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}`; const userShareUrl = `${process.env.NEXT_PUBLIC_URL}/share/${context.user.fid}`;
await navigator.clipboard.writeText(userShareUrl); await navigator.clipboard.writeText(userShareUrl);
setNotificationState((prev) => ({ ...prev, shareUrlCopied: true })); setNotificationState((prev) => ({ ...prev, shareUrlCopied: true }));
setTimeout(() => setNotificationState((prev) => ({ ...prev, shareUrlCopied: false })), 2000); setTimeout(
() =>
setNotificationState((prev) => ({ ...prev, shareUrlCopied: false })),
2000
);
} }
}, [context?.user?.fid]); }, [context?.user?.fid]);
/** /**
* Triggers haptic feedback with the selected intensity. * Triggers haptic feedback with the selected intensity.
* *
* This function calls the haptics.impactOccurred method with the current * This function calls the haptics.impactOccurred method with the current
* selectedHapticIntensity setting. It handles errors gracefully by logging them. * selectedHapticIntensity setting. It handles errors gracefully by logging them.
*/ */
@ -113,56 +123,76 @@ export function ActionsTab() {
// --- Render --- // --- Render ---
return ( return (
<div className="space-y-3 px-6 w-full max-w-md mx-auto"> <div className='space-y-3 px-6 w-full max-w-md mx-auto'>
{/* Share functionality */} {/* Share functionality */}
<ShareButton <ShareButton
buttonText="Share Mini App" buttonText='Share Mini App'
cast={{ cast={{
text: "Check out this awesome frame @1 @2 @3! 🚀🪐", text: 'Check out this awesome frame @1 @2 @3! 🚀🪐',
bestFriends: true, bestFriends: true,
embeds: [`${process.env.NEXT_PUBLIC_URL}/share/${context?.user?.fid || ''}`] embeds: [
`${process.env.NEXT_PUBLIC_URL}/share/${context?.user?.fid || ''}`,
],
}} }}
className="w-full" className='w-full'
/> />
{/* Authentication */} {/* Authentication */}
<SignIn /> <SignIn />
{/* Mini app actions */} {/* Neynar Authentication */}
<Button onClick={() => actions.openUrl("https://www.youtube.com/watch?v=dQw4w9WgXcQ")} className="w-full">Open Link</Button> <NeynarAuthButton />
<Button onClick={actions.addMiniApp} disabled={added} className="w-full"> {/* Mini app actions */}
<Button
onClick={() =>
actions.openUrl('https://www.youtube.com/watch?v=dQw4w9WgXcQ')
}
className='w-full'
>
Open Link
</Button>
<Button onClick={actions.addMiniApp} disabled={added} className='w-full'>
Add Mini App to Client Add Mini App to Client
</Button> </Button>
{/* Notification functionality */} {/* Notification functionality */}
{notificationState.sendStatus && ( {notificationState.sendStatus && (
<div className="text-sm w-full"> <div className='text-sm w-full'>
Send notification result: {notificationState.sendStatus} Send notification result: {notificationState.sendStatus}
</div> </div>
)} )}
<Button onClick={sendFarcasterNotification} disabled={!notificationDetails} className="w-full"> <Button
onClick={sendFarcasterNotification}
disabled={!notificationDetails}
className='w-full'
>
Send notification Send notification
</Button> </Button>
{/* Share URL copying */} {/* Share URL copying */}
<Button <Button
onClick={copyUserShareUrl} onClick={copyUserShareUrl}
disabled={!context?.user?.fid} disabled={!context?.user?.fid}
className="w-full" className='w-full'
> >
{notificationState.shareUrlCopied ? "Copied!" : "Copy share URL"} {notificationState.shareUrlCopied ? 'Copied!' : 'Copy share URL'}
</Button> </Button>
{/* Haptic feedback controls */} {/* Haptic feedback controls */}
<div className="space-y-2"> <div className='space-y-2'>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300"> <label className='block text-sm font-medium text-gray-700 dark:text-gray-300'>
Haptic Intensity Haptic Intensity
</label> </label>
<select <select
value={selectedHapticIntensity} value={selectedHapticIntensity}
onChange={(e) => setSelectedHapticIntensity(e.target.value as Haptics.ImpactOccurredType)} onChange={(e) =>
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-primary" setSelectedHapticIntensity(
e.target.value as Haptics.ImpactOccurredType
)
}
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-primary'
> >
<option value={'light'}>Light</option> <option value={'light'}>Light</option>
<option value={'medium'}>Medium</option> <option value={'medium'}>Medium</option>
@ -170,13 +200,10 @@ export function ActionsTab() {
<option value={'soft'}>Soft</option> <option value={'soft'}>Soft</option>
<option value={'rigid'}>Rigid</option> <option value={'rigid'}>Rigid</option>
</select> </select>
<Button <Button onClick={triggerHapticFeedback} className='w-full'>
onClick={triggerHapticFeedback}
className="w-full"
>
Trigger Haptic Feedback Trigger Haptic Feedback
</Button> </Button>
</div> </div>
</div> </div>
); );
} }

View File

@ -1,4 +1,4 @@
"use client"; 'use client';
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { signIn, signOut, getCsrfToken } from "next-auth/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). * SignIn component handles Farcaster authentication using Sign-In with Farcaster (SIWF).
* *
* This component provides a complete authentication flow for Farcaster users: * This component provides a complete authentication flow for Farcaster users:
* - Generates nonces for secure authentication * - Generates nonces for secure authentication
* - Handles the SIWF flow using the Farcaster SDK * - Handles the SIWF flow using the Farcaster SDK
* - Manages NextAuth session state * - Manages NextAuth session state
* - Provides sign-out functionality * - Provides sign-out functionality
* - Displays authentication status and results * - Displays authentication status and results
* *
* The component integrates with both the Farcaster Frame SDK and NextAuth * The component integrates with both the Farcaster Frame SDK and NextAuth
* to provide seamless authentication within mini apps. * to provide seamless authentication within mini apps.
* *
* @example * @example
* ```tsx * ```tsx
* <SignIn /> * <SignIn />
@ -45,29 +45,29 @@ export function SignIn() {
// --- Handlers --- // --- Handlers ---
/** /**
* Generates a nonce for the sign-in process. * Generates a nonce for the sign-in process.
* *
* This function retrieves a CSRF token from NextAuth to use as a nonce * This function retrieves a CSRF token from NextAuth to use as a nonce
* for the SIWF authentication flow. The nonce ensures the authentication * for the SIWF authentication flow. The nonce ensures the authentication
* request is fresh and prevents replay attacks. * request is fresh and prevents replay attacks.
* *
* @returns Promise<string> - The generated nonce token * @returns Promise<string> - The generated nonce token
* @throws Error if unable to generate nonce * @throws Error if unable to generate nonce
*/ */
const getNonce = useCallback(async () => { const getNonce = useCallback(async () => {
const nonce = await getCsrfToken(); const nonce = await getCsrfToken();
if (!nonce) throw new Error("Unable to generate nonce"); if (!nonce) throw new Error('Unable to generate nonce');
return nonce; return nonce;
}, []); }, []);
/** /**
* Handles the sign-in process using Farcaster SDK. * Handles the sign-in process using Farcaster SDK.
* *
* This function orchestrates the complete SIWF flow: * This function orchestrates the complete SIWF flow:
* 1. Generates a nonce for security * 1. Generates a nonce for security
* 2. Calls the Farcaster SDK to initiate sign-in * 2. Calls the Farcaster SDK to initiate sign-in
* 3. Submits the result to NextAuth for session management * 3. Submits the result to NextAuth for session management
* 4. Handles various error conditions including user rejection * 4. Handles various error conditions including user rejection
* *
* @returns Promise<void> * @returns Promise<void>
*/ */
const handleSignIn = useCallback(async () => { const handleSignIn = useCallback(async () => {
@ -77,17 +77,17 @@ export function SignIn() {
const nonce = await getNonce(); const nonce = await getNonce();
const result = await sdk.actions.signIn({ nonce }); const result = await sdk.actions.signIn({ nonce });
setSignInResult(result); setSignInResult(result);
await signIn("credentials", { await signIn('farcaster', {
message: result.message, message: result.message,
signature: result.signature, signature: result.signature,
redirect: false, redirect: false,
}); });
} catch (e) { } catch (e) {
if (e instanceof SignInCore.RejectedByUser) { if (e instanceof SignInCore.RejectedByUser) {
setSignInFailure("Rejected by user"); setSignInFailure('Rejected by user');
return; return;
} }
setSignInFailure("Unknown error"); setSignInFailure('Unknown error');
} finally { } finally {
setAuthState((prev) => ({ ...prev, signingIn: false })); setAuthState((prev) => ({ ...prev, signingIn: false }));
} }
@ -95,32 +95,35 @@ export function SignIn() {
/** /**
* Handles the sign-out process. * Handles the sign-out process.
* *
* This function clears the NextAuth session and resets the local * This function clears the NextAuth session only if the current session
* sign-in result state to complete the sign-out flow. * is using the Farcaster provider, and resets the local sign-in result state.
* *
* @returns Promise<void> * @returns Promise<void>
*/ */
const handleSignOut = useCallback(async () => { const handleSignOut = useCallback(async () => {
try { try {
setAuthState((prev) => ({ ...prev, signingOut: true })); 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); setSignInResult(undefined);
} finally { } finally {
setAuthState((prev) => ({ ...prev, signingOut: false })); setAuthState((prev) => ({ ...prev, signingOut: false }));
} }
}, []); }, [session]);
// --- Render --- // --- Render ---
return ( return (
<> <>
{/* Authentication Buttons */} {/* Authentication Buttons */}
{status !== "authenticated" && ( {(status !== 'authenticated' || session?.provider !== 'farcaster') && (
<Button onClick={handleSignIn} disabled={authState.signingIn}> <Button onClick={handleSignIn} disabled={authState.signingIn}>
Sign In with Farcaster Sign In with Farcaster
</Button> </Button>
)} )}
{status === "authenticated" && ( {status === 'authenticated' && session?.provider === 'farcaster' && (
<Button onClick={handleSignOut} disabled={authState.signingOut}> <Button onClick={handleSignOut} disabled={authState.signingOut}>
Sign out Sign out
</Button> </Button>
@ -155,4 +158,4 @@ export function SignIn() {
)} )}
</> </>
); );
} }

View File

@ -0,0 +1,18 @@
import { useEffect } from 'react';
export function useDetectClickOutside<T extends HTMLElement>(
ref: React.RefObject<T | null>,
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]);
}

View File

@ -1,10 +1,10 @@
/** /**
* Application constants and configuration values. * Application constants and configuration values.
* *
* This file contains all the configuration constants used throughout the mini app. * This file contains all the configuration constants used throughout the mini app.
* These values are either sourced from environment variables or hardcoded and provide * These values are either sourced from environment variables or hardcoded and provide
* configuration for the app's appearance, behavior, and integration settings. * configuration for the app's appearance, behavior, and integration settings.
* *
* NOTE: This file is automatically updated by the init script. * NOTE: This file is automatically updated by the init script.
* Manual changes may be overwritten during project initialization. * 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. * The name of the mini app as displayed to users.
* Used in titles, headers, and app store listings. * 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. * A brief description of the mini app's functionality.
* Used in app store listings and metadata. * 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. * The primary category for the mini app.
* Used for app store categorization and discovery. * 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. * 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. * Background color for the splash screen.
* Used as fallback when splash image is loading. * Used as fallback when splash image is loading.
*/ */
export const APP_SPLASH_BACKGROUND_COLOR = "#f7f7f7"; export const APP_SPLASH_BACKGROUND_COLOR = '#f7f7f7';
// --- UI Configuration --- // --- UI Configuration ---
/** /**
* Text displayed on the main action button. * Text displayed on the main action button.
* Used for the primary call-to-action in the mini app. * 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 --- // --- Integration Configuration ---
/** /**
* Webhook URL for receiving events from Neynar. * Webhook URL for receiving events from Neynar.
* *
* If Neynar API key and client ID are configured, uses the official * If Neynar API key and client ID are configured, uses the official
* Neynar webhook endpoint. Otherwise, falls back to a local webhook * Neynar webhook endpoint. Otherwise, falls back to a local webhook
* endpoint for development and testing. * 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` ? `https://api.neynar.com/f/app/${process.env.NEYNAR_CLIENT_ID}/event`
: `${APP_URL}/api/webhook`; : `${APP_URL}/api/webhook`;
/** /**
* Flag to enable/disable wallet functionality. * Flag to enable/disable wallet functionality.
* *
* When true, wallet-related components and features are rendered. * When true, wallet-related components and features are rendered.
* When false, wallet functionality is completely hidden from the UI. * When false, wallet functionality is completely hidden from the UI.
* Useful for mini apps that don't require wallet integration. * 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. * Flag to enable/disable analytics tracking.
* *
* When true, usage analytics are collected and sent to Neynar. * When true, usage analytics are collected and sent to Neynar.
* When false, analytics collection is disabled. * When false, analytics collection is disabled.
* Useful for privacy-conscious users or development environments. * Useful for privacy-conscious users or development environments.
*/ */
export const ANALYTICS_ENABLED = true; 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' },
];

27
src/lib/devices.ts Normal file
View File

@ -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();
}

25
src/lib/localStorage.ts Normal file
View File

@ -0,0 +1,25 @@
export function setItem<T>(key: string, value: T) {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch (error) {
console.warn('Failed to save item:', error);
}
}
export function getItem<T>(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);
}
}