diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..ddc32ca --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,30 @@ +name: Publish to npm šŸš€ + +on: + push: + branches: + - main + paths: + - package.json + +jobs: + publish: + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + registry-url: 'https://registry.npmjs.org' + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Install dependencies + run: npm ci + + - name: Publish to npm + run: npm publish --access public \ No newline at end of file diff --git a/bin/index.js b/bin/index.js index 4fb6e3d..65ec3e0 100755 --- a/bin/index.js +++ b/bin/index.js @@ -6,6 +6,7 @@ import { init } from './init.js'; const args = process.argv.slice(2); let projectName = null; let autoAcceptDefaults = false; +let apiKey = null; // Check for -y flag const yIndex = args.indexOf('-y'); @@ -14,18 +15,48 @@ if (yIndex !== -1) { args.splice(yIndex, 1); // Remove -y from args } -// If there's a remaining argument, it's the project name -if (args.length > 0) { - projectName = args[0]; -} + // Parse other arguments + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + + if (arg === '-p' || arg === '--project') { + if (i + 1 < args.length) { + projectName = args[i + 1]; + if (projectName.startsWith('-')) { + console.error('Error: Project name cannot start with a dash (-)'); + process.exit(1); + } + args.splice(i, 2); // Remove both the flag and its value + i--; // Adjust index since we removed 2 elements + } else { + console.error('Error: -p/--project requires a project name'); + process.exit(1); + } + } else if (arg === '-k' || arg === '--api-key') { + if (i + 1 < args.length) { + apiKey = args[i + 1]; + if (apiKey.startsWith('-')) { + console.error('Error: API key cannot start with a dash (-)'); + process.exit(1); + } + args.splice(i, 2); // Remove both the flag and its value + i--; // Adjust index since we removed 2 elements + } else { + console.error('Error: -k/--api-key requires an API key'); + process.exit(1); + } + } + } -// If -y is used without project name, we still need to ask for project name + + +// Validate that if -y is used, a project name must be provided if (autoAcceptDefaults && !projectName) { - // We'll handle this case in the init function by asking only for project name - autoAcceptDefaults = false; + console.error('Error: -y flag requires a project name. Use -p/--project to specify the project name.'); + process.exit(1); } -init(projectName, autoAcceptDefaults).catch(err => { +init(projectName, autoAcceptDefaults, apiKey).catch((err) => { console.error('Error:', err); process.exit(1); -}); +}); \ No newline at end of file diff --git a/bin/init.js b/bin/init.js index 5c814a7..e1ddaa2 100644 --- a/bin/init.js +++ b/bin/init.js @@ -1,18 +1,19 @@ #!/usr/bin/env node -import { execSync } from 'child_process'; -import crypto from 'crypto'; -import fs from 'fs'; -import path, { dirname } from 'path'; -import { fileURLToPath } from 'url'; import inquirer from 'inquirer'; +import { fileURLToPath } from 'url'; +import { dirname } from 'path'; +import { execSync } from 'child_process'; +import fs from 'fs'; +import path from 'path'; +import crypto from 'crypto'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const REPO_URL = 'https://github.com/neynarxyz/create-farcaster-mini-app.git'; const SCRIPT_VERSION = JSON.parse( - fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'), + fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8') ).version; // ANSI color codes @@ -46,12 +47,12 @@ async function queryNeynarApp(apiKey) { } try { const response = await fetch( - '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: { 'x-api-key': apiKey, }, - }, + } ); const data = await response.json(); return data; @@ -62,7 +63,7 @@ async function queryNeynarApp(apiKey) { } // Export the main CLI function for programmatic use -export async function init(projectName = null, autoAcceptDefaults = false) { +export async function init(projectName = null, autoAcceptDefaults = false, apiKey = null) { printWelcomeMessage(); // Ask about Neynar usage @@ -100,52 +101,59 @@ export async function init(projectName = null, autoAcceptDefaults = false) { break; } - console.log( - '\n🪐 Find your Neynar API key at: https://dev.neynar.com/app\n', - ); - - let neynarKeyAnswer; - if (autoAcceptDefaults) { - neynarKeyAnswer = { neynarApiKey: null }; + // Use provided API key if available, otherwise prompt for it + if (apiKey) { + neynarApiKey = apiKey; } else { - neynarKeyAnswer = await inquirer.prompt([ - { - type: 'password', - name: 'neynarApiKey', - message: 'Enter your Neynar API key (or press enter to skip):', - default: null, - }, - ]); - } + if (!autoAcceptDefaults) { + console.log( + '\n🪐 Find your Neynar API key at: https://dev.neynar.com/app\n' + ); + } - if (neynarKeyAnswer.neynarApiKey) { - neynarApiKey = neynarKeyAnswer.neynarApiKey; - } else { - let useDemoKey; + let neynarKeyAnswer; if (autoAcceptDefaults) { - useDemoKey = { useDemo: true }; + neynarKeyAnswer = { neynarApiKey: null }; } else { - useDemoKey = await inquirer.prompt([ + neynarKeyAnswer = await inquirer.prompt([ { - type: 'confirm', - name: 'useDemo', - message: 'Would you like to try the demo Neynar API key?', - default: true, + type: 'password', + name: 'neynarApiKey', + message: 'Enter your Neynar API key (or press enter to skip):', + default: null, }, ]); } - if (useDemoKey.useDemo) { - console.warn( - '\nāš ļø Note: the demo key is for development purposes only and is aggressively rate limited.', - ); - console.log( - 'For production, please sign up for a Neynar account at https://neynar.com/ and configure the API key in your .env or .env.local file with NEYNAR_API_KEY.', - ); - console.log( - `\n${purple}${bright}${italic}Neynar now has a free tier! See https://neynar.com/#pricing for details.\n${reset}`, - ); - neynarApiKey = 'FARCASTER_V2_FRAMES_DEMO'; + if (neynarKeyAnswer.neynarApiKey) { + neynarApiKey = neynarKeyAnswer.neynarApiKey; + } else { + let useDemoKey; + if (autoAcceptDefaults) { + useDemoKey = { useDemo: true }; + } else { + useDemoKey = await inquirer.prompt([ + { + type: 'confirm', + name: 'useDemo', + message: 'Would you like to try the demo Neynar API key?', + default: true, + }, + ]); + } + + if (useDemoKey.useDemo) { + console.warn( + '\nāš ļø Note: the demo key is for development purposes only and is aggressively rate limited.' + ); + console.log( + 'For production, please sign up for a Neynar account at https://neynar.com/ and configure the API key in your .env or .env.local file with NEYNAR_API_KEY.' + ); + console.log( + `\n${purple}${bright}${italic}Neynar now has a free tier! See https://neynar.com/#pricing for details.\n${reset}` + ); + neynarApiKey = 'FARCASTER_V2_FRAMES_DEMO'; + } } } @@ -155,7 +163,7 @@ export async function init(projectName = null, autoAcceptDefaults = false) { break; } console.log( - '\nāš ļø No valid API key provided. Would you like to try again?', + '\nāš ļø No valid API key provided. Would you like to try again?' ); const { retry } = await inquirer.prompt([ { @@ -220,6 +228,8 @@ export async function init(projectName = null, autoAcceptDefaults = false) { useWallet: true, useTunnel: true, enableAnalytics: true, + seedPhrase: null, + sponsorSigner: false, }; } else { // If autoAcceptDefaults is false but we have a projectName, we still need to ask for other options @@ -229,7 +239,7 @@ export async function init(projectName = null, autoAcceptDefaults = false) { name: 'projectName', message: 'What is the name of your mini app?', default: projectName || defaultMiniAppName, - validate: input => { + validate: (input) => { if (input.trim() === '') { return 'Project name cannot be empty'; } @@ -276,13 +286,13 @@ export async function init(projectName = null, autoAcceptDefaults = false) { message: 'Enter tags for your mini app (separate with spaces or commas, optional):', default: '', - filter: input => { + filter: (input) => { if (!input.trim()) return []; // Split by both spaces and commas, trim whitespace, and filter out empty strings return input .split(/[,\s]+/) - .map(tag => tag.trim()) - .filter(tag => tag.length > 0); + .map((tag) => tag.trim()) + .filter((tag) => tag.length > 0); }, }, { @@ -290,7 +300,7 @@ export async function init(projectName = null, autoAcceptDefaults = false) { name: 'buttonText', message: 'Enter the button text for your mini app:', default: 'Launch Mini App', - validate: input => { + validate: (input) => { if (input.trim() === '') { return 'Button text cannot be empty'; } @@ -335,6 +345,43 @@ export async function init(projectName = null, autoAcceptDefaults = false) { ]); answers.useTunnel = hostingAnswer.useTunnel; + // Ask about Neynar Sponsored Signers / SIWN + const sponsoredSignerAnswer = await inquirer.prompt([ + { + type: 'confirm', + name: 'useSponsoredSigner', + message: + 'Would you like to use Neynar Sponsored Signers and/or Sign In With Neynar (SIWN)?\n' + + 'This enables the simplest, most secure, and most user-friendly Farcaster authentication for your app.\n\n' + + 'Benefits of using Neynar Sponsored Signers/SIWN:\n' + + '- No auth buildout or signer management required for developers\n' + + '- Cost-effective for users (no gas for signers)\n' + + '- Users can revoke signers at any time\n' + + '- Plug-and-play for web and React Native\n' + + '- Recommended for most developers\n' + + '\nāš ļø A seed phrase is required for this option.\n', + default: false, + }, + ]); + answers.useSponsoredSigner = sponsoredSignerAnswer.useSponsoredSigner; + + if (answers.useSponsoredSigner) { + const { seedPhrase } = await inquirer.prompt([ + { + type: 'password', + name: 'seedPhrase', + message: 'Enter your Farcaster custody account seed phrase (required for Neynar Sponsored Signers/SIWN):', + validate: (input) => { + if (!input || input.trim().split(' ').length < 12) { + return 'Seed phrase must be at least 12 words'; + } + return true; + }, + }, + ]); + answers.seedPhrase = seedPhrase; + } + // Ask about analytics opt-out const analyticsAnswer = await inquirer.prompt([ { @@ -392,7 +439,7 @@ export async function init(projectName = null, autoAcceptDefaults = false) { // Update package.json console.log('\nUpdating package.json...'); const packageJsonPath = path.join(projectPath, 'package.json'); - const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + let packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); packageJson.name = finalProjectName; packageJson.version = '0.1.0'; @@ -409,10 +456,9 @@ export async function init(projectName = null, autoAcceptDefaults = false) { packageJson.dependencies = { '@farcaster/auth-client': '>=0.3.0 <1.0.0', '@farcaster/auth-kit': '>=0.6.0 <1.0.0', - '@farcaster/frame-core': '>=0.0.29 <1.0.0', - '@farcaster/frame-node': '>=0.0.18 <1.0.0', - '@farcaster/frame-sdk': '>=0.0.31 <1.0.0', - '@farcaster/frame-wagmi-connector': '>=0.0.19 <1.0.0', + '@farcaster/miniapp-node': '>=0.1.5 <1.0.0', + '@farcaster/miniapp-sdk': '>=0.1.6 <1.0.0', + '@farcaster/miniapp-wagmi-connector': '^1.0.0', '@farcaster/mini-app-solana': '>=0.0.17 <1.0.0', '@neynar/react': '^1.2.5', '@radix-ui/react-label': '^2.1.1', @@ -433,24 +479,20 @@ export async function init(projectName = null, autoAcceptDefaults = false) { viem: '^2.23.6', wagmi: '^2.14.12', zod: '^3.24.2', + siwe: '^3.0.0', }; packageJson.devDependencies = { '@types/node': '^20', '@types/react': '^19', '@types/react-dom': '^19', - '@typescript-eslint/eslint-plugin': '^8.0.0', - '@typescript-eslint/parser': '^8.0.0', '@vercel/sdk': '^1.9.0', crypto: '^1.0.1', - eslint: '^8.57.0', + eslint: '^8', 'eslint-config-next': '15.0.3', - 'eslint-config-prettier': '^9.1.0', - 'eslint-plugin-prettier': '^5.2.1', localtunnel: '^2.0.2', 'pino-pretty': '^13.0.0', postcss: '^8', - prettier: '^3.3.3', tailwindcss: '^3.4.1', typescript: '^5', }; @@ -460,15 +502,6 @@ export async function init(projectName = null, autoAcceptDefaults = false) { packageJson.dependencies['@neynar/nodejs-sdk'] = '^2.19.0'; } - // Update scripts with formatting and linting - packageJson.scripts = { - ...packageJson.scripts, - 'lint:fix': 'next lint --fix', - format: 'prettier --write .', - 'format:check': 'prettier --check .', - 'type-check': 'tsc --noEmit', - }; - fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2)); // Handle .env file @@ -489,18 +522,21 @@ export async function init(projectName = null, autoAcceptDefaults = false) { let constantsContent = fs.readFileSync(constantsPath, 'utf8'); // 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 const safeReplace = (content, pattern, replacement, constantName) => { const match = content.match(pattern); if (!match) { console.log( - `āš ļø Warning: Could not update ${constantName} in constants.ts. Pattern not found.`, + `āš ļø Warning: Could not update ${constantName} in constants.ts. Pattern not found.` ); console.log(`Pattern: ${pattern}`); console.log( - `Expected to match in: ${content.split('\n').find(line => line.includes(constantName)) || 'Not found'}`, + `Expected to match in: ${ + content.split('\n').find((line) => line.includes(constantName)) || + 'Not found' + }` ); } else { const newContent = content.replace(pattern, replacement); @@ -529,43 +565,49 @@ export async function init(projectName = null, autoAcceptDefaults = false) { constantsContent, patterns.APP_NAME, `export const APP_NAME = '${escapeString(answers.projectName)}';`, - 'APP_NAME', + 'APP_NAME' ); // Update APP_DESCRIPTION constantsContent = safeReplace( constantsContent, patterns.APP_DESCRIPTION, - `export const APP_DESCRIPTION = '${escapeString(answers.description)}';`, - 'APP_DESCRIPTION', + `export const APP_DESCRIPTION = '${escapeString( + answers.description + )}';`, + 'APP_DESCRIPTION' ); // Update APP_PRIMARY_CATEGORY (always update, null becomes empty string) constantsContent = safeReplace( constantsContent, patterns.APP_PRIMARY_CATEGORY, - `export const APP_PRIMARY_CATEGORY = '${escapeString(answers.primaryCategory || '')}';`, - 'APP_PRIMARY_CATEGORY', + `export const APP_PRIMARY_CATEGORY = '${escapeString( + answers.primaryCategory || '' + )}';`, + 'APP_PRIMARY_CATEGORY' ); // Update APP_TAGS const tagsString = answers.tags.length > 0 - ? `['${answers.tags.map(tag => escapeString(tag)).join("', '")}']` + ? `['${answers.tags.map((tag) => escapeString(tag)).join("', '")}']` : "['neynar', 'starter-kit', 'demo']"; constantsContent = safeReplace( constantsContent, patterns.APP_TAGS, `export const APP_TAGS = ${tagsString};`, - 'APP_TAGS', + 'APP_TAGS' ); // Update APP_BUTTON_TEXT (always update, use answers value) constantsContent = safeReplace( constantsContent, patterns.APP_BUTTON_TEXT, - `export const APP_BUTTON_TEXT = '${escapeString(answers.buttonText || '')}';`, - 'APP_BUTTON_TEXT', + `export const APP_BUTTON_TEXT = '${escapeString( + answers.buttonText || '' + )}';`, + 'APP_BUTTON_TEXT' ); // Update USE_WALLET @@ -573,7 +615,7 @@ export async function init(projectName = null, autoAcceptDefaults = false) { constantsContent, patterns.USE_WALLET, `export const USE_WALLET = ${answers.useWallet};`, - 'USE_WALLET', + 'USE_WALLET' ); // Update ANALYTICS_ENABLED @@ -581,7 +623,7 @@ export async function init(projectName = null, autoAcceptDefaults = false) { constantsContent, patterns.ANALYTICS_ENABLED, `export const ANALYTICS_ENABLED = ${answers.enableAnalytics};`, - 'ANALYTICS_ENABLED', + 'ANALYTICS_ENABLED' ); fs.writeFileSync(constantsPath, constantsContent); @@ -591,22 +633,25 @@ export async function init(projectName = null, autoAcceptDefaults = false) { fs.appendFileSync( envPath, - `\nNEXTAUTH_SECRET="${crypto.randomBytes(32).toString('hex')}"`, + `\nNEXTAUTH_SECRET="${crypto.randomBytes(32).toString('hex')}"` ); if (useNeynar && neynarApiKey && neynarClientId) { fs.appendFileSync(envPath, `\nNEYNAR_API_KEY="${neynarApiKey}"`); fs.appendFileSync(envPath, `\nNEYNAR_CLIENT_ID="${neynarClientId}"`); } else if (useNeynar) { console.log( - '\nāš ļø Could not find a Neynar client ID and/or API key. Please configure Neynar manually in .env.local with NEYNAR_API_KEY and NEYNAR_CLIENT_ID', + '\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, `\nUSE_TUNNEL="${answers.useTunnel}"`); fs.unlinkSync(envExamplePath); } else { console.log( - '\n.env.example does not exist, skipping copy and remove operations', + '\n.env.example does not exist, skipping copy and remove operations' ); } @@ -651,7 +696,7 @@ export async function init(projectName = null, autoAcceptDefaults = false) { execSync('git add .', { cwd: projectPath }); execSync( 'git commit -m "initial commit from @neynar/create-farcaster-mini-app"', - { cwd: projectPath }, + { cwd: projectPath } ); // Calculate border length based on message length @@ -665,4 +710,4 @@ export async function init(projectName = null, autoAcceptDefaults = false) { console.log('\nTo run the app:'); console.log(` cd ${finalProjectName}`); console.log(' npm run dev\n'); -} +} \ No newline at end of file diff --git a/package.json b/package.json index e42aa4a..1a86ba8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@neynar/create-farcaster-mini-app", - "version": "1.5.3", + "version": "1.5.9", "type": "module", "private": false, "access": "public", diff --git a/scripts/build.js b/scripts/build.js index db52512..136a520 100755 --- a/scripts/build.js +++ b/scripts/build.js @@ -1,121 +1,77 @@ -import { execSync } from 'child_process'; -import crypto from 'crypto'; -import fs from 'fs'; -import path from 'path'; -import { fileURLToPath } from 'url'; -import dotenv from 'dotenv'; -import inquirer from 'inquirer'; -import { mnemonicToAccount } from 'viem/accounts'; - -// ANSI color codes -const yellow = '\x1b[33m'; -const italic = '\x1b[3m'; -const reset = '\x1b[0m'; +import { execSync } from "child_process"; +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; +import inquirer from "inquirer"; +import dotenv from "dotenv"; +import crypto from "crypto"; // Load environment variables in specific order // First load .env for main config -dotenv.config({ path: '.env' }); - -async function lookupFidByCustodyAddress(custodyAddress, apiKey) { - if (!apiKey) { - throw new Error('Neynar API key is required'); - } - const lowerCasedCustodyAddress = custodyAddress.toLowerCase(); - - const response = await fetch( - `https://api.neynar.com/v2/farcaster/user/bulk-by-address?addresses=${lowerCasedCustodyAddress}&address_types=custody_address`, - { - headers: { - accept: 'application/json', - 'x-api-key': 'FARCASTER_V2_FRAMES_DEMO', - }, - }, - ); - - if (!response.ok) { - throw new Error(`Failed to lookup FID: ${response.statusText}`); - } - - const data = await response.json(); - if ( - !data[lowerCasedCustodyAddress]?.length || - !data[lowerCasedCustodyAddress][0].custody_address - ) { - throw new Error('No FID found for this custody address'); - } - - return data[lowerCasedCustodyAddress][0].fid; -} +dotenv.config({ path: ".env" }); async function loadEnvLocal() { try { - if (fs.existsSync('.env.local')) { + if (fs.existsSync(".env.local")) { const { loadLocal } = await inquirer.prompt([ { - type: 'confirm', - name: 'loadLocal', + type: "confirm", + name: "loadLocal", message: - 'Found .env.local, likely created by the install script - would you like to load its values?', + "Found .env.local, likely created by the install script - would you like to load its values?", default: false, }, ]); if (loadLocal) { - console.log('Loading values from .env.local...'); - const localEnv = dotenv.parse(fs.readFileSync('.env.local')); + console.log("Loading values from .env.local..."); + const localEnv = dotenv.parse(fs.readFileSync(".env.local")); - // Copy all values except SEED_PHRASE to .env - const envContent = fs.existsSync('.env') - ? fs.readFileSync('.env', 'utf8') + '\n' - : ''; + // Copy all values to .env + const envContent = fs.existsSync(".env") + ? fs.readFileSync(".env", "utf8") + "\n" + : ""; let newEnvContent = envContent; for (const [key, value] of Object.entries(localEnv)) { - if (key !== 'SEED_PHRASE') { - // Update process.env - process.env[key] = value; - // Add to .env content if not already there - if (!envContent.includes(`${key}=`)) { - newEnvContent += `${key}="${value}"\n`; - } + // Update process.env + process.env[key] = value; + // Add to .env content if not already there + if (!envContent.includes(`${key}=`)) { + newEnvContent += `${key}="${value}"\n`; } } // Write updated content to .env - fs.writeFileSync('.env', newEnvContent); - console.log('āœ… Values from .env.local have been written to .env'); + fs.writeFileSync(".env", newEnvContent); + console.log("āœ… Values from .env.local have been written to .env"); } - } - - // Always try to load SEED_PHRASE from .env.local - if (fs.existsSync('.env.local')) { - const localEnv = dotenv.parse(fs.readFileSync('.env.local')); - if (localEnv.SEED_PHRASE) { - process.env.SEED_PHRASE = localEnv.SEED_PHRASE; + if (localEnv.SPONSOR_SIGNER) { + process.env.SPONSOR_SIGNER = localEnv.SPONSOR_SIGNER; } } } catch (error) { // Error reading .env.local, which is fine - console.log('Note: No .env.local file found'); + console.log("Note: No .env.local file found"); } } // TODO: make sure rebuilding is supported const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const projectRoot = path.join(__dirname, '..'); +const projectRoot = path.join(__dirname, ".."); async function validateDomain(domain) { // Remove http:// or https:// if present - const cleanDomain = domain.replace(/^https?:\/\//, ''); + const cleanDomain = domain.replace(/^https?:\/\//, ""); // Basic domain validation if ( !cleanDomain.match( - /^[a-zA-Z0-9][a-zA-Z0-9-]{1,61}[a-zA-Z0-9](?:\.[a-zA-Z]{2,})+$/, + /^[a-zA-Z0-9][a-zA-Z0-9-]{1,61}[a-zA-Z0-9](?:\.[a-zA-Z]{2,})+$/ ) ) { - throw new Error('Invalid domain format'); + throw new Error("Invalid domain format"); } return cleanDomain; @@ -127,79 +83,39 @@ async function queryNeynarApp(apiKey) { } try { const response = await fetch( - 'https://api.neynar.com/portal/app_by_api_key', + `https://api.neynar.com/portal/app_by_api_key`, { headers: { - 'x-api-key': apiKey, + "x-api-key": apiKey, }, - }, + } ); const data = await response.json(); return data; } catch (error) { - console.error('Error querying Neynar app data:', error); + console.error("Error querying Neynar app data:", error); return null; } } -async function validateSeedPhrase(seedPhrase) { - try { - // Try to create an account from the seed phrase - const account = mnemonicToAccount(seedPhrase); - return account.address; - } catch (error) { - throw new Error('Invalid seed phrase'); - } -} - -async function generateFarcasterMetadata( - domain, - fid, - accountAddress, - seedPhrase, - webhookUrl, -) { - const header = { - type: 'custody', - key: accountAddress, - fid, - }; - const encodedHeader = Buffer.from(JSON.stringify(header), 'utf-8').toString( - 'base64', - ); - - const payload = { - domain, - }; - const encodedPayload = Buffer.from(JSON.stringify(payload), 'utf-8').toString( - 'base64url', - ); - - const account = mnemonicToAccount(seedPhrase); - const signature = await account.signMessage({ - message: `${encodedHeader}.${encodedPayload}`, - }); - const encodedSignature = Buffer.from(signature, 'utf-8').toString( - 'base64url', - ); - - const tags = process.env.NEXT_PUBLIC_MINI_APP_TAGS?.split(','); +async function generateFarcasterMetadata(domain, webhookUrl) { + const tags = process.env.NEXT_PUBLIC_MINI_APP_TAGS?.split(","); return { accountAssociation: { - header: encodedHeader, - payload: encodedPayload, - signature: encodedSignature, + header: "", + payload: "", + signature: "", }, frame: { - version: '1', + version: "1", name: process.env.NEXT_PUBLIC_MINI_APP_NAME, iconUrl: `https://${domain}/icon.png`, homeUrl: `https://${domain}`, imageUrl: `https://${domain}/api/opengraph-image`, buttonTitle: process.env.NEXT_PUBLIC_MINI_APP_BUTTON_TEXT, splashImageUrl: `https://${domain}/splash.png`, - splashBackgroundColor: '#f7f7f7', + splashBackgroundColor: "#f7f7f7", webhookUrl, description: process.env.NEXT_PUBLIC_MINI_APP_DESCRIPTION, primaryCategory: process.env.NEXT_PUBLIC_MINI_APP_PRIMARY_CATEGORY, @@ -210,8 +126,8 @@ async function generateFarcasterMetadata( async function main() { try { - console.log('\nšŸ“ Checking environment variables...'); - console.log('Loading values from .env...'); + console.log("\nšŸ“ Checking environment variables..."); + console.log("Loading values from .env..."); // Load .env.local if user wants to await loadEnvLocal(); @@ -219,11 +135,11 @@ async function main() { // Get domain from user const { domain } = await inquirer.prompt([ { - type: 'input', - name: 'domain', + type: "input", + name: "domain", message: - 'Enter the domain where your mini app will be deployed (e.g., example.com):', - validate: async input => { + "Enter the domain where your mini app will be deployed (e.g., example.com):", + validate: async (input) => { try { await validateDomain(input); return true; @@ -237,13 +153,13 @@ async function main() { // Get frame name from user const { frameName } = await inquirer.prompt([ { - type: 'input', - name: 'frameName', - message: 'Enter the name for your mini app (e.g., My Cool Mini App):', + type: "input", + name: "frameName", + message: "Enter the name for your mini app (e.g., My Cool Mini App):", default: process.env.NEXT_PUBLIC_MINI_APP_NAME, - validate: input => { - if (input.trim() === '') { - return 'Mini app name cannot be empty'; + validate: (input) => { + if (input.trim() === "") { + return "Mini app name cannot be empty"; } return true; }, @@ -253,14 +169,14 @@ async function main() { // Get button text from user const { buttonText } = await inquirer.prompt([ { - type: 'input', - name: 'buttonText', - message: 'Enter the text for your mini app button:', + type: "input", + name: "buttonText", + message: "Enter the text for your mini app button:", default: - process.env.NEXT_PUBLIC_MINI_APP_BUTTON_TEXT || 'Launch Mini App', - validate: input => { - if (input.trim() === '') { - return 'Button text cannot be empty'; + process.env.NEXT_PUBLIC_MINI_APP_BUTTON_TEXT || "Launch Mini App", + validate: (input) => { + if (input.trim() === "") { + return "Button text cannot be empty"; } return true; }, @@ -276,16 +192,16 @@ async function main() { if (!neynarApiKey) { const { neynarApiKey: inputNeynarApiKey } = await inquirer.prompt([ { - type: 'password', - name: 'neynarApiKey', + type: "password", + name: "neynarApiKey", message: - 'Enter your Neynar API key (optional - leave blank to skip):', + "Enter your Neynar API key (optional - leave blank to skip):", default: null, }, ]); neynarApiKey = inputNeynarApiKey; } else { - console.log('Using existing Neynar API key from .env'); + console.log("Using existing Neynar API key from .env"); } if (!neynarApiKey) { @@ -298,7 +214,7 @@ async function main() { const appInfo = await queryNeynarApp(neynarApiKey); if (appInfo) { neynarClientId = appInfo.app_uuid; - console.log('āœ… Fetched Neynar app client ID'); + console.log("āœ… Fetched Neynar app client ID"); break; } } @@ -310,13 +226,13 @@ async function main() { // If we get here, the API key was invalid console.log( - '\nāš ļø Could not find Neynar app information. The API key may be incorrect.', + "\nāš ļø Could not find Neynar app information. The API key may be incorrect." ); const { retry } = await inquirer.prompt([ { - type: 'confirm', - name: 'retry', - message: 'Would you like to try a different API key?', + type: "confirm", + name: "retry", + message: "Would you like to try a different API key?", default: true, }, ]); @@ -331,66 +247,23 @@ async function main() { } } - // Get seed phrase from user - let seedPhrase = process.env.SEED_PHRASE; - if (!seedPhrase) { - const { seedPhrase: inputSeedPhrase } = await inquirer.prompt([ - { - type: 'password', - name: 'seedPhrase', - message: - 'Your farcaster custody account seed phrase is required to create a signature proving this app was created by you.\n' + - `āš ļø ${yellow}${italic}seed phrase is only used to sign the mini app manifest, then discarded${reset} āš ļø\n` + - 'Seed phrase:', - validate: async input => { - try { - await validateSeedPhrase(input); - return true; - } catch (error) { - return error.message; - } - }, - }, - ]); - seedPhrase = inputSeedPhrase; - } else { - console.log('Using existing seed phrase from .env'); - } - - // Validate seed phrase and get account address - const accountAddress = await validateSeedPhrase(seedPhrase); - console.log('āœ… Generated account address from seed phrase'); - - const fid = await lookupFidByCustodyAddress( - accountAddress, - neynarApiKey ?? 'FARCASTER_V2_FRAMES_DEMO', - ); - - // Generate and sign manifest - console.log('\nšŸ”Ø Generating mini app manifest...'); + // Generate manifest + console.log("\nšŸ”Ø Generating mini app manifest..."); // Determine webhook URL based on environment variables const webhookUrl = neynarApiKey && neynarClientId ? `https://api.neynar.com/f/app/${neynarClientId}/event` - : `${domain}/api/webhook`; + : `https://${domain}/api/webhook`; - const metadata = await generateFarcasterMetadata( - domain, - fid, - accountAddress, - seedPhrase, - webhookUrl, - ); - console.log( - '\nāœ… Mini app manifest generated' + (seedPhrase ? ' and signed' : ''), - ); + const metadata = await generateFarcasterMetadata(domain, webhookUrl); + console.log("\nāœ… Mini app manifest generated"); // Read existing .env file or create new one - const envPath = path.join(projectRoot, '.env'); + const envPath = path.join(projectRoot, ".env"); let envContent = fs.existsSync(envPath) - ? fs.readFileSync(envPath, 'utf8') - : ''; + ? fs.readFileSync(envPath, "utf8") + : ""; // Add or update environment variables const newEnvVars = [ @@ -399,26 +272,40 @@ async function main() { // Mini app metadata `NEXT_PUBLIC_MINI_APP_NAME="${frameName}"`, - `NEXT_PUBLIC_MINI_APP_DESCRIPTION="${process.env.NEXT_PUBLIC_MINI_APP_DESCRIPTION || ''}"`, - `NEXT_PUBLIC_MINI_APP_PRIMARY_CATEGORY="${process.env.NEXT_PUBLIC_MINI_APP_PRIMARY_CATEGORY || ''}"`, - `NEXT_PUBLIC_MINI_APP_TAGS="${process.env.NEXT_PUBLIC_MINI_APP_TAGS || ''}"`, + `NEXT_PUBLIC_MINI_APP_DESCRIPTION="${ + process.env.NEXT_PUBLIC_MINI_APP_DESCRIPTION || "" + }"`, + `NEXT_PUBLIC_MINI_APP_PRIMARY_CATEGORY="${ + process.env.NEXT_PUBLIC_MINI_APP_PRIMARY_CATEGORY || "" + }"`, + `NEXT_PUBLIC_MINI_APP_TAGS="${ + process.env.NEXT_PUBLIC_MINI_APP_TAGS || "" + }"`, `NEXT_PUBLIC_MINI_APP_BUTTON_TEXT="${buttonText}"`, // Analytics - `NEXT_PUBLIC_ANALYTICS_ENABLED="${process.env.NEXT_PUBLIC_ANALYTICS_ENABLED || 'false'}"`, + `NEXT_PUBLIC_ANALYTICS_ENABLED="${ + process.env.NEXT_PUBLIC_ANALYTICS_ENABLED || "false" + }"`, // Neynar configuration (if it exists in current env) ...(process.env.NEYNAR_API_KEY ? [`NEYNAR_API_KEY="${process.env.NEYNAR_API_KEY}"`] : []), ...(neynarClientId ? [`NEYNAR_CLIENT_ID="${neynarClientId}"`] : []), + ...(process.env.SPONSOR_SIGNER ? + [`SPONSOR_SIGNER="${process.env.SPONSOR_SIGNER}"`] : []), // FID (if it exists in current env) ...(process.env.FID ? [`FID="${process.env.FID}"`] : []), - `NEXT_PUBLIC_USE_WALLET="${process.env.NEXT_PUBLIC_USE_WALLET || 'false'}"`, + `NEXT_PUBLIC_USE_WALLET="${ + process.env.NEXT_PUBLIC_USE_WALLET || "false" + }"`, // NextAuth configuration - `NEXTAUTH_SECRET="${process.env.NEXTAUTH_SECRET || crypto.randomBytes(32).toString('hex')}"`, + `NEXTAUTH_SECRET="${ + process.env.NEXTAUTH_SECRET || crypto.randomBytes(32).toString("hex") + }"`, `NEXTAUTH_URL="https://${domain}"`, // Mini app manifest with signature @@ -426,14 +313,14 @@ async function main() { ]; // Filter out empty values and join with newlines - const validEnvVars = newEnvVars.filter(line => { - const [, value] = line.split('='); + const validEnvVars = newEnvVars.filter((line) => { + const [, value] = line.split("="); return value && value !== '""'; }); // Update or append each environment variable - validEnvVars.forEach(varLine => { - const [key] = varLine.split('='); + validEnvVars.forEach((varLine) => { + const [key] = varLine.split("="); if (envContent.includes(`${key}=`)) { envContent = envContent.replace(new RegExp(`${key}=.*`), varLine); } else { @@ -444,29 +331,29 @@ async function main() { // Write updated .env file fs.writeFileSync(envPath, envContent); - console.log('\nāœ… Environment variables updated'); + console.log("\nāœ… Environment variables updated"); // Run next build - console.log('\nBuilding Next.js application...'); + console.log("\nBuilding Next.js application..."); const nextBin = path.normalize( - path.join(projectRoot, 'node_modules', '.bin', 'next'), + path.join(projectRoot, "node_modules", ".bin", "next") ); execSync(`"${nextBin}" build`, { cwd: projectRoot, - stdio: 'inherit', - shell: process.platform === 'win32', + stdio: "inherit", + shell: process.platform === "win32", }); console.log( - '\n✨ Build complete! Your mini app is ready for deployment. 🪐', + "\n✨ Build complete! Your mini app is ready for deployment. 🪐" ); console.log( - 'šŸ“ Make sure to configure the environment variables from .env in your hosting provider', + "šŸ“ Make sure to configure the environment variables from .env in your hosting provider" ); } catch (error) { - console.error('\nāŒ Error:', error.message); + console.error("\nāŒ Error:", error.message); process.exit(1); } } -main(); +main(); \ No newline at end of file diff --git a/scripts/cleanup.js b/scripts/cleanup.js index 2ffc9e2..c1f071e 100644 --- a/scripts/cleanup.js +++ b/scripts/cleanup.js @@ -1,6 +1,6 @@ #!/usr/bin/env node -const { execSync } = require('child_process'); +import { execSync } from 'child_process'; // Parse arguments const args = process.argv.slice(2); diff --git a/scripts/deploy.js b/scripts/deploy.js index dfe5470..b291146 100755 --- a/scripts/deploy.js +++ b/scripts/deploy.js @@ -1,13 +1,12 @@ import { execSync, spawn } from 'child_process'; -import crypto from 'crypto'; import fs from 'fs'; -import os from 'os'; import path from 'path'; +import os from 'os'; import { fileURLToPath } from 'url'; -import { Vercel } from '@vercel/sdk'; -import dotenv from 'dotenv'; import inquirer from 'inquirer'; -import { mnemonicToAccount } from 'viem/accounts'; +import dotenv from 'dotenv'; +import crypto from 'crypto'; +import { Vercel } from '@vercel/sdk'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const projectRoot = path.join(__dirname, '..'); @@ -15,86 +14,11 @@ const projectRoot = path.join(__dirname, '..'); // Load environment variables in specific order dotenv.config({ path: '.env' }); -async function validateSeedPhrase(seedPhrase) { - try { - const account = mnemonicToAccount(seedPhrase); - return account.address; - } catch (error) { - throw new Error('Invalid seed phrase'); - } -} - -async function lookupFidByCustodyAddress(custodyAddress, apiKey) { - if (!apiKey) { - throw new Error('Neynar API key is required'); - } - const lowerCasedCustodyAddress = custodyAddress.toLowerCase(); - - const response = await fetch( - `https://api.neynar.com/v2/farcaster/user/bulk-by-address?addresses=${lowerCasedCustodyAddress}&address_types=custody_address`, - { - headers: { - accept: 'application/json', - 'x-api-key': apiKey, - }, - }, - ); - - if (!response.ok) { - throw new Error(`Failed to lookup FID: ${response.statusText}`); - } - - const data = await response.json(); - if ( - !data[lowerCasedCustodyAddress]?.length || - !data[lowerCasedCustodyAddress][0].custody_address - ) { - throw new Error('No FID found for this custody address'); - } - - return data[lowerCasedCustodyAddress][0].fid; -} - -async function generateFarcasterMetadata( - domain, - fid, - accountAddress, - seedPhrase, - webhookUrl, -) { +async function generateFarcasterMetadata(domain, webhookUrl) { const trimmedDomain = domain.trim(); - const header = { - type: 'custody', - key: accountAddress, - fid, - }; - const encodedHeader = Buffer.from(JSON.stringify(header), 'utf-8').toString( - 'base64', - ); - - const payload = { - domain: trimmedDomain, - }; - const encodedPayload = Buffer.from(JSON.stringify(payload), 'utf-8').toString( - 'base64url', - ); - - const account = mnemonicToAccount(seedPhrase); - const signature = await account.signMessage({ - message: `${encodedHeader}.${encodedPayload}`, - }); - const encodedSignature = Buffer.from(signature, 'utf-8').toString( - 'base64url', - ); - const tags = process.env.NEXT_PUBLIC_MINI_APP_TAGS?.split(','); return { - accountAssociation: { - header: encodedHeader, - payload: encodedPayload, - signature: encodedSignature, - }, frame: { version: '1', name: process.env.NEXT_PUBLIC_MINI_APP_NAME, @@ -120,7 +44,7 @@ async function loadEnvLocal() { type: 'confirm', name: 'loadLocal', message: - 'Found .env.local - would you like to load its values in addition to .env values? (except for SEED_PHRASE, values will be written to .env)', + 'Found .env.local - would you like to load its values in addition to .env values?', default: true, }, ]); @@ -130,7 +54,6 @@ async function loadEnvLocal() { const localEnv = dotenv.parse(fs.readFileSync('.env.local')); const allowedVars = [ - 'SEED_PHRASE', 'NEXT_PUBLIC_MINI_APP_NAME', 'NEXT_PUBLIC_MINI_APP_DESCRIPTION', 'NEXT_PUBLIC_MINI_APP_PRIMARY_CATEGORY', @@ -139,6 +62,7 @@ async function loadEnvLocal() { 'NEXT_PUBLIC_ANALYTICS_ENABLED', 'NEYNAR_API_KEY', 'NEYNAR_CLIENT_ID', + 'SPONSOR_SIGNER', ]; const envContent = fs.existsSync('.env') @@ -149,7 +73,7 @@ async function loadEnvLocal() { for (const [key, value] of Object.entries(localEnv)) { if (allowedVars.includes(key)) { process.env[key] = value; - if (key !== 'SEED_PHRASE' && !envContent.includes(`${key}=`)) { + if (!envContent.includes(`${key}=`)) { newEnvContent += `${key}="${value}"\n`; } } @@ -175,19 +99,20 @@ async function checkRequiredEnvVars() { name: 'NEXT_PUBLIC_MINI_APP_NAME', message: 'Enter the name for your frame (e.g., My Cool Mini App):', default: process.env.NEXT_PUBLIC_MINI_APP_NAME, - validate: input => input.trim() !== '' || 'Mini app name cannot be empty', + validate: (input) => + input.trim() !== '' || 'Mini app name cannot be empty', }, { name: 'NEXT_PUBLIC_MINI_APP_BUTTON_TEXT', message: 'Enter the text for your frame button:', default: process.env.NEXT_PUBLIC_MINI_APP_BUTTON_TEXT ?? 'Launch Mini App', - validate: input => input.trim() !== '' || 'Button text cannot be empty', + validate: (input) => input.trim() !== '' || 'Button text cannot be empty', }, ]; const missingVars = requiredVars.filter( - varConfig => !process.env[varConfig.name], + (varConfig) => !process.env[varConfig.name] ); if (missingVars.length > 0) { @@ -213,45 +138,46 @@ async function checkRequiredEnvVars() { const newLine = envContent ? '\n' : ''; fs.appendFileSync( '.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'); + } + } } } - // Check for seed phrase - if (!process.env.SEED_PHRASE) { - console.log('\nšŸ”‘ Mini App Manifest Signing'); - console.log('A signed manifest helps users trust your mini app.'); - const { seedPhrase } = await inquirer.prompt([ - { - type: 'password', - name: 'seedPhrase', - message: - 'Enter your Farcaster custody account seed phrase to sign the mini app manifest\n(optional -- leave blank to create an unsigned mini app)\n\nSeed phrase:', - default: null, - }, - ]); - - if (seedPhrase) { - process.env.SEED_PHRASE = seedPhrase; - - const { storeSeedPhrase } = await inquirer.prompt([ - { - type: 'confirm', - name: 'storeSeedPhrase', - message: - 'Would you like to store this seed phrase in .env.local for future use?', - default: false, - }, - ]); - - if (storeSeedPhrase) { - fs.appendFileSync('.env.local', `\nSEED_PHRASE="${seedPhrase}"`); - console.log('āœ… Seed phrase stored in .env.local'); - } else { - console.log('ā„¹ļø Seed phrase will only be used for this deployment'); - } + // 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; } } } @@ -318,7 +244,7 @@ async function getVercelToken() { return null; // We'll fall back to CLI operations } catch (error) { throw new Error( - 'Not logged in to Vercel CLI. Please run this script again to login.', + 'Not logged in to Vercel CLI. Please run this script again to login.' ); } } @@ -334,7 +260,7 @@ async function loginToVercel() { console.log('3. Complete the Vercel account setup in your browser'); console.log('4. Return here once your Vercel account is created\n'); console.log( - '\nNote: you may need to cancel this script with ctrl+c and run it again if creating a new vercel account', + '\nNote: you may need to cancel this script with ctrl+c and run it again if creating a new vercel account' ); const child = spawn('vercel', ['login'], { @@ -342,14 +268,14 @@ async function loginToVercel() { }); await new Promise((resolve, reject) => { - child.on('close', code => { + child.on('close', (code) => { resolve(); }); }); console.log('\nšŸ“± Waiting for login to complete...'); console.log( - "If you're creating a new account, please complete the Vercel account setup in your browser first.", + "If you're creating a new account, please complete the Vercel account setup in your browser first." ); for (let i = 0; i < 150; i++) { @@ -361,7 +287,7 @@ async function loginToVercel() { if (error.message.includes('Account not found')) { console.log('ā„¹ļø Waiting for Vercel account setup to complete...'); } - await new Promise(resolve => setTimeout(resolve, 2000)); + await new Promise((resolve) => setTimeout(resolve, 2000)); } } @@ -387,7 +313,7 @@ async function setVercelEnvVarSDK(vercelClient, projectId, key, value) { }); const existingVar = existingVars.envs?.find( - env => env.key === key && env.target?.includes('production'), + (env) => env.key === key && env.target?.includes('production') ); if (existingVar) { @@ -419,7 +345,7 @@ async function setVercelEnvVarSDK(vercelClient, projectId, key, value) { } catch (error) { console.warn( `āš ļø Warning: Failed to set environment variable ${key}:`, - error.message, + error.message ); return false; } @@ -474,7 +400,7 @@ async function setVercelEnvVarCLI(key, value, projectRoot) { } console.warn( `āš ļø Warning: Failed to set environment variable ${key}:`, - error.message, + error.message ); return false; } @@ -484,7 +410,7 @@ async function setEnvironmentVariables( vercelClient, projectId, envVars, - projectRoot, + projectRoot ) { console.log('\nšŸ“ Setting up environment variables...'); @@ -509,18 +435,62 @@ async function setEnvironmentVariables( } // Report results - const failed = results.filter(r => !r.success); + const failed = results.filter((r) => !r.success); if (failed.length > 0) { 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( - '\nYou may need to set these manually in the Vercel dashboard.', + '\nYou may need to set these manually in the Vercel dashboard.' ); } return results; } +async function waitForDeployment( + vercelClient, + projectId, + maxWaitTime = 300000 +) { + // 5 minutes + console.log('\nā³ Waiting for deployment to complete...'); + const startTime = Date.now(); + + while (Date.now() - startTime < maxWaitTime) { + try { + const deployments = await vercelClient.deployments.list({ + projectId: projectId, + limit: 1, + }); + + if (deployments.deployments?.[0]) { + const deployment = deployments.deployments[0]; + console.log(`šŸ“Š Deployment status: ${deployment.state}`); + + if (deployment.state === 'READY') { + console.log('āœ… Deployment completed successfully!'); + return deployment; + } else if (deployment.state === 'ERROR') { + throw new Error(`Deployment failed with state: ${deployment.state}`); + } else if (deployment.state === 'CANCELED') { + throw new Error('Deployment was canceled'); + } + + // Still building, wait and check again + await new Promise((resolve) => setTimeout(resolve, 5000)); // Wait 5 seconds + } else { + console.log('ā³ No deployment found yet, waiting...'); + await new Promise((resolve) => setTimeout(resolve, 5000)); + } + } catch (error) { + console.warn('āš ļø Could not check deployment status:', error.message); + await new Promise((resolve) => setTimeout(resolve, 5000)); + } + } + + throw new Error('Deployment timed out after 5 minutes'); +} + async function deployToVercel(useGitHub = false) { try { console.log('\nšŸš€ Deploying to Vercel...'); @@ -537,31 +507,60 @@ async function deployToVercel(useGitHub = false) { framework: 'nextjs', }, null, - 2, - ), + 2 + ) ); } // Set up Vercel project console.log('\nšŸ“¦ Setting up Vercel project...'); console.log( - 'An initial deployment is required to get an assigned domain that can be used in the mini app manifest\n', + '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', + '\nāš ļø Note: choosing a longer, more unique project name will help avoid conflicts with other existing domains\n' ); - execSync('vercel', { + // Use spawn instead of execSync for better error handling + const { spawn } = await import('child_process'); + const vercelSetup = spawn('vercel', [], { cwd: projectRoot, stdio: 'inherit', shell: process.platform === 'win32', }); + await new Promise((resolve, reject) => { + vercelSetup.on('close', (code) => { + if (code === 0 || code === null) { + console.log('āœ… Vercel project setup completed'); + resolve(); + } else { + console.log('āš ļø Vercel setup command completed (this is normal)'); + resolve(); // Don't reject, as this is often expected + } + }); + + vercelSetup.on('error', (error) => { + console.log('āš ļø Vercel setup command completed (this is normal)'); + resolve(); // Don't reject, as this is often expected + }); + }); + + // Wait a moment for project files to be written + await new Promise((resolve) => setTimeout(resolve, 2000)); + // Load project info - const projectJson = JSON.parse( - fs.readFileSync('.vercel/project.json', 'utf8'), - ); - const projectId = projectJson.projectId; + let projectId; + try { + const projectJson = JSON.parse( + fs.readFileSync('.vercel/project.json', 'utf8') + ); + projectId = projectJson.projectId; + } catch (error) { + throw new Error( + 'Failed to load project info. Please ensure the Vercel project was created successfully.' + ); + } // Get Vercel token and initialize SDK client let vercelClient = null; @@ -575,7 +574,7 @@ async function deployToVercel(useGitHub = false) { } } catch (error) { console.warn( - 'āš ļø Could not initialize Vercel SDK, falling back to CLI operations', + 'āš ļø Could not initialize Vercel SDK, falling back to CLI operations' ); } @@ -594,65 +593,60 @@ async function deployToVercel(useGitHub = false) { console.log('🌐 Using project name for domain:', domain); } catch (error) { console.warn( - 'āš ļø Could not get project details via SDK, using CLI fallback', + 'āš ļø Could not get project details via SDK, using CLI fallback' ); } } // Fallback to CLI method if SDK failed if (!domain) { - const inspectOutput = execSync( - `vercel project inspect ${projectId} 2>&1`, - { - cwd: projectRoot, - encoding: 'utf8', - }, - ); + try { + const inspectOutput = execSync( + `vercel project inspect ${projectId} 2>&1`, + { + cwd: projectRoot, + encoding: 'utf8', + } + ); - const nameMatch = inspectOutput.match(/Name\s+([^\n]+)/); - if (nameMatch) { - projectName = nameMatch[1].trim(); - domain = `${projectName}.vercel.app`; - console.log('🌐 Using project name for domain:', domain); - } else { - const altMatch = inspectOutput.match(/Found Project [^/]+\/([^\n]+)/); - if (altMatch) { - projectName = altMatch[1].trim(); + const nameMatch = inspectOutput.match(/Name\s+([^\n]+)/); + if (nameMatch) { + projectName = nameMatch[1].trim(); domain = `${projectName}.vercel.app`; console.log('🌐 Using project name for domain:', domain); } else { - throw new Error( - 'Could not determine project name from inspection output', - ); + const altMatch = inspectOutput.match(/Found Project [^/]+\/([^\n]+)/); + if (altMatch) { + projectName = altMatch[1].trim(); + domain = `${projectName}.vercel.app`; + console.log('🌐 Using project name for domain:', domain); + } else { + console.warn( + 'āš ļø Could not determine project name from inspection, using fallback' + ); + // Use a fallback domain based on project ID + domain = `project-${projectId.slice(-8)}.vercel.app`; + console.log('🌐 Using fallback domain:', domain); + } } + } catch (error) { + console.warn('āš ļø Could not inspect project, using fallback domain'); + // Use a fallback domain based on project ID + domain = `project-${projectId.slice(-8)}.vercel.app`; + console.log('🌐 Using fallback domain:', domain); } } - // Generate mini app metadata if we have a seed phrase - let miniAppMetadata; - let fid; - if (process.env.SEED_PHRASE) { - console.log('\nšŸ”Ø Generating mini app metadata...'); - const accountAddress = await validateSeedPhrase(process.env.SEED_PHRASE); - fid = await lookupFidByCustodyAddress( - accountAddress, - process.env.NEYNAR_API_KEY ?? 'FARCASTER_V2_FRAMES_DEMO', - ); + // Generate mini app metadata + console.log('\nšŸ”Ø Generating mini app metadata...'); - const webhookUrl = - process.env.NEYNAR_API_KEY && process.env.NEYNAR_CLIENT_ID - ? `https://api.neynar.com/f/app/${process.env.NEYNAR_CLIENT_ID}/event` - : `https://${domain}/api/webhook`; + const webhookUrl = + process.env.NEYNAR_API_KEY && process.env.NEYNAR_CLIENT_ID + ? `https://api.neynar.com/f/app/${process.env.NEYNAR_CLIENT_ID}/event` + : `https://${domain}/api/webhook`; - miniAppMetadata = await generateFarcasterMetadata( - domain, - fid, - accountAddress, - process.env.SEED_PHRASE, - webhookUrl, - ); - console.log('āœ… Mini app metadata generated and signed'); - } + const miniAppMetadata = await generateFarcasterMetadata(domain, webhookUrl); + console.log('āœ… Mini app metadata generated'); // Prepare environment variables const nextAuthSecret = @@ -669,12 +663,15 @@ async function deployToVercel(useGitHub = false) { ...(process.env.NEYNAR_CLIENT_ID && { NEYNAR_CLIENT_ID: process.env.NEYNAR_CLIENT_ID, }), + ...(process.env.SPONSOR_SIGNER && { + SPONSOR_SIGNER: process.env.SPONSOR_SIGNER, + }), ...(miniAppMetadata && { MINI_APP_METADATA: miniAppMetadata }), ...Object.fromEntries( Object.entries(process.env).filter(([key]) => - key.startsWith('NEXT_PUBLIC_'), - ), + key.startsWith('NEXT_PUBLIC_') + ) ), }; @@ -683,7 +680,7 @@ async function deployToVercel(useGitHub = false) { vercelClient, projectId, vercelEnv, - projectRoot, + projectRoot ); // Deploy the project @@ -699,30 +696,55 @@ async function deployToVercel(useGitHub = false) { console.log('\nšŸ“¦ Deploying local code directly...'); } - execSync('vercel deploy --prod', { + // Use spawn for better control over the deployment process + const vercelDeploy = spawn('vercel', ['deploy', '--prod'], { cwd: projectRoot, stdio: 'inherit', env: process.env, }); + await new Promise((resolve, reject) => { + vercelDeploy.on('close', (code) => { + if (code === 0) { + console.log('āœ… Vercel deployment command completed'); + resolve(); + } else { + console.error(`āŒ Vercel deployment failed with code: ${code}`); + reject(new Error(`Vercel deployment failed with exit code: ${code}`)); + } + }); + + vercelDeploy.on('error', (error) => { + console.error('āŒ Vercel deployment error:', error.message); + reject(error); + }); + }); + + // Wait for deployment to actually complete + let deployment; + if (vercelClient) { + try { + deployment = await waitForDeployment(vercelClient, projectId); + } catch (error) { + console.warn( + 'āš ļø Could not verify deployment completion:', + error.message + ); + console.log('ā„¹ļø Proceeding with domain verification...'); + } + } + // Verify actual domain after deployment console.log('\nšŸ” Verifying deployment domain...'); let actualDomain = domain; - if (vercelClient) { + if (vercelClient && deployment) { try { - const deployments = await vercelClient.deployments.list({ - projectId: projectId, - limit: 1, - }); - - if (deployments.deployments?.[0]?.url) { - actualDomain = deployments.deployments[0].url; - console.log('🌐 Verified actual domain:', actualDomain); - } + actualDomain = deployment.url || domain; + console.log('🌐 Verified actual domain:', actualDomain); } catch (error) { console.warn( - 'āš ļø Could not verify domain via SDK, using assumed domain', + 'āš ļø Could not verify domain via SDK, using assumed domain' ); } } @@ -747,7 +769,7 @@ async function deployToVercel(useGitHub = false) { fid, await validateSeedPhrase(process.env.SEED_PHRASE), process.env.SEED_PHRASE, - webhookUrl, + webhookUrl ); updatedEnv.MINI_APP_METADATA = updatedMetadata; } @@ -756,23 +778,40 @@ async function deployToVercel(useGitHub = false) { vercelClient, projectId, updatedEnv, - projectRoot, + projectRoot ); console.log('\nšŸ“¦ Redeploying with correct domain...'); - execSync('vercel deploy --prod', { + const vercelRedeploy = spawn('vercel', ['deploy', '--prod'], { cwd: projectRoot, stdio: 'inherit', env: process.env, }); + await new Promise((resolve, reject) => { + vercelRedeploy.on('close', (code) => { + if (code === 0) { + console.log('āœ… Redeployment completed'); + resolve(); + } else { + console.error(`āŒ Redeployment failed with code: ${code}`); + reject(new Error(`Redeployment failed with exit code: ${code}`)); + } + }); + + vercelRedeploy.on('error', (error) => { + console.error('āŒ Redeployment error:', error.message); + reject(error); + }); + }); + domain = actualDomain; } console.log('\n✨ Deployment complete! Your mini app is now live at:'); console.log(`🌐 https://${domain}`); console.log( - '\nšŸ“ You can manage your project at https://vercel.com/dashboard', + '\nšŸ“ You can manage your project at https://vercel.com/dashboard' ); } catch (error) { console.error('\nāŒ Deployment failed:', error.message); @@ -784,7 +823,7 @@ async function main() { try { console.log('šŸš€ Vercel Mini App Deployment (SDK Edition)'); console.log( - 'This script will deploy your mini app to Vercel using the Vercel SDK.', + 'This script will deploy your mini app to Vercel using the Vercel SDK.' ); console.log('\nThe script will:'); console.log('1. Check for required environment variables'); @@ -863,4 +902,4 @@ async function main() { } } -main(); +main(); \ No newline at end of file diff --git a/src/app/api/auth/nonce/route.ts b/src/app/api/auth/nonce/route.ts new file mode 100644 index 0000000..a1f25ea --- /dev/null +++ b/src/app/api/auth/nonce/route.ts @@ -0,0 +1,16 @@ +import { NextResponse } from 'next/server'; +import { getNeynarClient } from '~/lib/neynar'; + +export async function GET() { + try { + const client = getNeynarClient(); + const response = await client.fetchNonce(); + return NextResponse.json(response); + } catch (error) { + console.error('Error fetching nonce:', error); + return NextResponse.json( + { error: 'Failed to fetch nonce' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/auth/session-signers/route.ts b/src/app/api/auth/session-signers/route.ts new file mode 100644 index 0000000..630ef3b --- /dev/null +++ b/src/app/api/auth/session-signers/route.ts @@ -0,0 +1,43 @@ +import { NextResponse } from 'next/server'; +import { getNeynarClient } from '~/lib/neynar'; + +export async function GET(request: Request) { + try { + const { searchParams } = new URL(request.url); + const message = searchParams.get('message'); + const signature = searchParams.get('signature'); + + if (!message || !signature) { + return NextResponse.json( + { error: 'Message and signature are required' }, + { status: 400 } + ); + } + + const client = getNeynarClient(); + const data = await client.fetchSigners({ message, signature }); + const signers = data.signers; + + // Fetch user data if signers exist + let user = null; + if (signers && signers.length > 0 && signers[0].fid) { + const { + users: [fetchedUser], + } = await client.fetchBulkUsers({ + fids: [signers[0].fid], + }); + user = fetchedUser; + } + + return NextResponse.json({ + signers, + user, + }); + } catch (error) { + console.error('Error in session-signers API:', error); + return NextResponse.json( + { error: 'Failed to fetch signers' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/auth/signer/route.ts b/src/app/api/auth/signer/route.ts new file mode 100644 index 0000000..f793d0e --- /dev/null +++ b/src/app/api/auth/signer/route.ts @@ -0,0 +1,42 @@ +import { NextResponse } from 'next/server'; +import { getNeynarClient } from '~/lib/neynar'; + +export async function POST() { + try { + const neynarClient = getNeynarClient(); + const signer = await neynarClient.createSigner(); + return NextResponse.json(signer); + } catch (error) { + console.error('Error fetching signer:', error); + return NextResponse.json( + { error: 'Failed to fetch signer' }, + { status: 500 } + ); + } +} + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const signerUuid = searchParams.get('signerUuid'); + + if (!signerUuid) { + return NextResponse.json( + { error: 'signerUuid is required' }, + { status: 400 } + ); + } + + try { + const neynarClient = getNeynarClient(); + const signer = await neynarClient.lookupSigner({ + signerUuid, + }); + return NextResponse.json(signer); + } catch (error) { + console.error('Error fetching signed key:', error); + return NextResponse.json( + { error: 'Failed to fetch signed key' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/auth/signer/signed_key/route.ts b/src/app/api/auth/signer/signed_key/route.ts new file mode 100644 index 0000000..d7a3df8 --- /dev/null +++ b/src/app/api/auth/signer/signed_key/route.ts @@ -0,0 +1,91 @@ +import { NextResponse } from 'next/server'; +import { getNeynarClient } from '~/lib/neynar'; +import { mnemonicToAccount } from 'viem/accounts'; +import { + SIGNED_KEY_REQUEST_TYPE, + SIGNED_KEY_REQUEST_VALIDATOR_EIP_712_DOMAIN, +} from '~/lib/constants'; + +const postRequiredFields = ['signerUuid', 'publicKey']; + +export async function POST(request: Request) { + const body = await request.json(); + + // Validate required fields + for (const field of postRequiredFields) { + if (!body[field]) { + return NextResponse.json( + { error: `${field} is required` }, + { status: 400 } + ); + } + } + + const { signerUuid, publicKey, redirectUrl } = body; + + if (redirectUrl && typeof redirectUrl !== 'string') { + return NextResponse.json( + { error: 'redirectUrl must be a string' }, + { status: 400 } + ); + } + + try { + // Get the app's account from seed phrase + const seedPhrase = process.env.SEED_PHRASE; + const shouldSponsor = process.env.SPONSOR_SIGNER === 'true'; + + if (!seedPhrase) { + return NextResponse.json( + { error: 'App configuration missing (SEED_PHRASE or FID)' }, + { status: 500 } + ); + } + + const neynarClient = getNeynarClient(); + + const account = mnemonicToAccount(seedPhrase); + + const { + user: { fid }, + } = await neynarClient.lookupUserByCustodyAddress({ + custodyAddress: account.address, + }); + + const appFid = fid; + + // Generate deadline (24 hours from now) + const deadline = Math.floor(Date.now() / 1000) + 86400; + + // Generate EIP-712 signature + const signature = await account.signTypedData({ + domain: SIGNED_KEY_REQUEST_VALIDATOR_EIP_712_DOMAIN, + types: { + SignedKeyRequest: SIGNED_KEY_REQUEST_TYPE, + }, + primaryType: 'SignedKeyRequest', + message: { + requestFid: BigInt(appFid), + key: publicKey, + deadline: BigInt(deadline), + }, + }); + + const signer = await neynarClient.registerSignedKey({ + appFid, + deadline, + signature, + signerUuid, + ...(redirectUrl && { redirectUrl }), + ...(shouldSponsor && { sponsor: { sponsored_by_neynar: true } }), + }); + + return NextResponse.json(signer); + } catch (error) { + console.error('Error registering signed key:', error); + return NextResponse.json( + { error: 'Failed to register signed key' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/auth/signers/route.ts b/src/app/api/auth/signers/route.ts new file mode 100644 index 0000000..1c89acf --- /dev/null +++ b/src/app/api/auth/signers/route.ts @@ -0,0 +1,38 @@ +import { NextResponse } from 'next/server'; +import { getNeynarClient } from '~/lib/neynar'; + +const requiredParams = ['message', 'signature']; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const params: Record = {}; + for (const param of requiredParams) { + params[param] = searchParams.get(param); + if (!params[param]) { + return NextResponse.json( + { + error: `${param} parameter is required`, + }, + { status: 400 } + ); + } + } + + const message = params.message as string; + const signature = params.signature as string; + + try { + const client = getNeynarClient(); + const data = await client.fetchSigners({ message, signature }); + const signers = data.signers; + return NextResponse.json({ + signers, + }); + } catch (error) { + console.error('Error fetching signers:', error); + return NextResponse.json( + { error: 'Failed to fetch signers' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/auth/update-session/route.ts b/src/app/api/auth/update-session/route.ts new file mode 100644 index 0000000..db4b4fc --- /dev/null +++ b/src/app/api/auth/update-session/route.ts @@ -0,0 +1,46 @@ +import { NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '~/auth'; + +export async function POST(request: Request) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user?.fid) { + return NextResponse.json( + { error: 'No authenticated session found' }, + { status: 401 } + ); + } + + const body = await request.json(); + const { signers, user } = body; + + if (!signers || !user) { + return NextResponse.json( + { error: 'Signers and user are required' }, + { status: 400 } + ); + } + + // For NextAuth to update the session, we need to trigger the JWT callback + // This is typically done by calling the session endpoint with updated data + // However, we can't directly modify the session token from here + + // Instead, we'll store the data temporarily and let the client refresh the session + // The session will be updated when the JWT callback is triggered + + return NextResponse.json({ + success: true, + message: 'Session update prepared', + signers, + user, + }); + } catch (error) { + console.error('Error preparing session update:', error); + return NextResponse.json( + { error: 'Failed to prepare session update' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/send-notification/route.ts b/src/app/api/send-notification/route.ts index 79a8e75..8f8c301 100644 --- a/src/app/api/send-notification/route.ts +++ b/src/app/api/send-notification/route.ts @@ -1,9 +1,9 @@ -import { NextRequest } from 'next/server'; -import { notificationDetailsSchema } from '@farcaster/frame-sdk'; -import { z } from 'zod'; -import { setUserNotificationDetails } from '~/lib/kv'; -import { sendNeynarMiniAppNotification } from '~/lib/neynar'; -import { sendMiniAppNotification } from '~/lib/notifs'; +import { notificationDetailsSchema } from "@farcaster/miniapp-sdk"; +import { NextRequest } from "next/server"; +import { z } from "zod"; +import { setUserNotificationDetails } from "~/lib/kv"; +import { sendMiniAppNotification } from "~/lib/notifs"; +import { sendNeynarMiniAppNotification } from "~/lib/neynar"; const requestSchema = z.object({ fid: z.number(), @@ -13,8 +13,7 @@ const requestSchema = z.object({ export async function POST(request: NextRequest) { // If Neynar is enabled, we don't need to store notification details // as they will be managed by Neynar's system - const neynarEnabled = - process.env.NEYNAR_API_KEY && process.env.NEYNAR_CLIENT_ID; + const neynarEnabled = process.env.NEYNAR_API_KEY && process.env.NEYNAR_CLIENT_ID; const requestJson = await request.json(); const requestBody = requestSchema.safeParse(requestJson); @@ -22,7 +21,7 @@ export async function POST(request: NextRequest) { if (requestBody.success === false) { return Response.json( { success: false, errors: requestBody.error.errors }, - { status: 400 }, + { status: 400 } ); } @@ -30,31 +29,29 @@ export async function POST(request: NextRequest) { if (!neynarEnabled) { await setUserNotificationDetails( Number(requestBody.data.fid), - requestBody.data.notificationDetails, + requestBody.data.notificationDetails ); } // Use appropriate notification function based on Neynar status - const sendNotification = neynarEnabled - ? sendNeynarMiniAppNotification - : sendMiniAppNotification; + const sendNotification = neynarEnabled ? sendNeynarMiniAppNotification : sendMiniAppNotification; const sendResult = await sendNotification({ fid: Number(requestBody.data.fid), - title: 'Test notification', - body: 'Sent at ' + new Date().toISOString(), + title: "Test notification", + body: "Sent at " + new Date().toISOString(), }); - if (sendResult.state === 'error') { + if (sendResult.state === "error") { return Response.json( { success: false, error: sendResult.error }, - { status: 500 }, + { status: 500 } ); - } else if (sendResult.state === 'rate_limit') { + } else if (sendResult.state === "rate_limit") { return Response.json( - { success: false, error: 'Rate limited' }, - { status: 429 }, + { success: false, error: "Rate limited" }, + { status: 429 } ); } return Response.json({ success: true }); -} +} \ No newline at end of file diff --git a/src/app/api/webhook/route.ts b/src/app/api/webhook/route.ts index 8691794..aec184e 100644 --- a/src/app/api/webhook/route.ts +++ b/src/app/api/webhook/route.ts @@ -1,21 +1,20 @@ -import { NextRequest } from 'next/server'; import { ParseWebhookEvent, parseWebhookEvent, verifyAppKeyWithNeynar, -} from '@farcaster/frame-node'; -import { APP_NAME } from '~/lib/constants'; +} from "@farcaster/miniapp-node"; +import { NextRequest } from "next/server"; +import { APP_NAME } from "~/lib/constants"; import { deleteUserNotificationDetails, setUserNotificationDetails, -} from '~/lib/kv'; -import { sendMiniAppNotification } from '~/lib/notifs'; +} from "~/lib/kv"; +import { sendMiniAppNotification } from "~/lib/notifs"; export async function POST(request: NextRequest) { // If Neynar is enabled, we don't need to handle webhooks here // as they will be handled by Neynar's webhook endpoint - const neynarEnabled = - process.env.NEYNAR_API_KEY && process.env.NEYNAR_CLIENT_ID; + const neynarEnabled = process.env.NEYNAR_API_KEY && process.env.NEYNAR_CLIENT_ID; if (neynarEnabled) { return Response.json({ success: true }); } @@ -29,24 +28,24 @@ export async function POST(request: NextRequest) { const error = e as ParseWebhookEvent.ErrorType; switch (error.name) { - case 'VerifyJsonFarcasterSignature.InvalidDataError': - case 'VerifyJsonFarcasterSignature.InvalidEventDataError': + case "VerifyJsonFarcasterSignature.InvalidDataError": + case "VerifyJsonFarcasterSignature.InvalidEventDataError": // The request data is invalid return Response.json( { success: false, error: error.message }, - { status: 400 }, + { status: 400 } ); - case 'VerifyJsonFarcasterSignature.InvalidAppKeyError': + case "VerifyJsonFarcasterSignature.InvalidAppKeyError": // The app key is invalid return Response.json( { success: false, error: error.message }, - { status: 401 }, + { status: 401 } ); - case 'VerifyJsonFarcasterSignature.VerifyAppKeyError': + case "VerifyJsonFarcasterSignature.VerifyAppKeyError": // Internal error verifying the app key (caller may want to try again) return Response.json( { success: false, error: error.message }, - { status: 500 }, + { status: 500 } ); } } @@ -57,36 +56,36 @@ export async function POST(request: NextRequest) { // Only handle notifications if Neynar is not enabled // When Neynar is enabled, notifications are handled through their webhook switch (event.event) { - case 'frame_added': + case "frame_added": if (event.notificationDetails) { await setUserNotificationDetails(fid, event.notificationDetails); await sendMiniAppNotification({ fid, title: `Welcome to ${APP_NAME}`, - body: 'Mini app is now added to your client', + body: "Mini app is now added to your client", }); } else { await deleteUserNotificationDetails(fid); } break; - case 'frame_removed': + case "frame_removed": await deleteUserNotificationDetails(fid); break; - case 'notifications_enabled': + case "notifications_enabled": await setUserNotificationDetails(fid, event.notificationDetails); await sendMiniAppNotification({ fid, title: `Welcome to ${APP_NAME}`, - body: 'Notifications are now enabled', + body: "Notifications are now enabled", }); break; - case 'notifications_disabled': + case "notifications_disabled": await deleteUserNotificationDetails(fid); break; } return Response.json({ success: true }); -} +} \ No newline at end of file diff --git a/src/app/providers.tsx b/src/app/providers.tsx index a91107a..014110a 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -1,17 +1,18 @@ 'use client'; import dynamic from 'next/dynamic'; -import { MiniAppProvider } from '@neynar/react'; import type { Session } from 'next-auth'; import { SessionProvider } from 'next-auth/react'; +import { MiniAppProvider } from '@neynar/react'; import { SafeFarcasterSolanaProvider } from '~/components/providers/SafeFarcasterSolanaProvider'; import { ANALYTICS_ENABLED } from '~/lib/constants'; +import { AuthKitProvider } from '@farcaster/auth-kit'; const WagmiProvider = dynamic( () => import('~/components/providers/WagmiProvider'), { ssr: false, - }, + } ); export function Providers({ @@ -31,10 +32,10 @@ export function Providers({ backButtonEnabled={true} > - {children} + {children} ); -} +} \ No newline at end of file diff --git a/src/auth.ts b/src/auth.ts index bc7db6a..d77fbba 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -1,11 +1,200 @@ -import { createAppClient, viemConnector } from '@farcaster/auth-client'; import { AuthOptions, getServerSession } from 'next-auth'; import CredentialsProvider from 'next-auth/providers/credentials'; +import { createAppClient, viemConnector } from '@farcaster/auth-client'; declare module 'next-auth' { interface Session { - user: { + provider?: string; + user?: { fid: number; + object?: 'user'; + username?: string; + display_name?: string; + pfp_url?: string; + custody_address?: string; + profile?: { + bio: { + text: string; + mentioned_profiles?: Array<{ + object: 'user_dehydrated'; + fid: number; + username: string; + display_name: string; + pfp_url: string; + custody_address: string; + }>; + mentioned_profiles_ranges?: Array<{ + start: number; + end: number; + }>; + }; + location?: { + latitude: number; + longitude: number; + address: { + city: string; + state: string; + country: string; + country_code: string; + }; + }; + }; + follower_count?: number; + following_count?: number; + verifications?: string[]; + verified_addresses?: { + eth_addresses: string[]; + sol_addresses: string[]; + primary: { + eth_address: string; + sol_address: string; + }; + }; + verified_accounts?: Array>; + power_badge?: boolean; + url?: string; + experimental?: { + neynar_user_score: number; + deprecation_notice: string; + }; + score?: number; + }; + signers?: { + object: 'signer'; + signer_uuid: string; + public_key: string; + status: 'approved'; + fid: number; + }[]; + } + + interface User { + provider?: string; + signers?: Array<{ + object: 'signer'; + signer_uuid: string; + public_key: string; + status: 'approved'; + fid: number; + }>; + user?: { + object: 'user'; + fid: number; + username: string; + display_name: string; + pfp_url: string; + custody_address: string; + profile: { + bio: { + text: string; + mentioned_profiles?: Array<{ + object: 'user_dehydrated'; + fid: number; + username: string; + display_name: string; + pfp_url: string; + custody_address: string; + }>; + mentioned_profiles_ranges?: Array<{ + start: number; + end: number; + }>; + }; + location?: { + latitude: number; + longitude: number; + address: { + city: string; + state: string; + country: string; + country_code: string; + }; + }; + }; + follower_count: number; + following_count: number; + verifications: string[]; + verified_addresses: { + eth_addresses: string[]; + sol_addresses: string[]; + primary: { + eth_address: string; + sol_address: string; + }; + }; + verified_accounts: Array>; + power_badge: boolean; + url?: string; + experimental?: { + neynar_user_score: number; + deprecation_notice: string; + }; + score: number; + }; + } + + interface JWT { + provider?: string; + signers?: Array<{ + object: 'signer'; + signer_uuid: string; + public_key: string; + status: 'approved'; + fid: number; + }>; + user?: { + object: 'user'; + fid: number; + username: string; + display_name: string; + pfp_url: string; + custody_address: string; + profile: { + bio: { + text: string; + mentioned_profiles?: Array<{ + object: 'user_dehydrated'; + fid: number; + username: string; + display_name: string; + pfp_url: string; + custody_address: string; + }>; + mentioned_profiles_ranges?: Array<{ + start: number; + end: number; + }>; + }; + location?: { + latitude: number; + longitude: number; + address: { + city: string; + state: string; + country: string; + country_code: string; + }; + }; + }; + follower_count: number; + following_count: number; + verifications: string[]; + verified_addresses: { + eth_addresses: string[]; + sol_addresses: string[]; + primary: { + eth_address: string; + sol_address: string; + }; + }; + verified_accounts?: Array>; + power_badge?: boolean; + url?: string; + experimental?: { + neynar_user_score: number; + deprecation_notice: string; + }; + score?: number; }; } } @@ -29,6 +218,7 @@ export const authOptions: AuthOptions = { // Configure one or more authentication providers providers: [ CredentialsProvider({ + id: 'farcaster', name: 'Sign in with Farcaster', credentials: { message: { @@ -41,6 +231,11 @@ export const authOptions: AuthOptions = { type: 'text', placeholder: '0x0', }, + nonce: { + label: 'Nonce', + type: 'text', + placeholder: 'Custom nonce (optional)', + }, // In a production app with a server, these should be fetched from // your Farcaster data indexer rather than have them accepted as part // of credentials. @@ -57,12 +252,12 @@ export const authOptions: AuthOptions = { }, }, async authorize(credentials, req) { - const csrfToken = req?.body?.csrfToken; - if (!csrfToken) { - console.error('CSRF token is missing from request'); + const nonce = req?.body?.csrfToken; + + if (!nonce) { + console.error('No nonce or CSRF token provided'); return null; } - const appClient = createAppClient({ ethereum: viemConnector(), }); @@ -73,8 +268,9 @@ export const authOptions: AuthOptions = { message: credentials?.message as string, signature: credentials?.signature as `0x${string}`, domain, - nonce: csrfToken, + nonce, }); + const { success, fid } = verifyResponse; if (!success) { @@ -83,21 +279,129 @@ export const authOptions: AuthOptions = { return { id: fid.toString(), + name: credentials?.name || `User ${fid}`, + image: credentials?.pfp || null, + provider: 'farcaster', }; }, }), + CredentialsProvider({ + id: 'neynar', + name: 'Sign in with Neynar', + credentials: { + message: { + label: 'Message', + type: 'text', + placeholder: '0x0', + }, + signature: { + label: 'Signature', + type: 'text', + placeholder: '0x0', + }, + nonce: { + label: 'Nonce', + type: 'text', + placeholder: 'Custom nonce (optional)', + }, + fid: { + label: 'FID', + type: 'text', + placeholder: '0', + }, + signers: { + label: 'Signers', + type: 'text', + placeholder: 'JSON string of signers', + }, + user: { + label: 'User Data', + type: 'text', + placeholder: 'JSON string of user data', + }, + }, + async authorize(credentials) { + const nonce = credentials?.nonce; + + if (!nonce) { + console.error('No nonce or CSRF token provided for Neynar auth'); + return null; + } + + // For Neynar, we can use a different validation approach + // This could involve validating against Neynar's API or using their SDK + try { + // Validate the signature using Farcaster's auth client (same as Farcaster provider) + const appClient = createAppClient({ + ethereum: viemConnector(), + }); + + const domain = getDomainFromUrl(process.env.NEXTAUTH_URL); + + const verifyResponse = await appClient.verifySignInMessage({ + message: credentials?.message as string, + signature: credentials?.signature as `0x${string}`, + domain, + nonce, + }); + + const { success, fid } = verifyResponse; + + if (!success) { + return null; + } + + // Validate that the provided FID matches the verified FID + if (credentials?.fid && parseInt(credentials.fid) !== fid) { + console.error('FID mismatch in Neynar auth'); + return null; + } + + return { + id: fid.toString(), + provider: 'neynar', + signers: credentials?.signers + ? JSON.parse(credentials.signers) + : undefined, + user: credentials?.user ? JSON.parse(credentials.user) : undefined, + }; + } catch (error) { + console.error('Error in Neynar auth:', error); + return null; + } + }, + }), ], callbacks: { session: async ({ session, token }) => { - if (session?.user) { - session.user.fid = parseInt(token.sub ?? ''); + // Set provider at the root level + session.provider = token.provider as string; + + if (token.provider === 'farcaster') { + // For Farcaster, simple structure + session.user = { + fid: parseInt(token.sub ?? ''), + }; + } else if (token.provider === 'neynar') { + // For Neynar, use full user data structure from user + session.user = token.user as typeof session.user; + session.signers = token.signers as typeof session.signers; } + return session; }, + jwt: async ({ token, user }) => { + if (user) { + token.provider = user.provider; + token.signers = user.signers; + token.user = user.user; + } + return token; + }, }, cookies: { sessionToken: { - name: 'next-auth.session-token', + name: `next-auth.session-token`, options: { httpOnly: true, sameSite: 'none', @@ -106,7 +410,7 @@ export const authOptions: AuthOptions = { }, }, callbackUrl: { - name: 'next-auth.callback-url', + name: `next-auth.callback-url`, options: { sameSite: 'none', path: '/', @@ -114,7 +418,7 @@ export const authOptions: AuthOptions = { }, }, csrfToken: { - name: 'next-auth.csrf-token', + name: `next-auth.csrf-token`, options: { httpOnly: true, sameSite: 'none', @@ -132,4 +436,4 @@ export const getSession = async () => { console.error('Error getting server session:', error); return null; } -}; +}; \ No newline at end of file diff --git a/src/components/providers/SafeFarcasterSolanaProvider.tsx b/src/components/providers/SafeFarcasterSolanaProvider.tsx index 57b166c..ee6d923 100644 --- a/src/components/providers/SafeFarcasterSolanaProvider.tsx +++ b/src/components/providers/SafeFarcasterSolanaProvider.tsx @@ -1,13 +1,10 @@ -import React, { createContext, useEffect, useState } from 'react'; -import dynamic from 'next/dynamic'; -import { sdk } from '@farcaster/frame-sdk'; +import React, { createContext, useEffect, useState } from "react"; +import dynamic from "next/dynamic"; +import { sdk } from '@farcaster/miniapp-sdk'; const FarcasterSolanaProvider = dynamic( - () => - import('@farcaster/mini-app-solana').then( - mod => mod.FarcasterSolanaProvider, - ), - { ssr: false }, + () => import('@farcaster/mini-app-solana').then(mod => mod.FarcasterSolanaProvider), + { ssr: false } ); type SafeFarcasterSolanaProviderProps = { @@ -15,15 +12,10 @@ type SafeFarcasterSolanaProviderProps = { children: React.ReactNode; }; -const SolanaProviderContext = createContext<{ hasSolanaProvider: boolean }>({ - hasSolanaProvider: false, -}); +const SolanaProviderContext = createContext<{ hasSolanaProvider: boolean }>({ hasSolanaProvider: false }); -export function SafeFarcasterSolanaProvider({ - endpoint, - children, -}: SafeFarcasterSolanaProviderProps) { - const isClient = typeof window !== 'undefined'; +export function SafeFarcasterSolanaProvider({ endpoint, children }: SafeFarcasterSolanaProviderProps) { + const isClient = typeof window !== "undefined"; const [hasSolanaProvider, setHasSolanaProvider] = useState(false); const [checked, setChecked] = useState(false); @@ -56,8 +48,8 @@ export function SafeFarcasterSolanaProvider({ const origError = console.error; console.error = (...args) => { if ( - typeof args[0] === 'string' && - args[0].includes('WalletConnectionError: could not get Solana provider') + typeof args[0] === "string" && + args[0].includes("WalletConnectionError: could not get Solana provider") ) { if (!errorShown) { origError(...args); @@ -91,4 +83,4 @@ export function SafeFarcasterSolanaProvider({ export function useHasSolanaProvider() { return React.useContext(SolanaProviderContext).hasSolanaProvider; -} +} \ No newline at end of file diff --git a/src/components/providers/WagmiProvider.tsx b/src/components/providers/WagmiProvider.tsx index aef7628..7f5e9c8 100644 --- a/src/components/providers/WagmiProvider.tsx +++ b/src/components/providers/WagmiProvider.tsx @@ -1,12 +1,12 @@ -import { useEffect, useState } from 'react'; -import React from 'react'; -import { farcasterFrame } from '@farcaster/frame-wagmi-connector'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { createConfig, http, WagmiProvider } from 'wagmi'; -import { useConnect, useAccount } from 'wagmi'; -import { base, degen, mainnet, optimism, unichain, celo } from 'wagmi/chains'; +import { createConfig, http, WagmiProvider } from "wagmi"; +import { base, degen, mainnet, optimism, unichain, celo } from "wagmi/chains"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { farcasterFrame } from "@farcaster/miniapp-wagmi-connector"; import { coinbaseWallet, metaMask } from 'wagmi/connectors'; -import { APP_NAME, APP_ICON_URL, APP_URL } from '~/lib/constants'; +import { APP_NAME, APP_ICON_URL, APP_URL } from "~/lib/constants"; +import { useEffect, useState } from "react"; +import { useConnect, useAccount } from "wagmi"; +import React from "react"; // Custom hook for Coinbase Wallet detection and auto-connection function useCoinbaseWalletAutoConnect() { @@ -17,16 +17,15 @@ function useCoinbaseWalletAutoConnect() { useEffect(() => { // Check if we're running in Coinbase Wallet const checkCoinbaseWallet = () => { - const isInCoinbaseWallet = - window.ethereum?.isCoinbaseWallet || + const isInCoinbaseWallet = window.ethereum?.isCoinbaseWallet || window.ethereum?.isCoinbaseWalletExtension || window.ethereum?.isCoinbaseWalletBrowser; setIsCoinbaseWallet(!!isInCoinbaseWallet); }; - + checkCoinbaseWallet(); window.addEventListener('ethereum#initialized', checkCoinbaseWallet); - + return () => { window.removeEventListener('ethereum#initialized', checkCoinbaseWallet); }; @@ -71,11 +70,7 @@ export const config = createConfig({ const queryClient = new QueryClient(); // Wrapper component that provides Coinbase Wallet auto-connection -function CoinbaseWalletAutoConnect({ - children, -}: { - children: React.ReactNode; -}) { +function CoinbaseWalletAutoConnect({ children }: { children: React.ReactNode }) { useCoinbaseWalletAutoConnect(); return <>{children}; } @@ -84,8 +79,10 @@ export default function Provider({ children }: { children: React.ReactNode }) { return ( - {children} + + {children} + ); -} +} \ No newline at end of file diff --git a/src/components/ui/Header.tsx b/src/components/ui/Header.tsx index 942fa20..eee6c22 100644 --- a/src/components/ui/Header.tsx +++ b/src/components/ui/Header.tsx @@ -1,9 +1,9 @@ -'use client'; +"use client"; -import { useState } from 'react'; -import sdk from '@farcaster/frame-sdk'; -import { useMiniApp } from '@neynar/react'; -import { APP_NAME } from '~/lib/constants'; +import { useState } from "react"; +import { APP_NAME } from "~/lib/constants"; +import sdk from "@farcaster/miniapp-sdk"; +import { useMiniApp } from "@neynar/react"; type HeaderProps = { neynarUser?: { @@ -18,19 +18,23 @@ export function Header({ neynarUser }: HeaderProps) { return (
-
-
Welcome to {APP_NAME}!
+
+
+ Welcome to {APP_NAME}! +
{context?.user && ( -
{ setIsUserDropdownOpen(!isUserDropdownOpen); }} > {context.user.pfpUrl && ( - Profile )} @@ -38,16 +42,14 @@ export function Header({ neynarUser }: HeaderProps) { )}
{context?.user && ( - <> + <> {isUserDropdownOpen && (
-

- sdk.actions.viewProfile({ fid: context.user.fid }) - } + onClick={() => sdk.actions.viewProfile({ fid: context.user.fid })} > {context.user.displayName || context.user.username}

@@ -72,4 +74,4 @@ export function Header({ neynarUser }: HeaderProps) { )}
); -} +} \ No newline at end of file diff --git a/src/components/ui/NeynarAuthButton/AuthDialog.tsx b/src/components/ui/NeynarAuthButton/AuthDialog.tsx new file mode 100644 index 0000000..a458ab4 --- /dev/null +++ b/src/components/ui/NeynarAuthButton/AuthDialog.tsx @@ -0,0 +1,221 @@ +'use client'; + +export function AuthDialog({ + open, + onClose, + url, + isError, + error, + step, + isLoading, + signerApprovalUrl, +}: { + open: boolean; + onClose: () => void; + url?: string; + isError: boolean; + error?: Error | null; + step: 'signin' | 'access' | 'loading'; + isLoading?: boolean; + signerApprovalUrl?: string | null; +}) { + if (!open) return null; + + const getStepContent = () => { + switch (step) { + case 'signin': + return { + title: 'Sign in', + description: + "To sign in, scan the code below with your phone's camera.", + showQR: true, + qrUrl: url, + showOpenButton: true, + }; + + case 'loading': + return { + title: 'Setting up access...', + description: + 'Checking your account permissions and setting up secure access.', + showQR: false, + qrUrl: '', + showOpenButton: false, + }; + + case 'access': + return { + title: 'Grant Access', + description: ( +
+

+ Allow this app to access your Farcaster account: +

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

+ {isError ? 'Error' : content.title} +

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

+ {content.description} +

+ ) : ( + content.description + )} +
+ +
+ {content.showQR && content.qrUrl ? ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + QR Code +
+ ) : step === 'loading' || isLoading ? ( +
+
+
+ + {step === 'loading' + ? 'Setting up access...' + : 'Loading...'} + +
+
+ ) : null} +
+ + {content.showOpenButton && content.qrUrl && ( + + )} +
+ )} +
+
+
+ ); +} diff --git a/src/components/ui/NeynarAuthButton/ProfileButton.tsx b/src/components/ui/NeynarAuthButton/ProfileButton.tsx new file mode 100644 index 0000000..bcf1ca7 --- /dev/null +++ b/src/components/ui/NeynarAuthButton/ProfileButton.tsx @@ -0,0 +1,92 @@ +'use client'; + +import { useRef, useState } from 'react'; +import { useDetectClickOutside } from '~/hooks/useDetectClickOutside'; +import { cn } from '~/lib/utils'; + +export function ProfileButton({ + userData, + onSignOut, +}: { + userData?: { fid?: number; pfpUrl?: string; username?: string }; + onSignOut: () => void; +}) { + const [showDropdown, setShowDropdown] = useState(false); + const ref = useRef(null); + + useDetectClickOutside(ref, () => setShowDropdown(false)); + + const name = userData?.username ?? `!${userData?.fid}`; + const pfpUrl = userData?.pfpUrl ?? 'https://farcaster.xyz/avatar.png'; + + return ( +
+ + + {showDropdown && ( +
+ +
+ )} +
+ ); +} diff --git a/src/components/ui/NeynarAuthButton/index.tsx b/src/components/ui/NeynarAuthButton/index.tsx new file mode 100644 index 0000000..8d96711 --- /dev/null +++ b/src/components/ui/NeynarAuthButton/index.tsx @@ -0,0 +1,705 @@ +'use client'; + +import '@farcaster/auth-kit/styles.css'; +import { useSignIn, UseSignInData } from '@farcaster/auth-kit'; +import { useCallback, useEffect, useState, useRef } from 'react'; +import { cn } from '~/lib/utils'; +import { Button } from '~/components/ui/Button'; +import { ProfileButton } from '~/components/ui/NeynarAuthButton/ProfileButton'; +import { AuthDialog } from '~/components/ui/NeynarAuthButton/AuthDialog'; +import { getItem, removeItem, setItem } from '~/lib/localStorage'; +import { useMiniApp } from '@neynar/react'; +import { + signIn as backendSignIn, + signOut as backendSignOut, + useSession, +} from 'next-auth/react'; +import sdk, { SignIn as SignInCore } from '@farcaster/frame-sdk'; + +type User = { + fid: number; + username: string; + display_name: string; + pfp_url: string; + // Add other user properties as needed +}; + +const STORAGE_KEY = 'neynar_authenticated_user'; +const FARCASTER_FID = 9152; + +interface StoredAuthState { + isAuthenticated: boolean; + user: { + object: 'user'; + fid: number; + username: string; + display_name: string; + pfp_url: string; + custody_address: string; + profile: { + bio: { + text: string; + mentioned_profiles?: Array<{ + object: 'user_dehydrated'; + fid: number; + username: string; + display_name: string; + pfp_url: string; + custody_address: string; + }>; + mentioned_profiles_ranges?: Array<{ + start: number; + end: number; + }>; + }; + location?: { + latitude: number; + longitude: number; + address: { + city: string; + state: string; + country: string; + country_code: string; + }; + }; + }; + follower_count: number; + following_count: number; + verifications: string[]; + verified_addresses: { + eth_addresses: string[]; + sol_addresses: string[]; + primary: { + eth_address: string; + sol_address: string; + }; + }; + verified_accounts: Array>; + power_badge: boolean; + url?: string; + experimental?: { + neynar_user_score: number; + deprecation_notice: string; + }; + score: number; + } | null; + signers: { + object: 'signer'; + signer_uuid: string; + public_key: string; + status: 'approved'; + fid: number; + }[]; +} + +// Main Custom SignInButton Component +export function NeynarAuthButton() { + const [nonce, setNonce] = useState(null); + const [storedAuth, setStoredAuth] = useState(null); + const [signersLoading, setSignersLoading] = useState(false); + const { context } = useMiniApp(); + const { data: session } = useSession(); + // New state for unified dialog flow + const [showDialog, setShowDialog] = useState(false); + const [dialogStep, setDialogStep] = useState<'signin' | 'access' | 'loading'>( + 'loading' + ); + const [signerApprovalUrl, setSignerApprovalUrl] = useState( + null + ); + const [pollingInterval, setPollingInterval] = useState( + null + ); + const [message, setMessage] = useState(null); + const [signature, setSignature] = useState(null); + const [isSignerFlowRunning, setIsSignerFlowRunning] = useState(false); + const signerFlowStartedRef = useRef(false); + + // Determine which flow to use based on context + const useBackendFlow = context !== undefined; + + // Helper function to create a signer + const createSigner = useCallback(async () => { + try { + const response = await fetch('/api/auth/signer', { + method: 'POST', + }); + + if (!response.ok) { + throw new Error('Failed to create signer'); + } + + const signerData = await response.json(); + return signerData; + } catch (error) { + console.error('āŒ Error creating signer:', error); + // throw error; + } + }, []); + + // Helper function to update session with signers (backend flow only) + const updateSessionWithSigners = useCallback( + async ( + signers: StoredAuthState['signers'], + user: StoredAuthState['user'] + ) => { + if (!useBackendFlow) return; + + try { + // For backend flow, we need to sign in again with the additional data + if (message && signature) { + const signInData = { + message, + signature, + redirect: false, + nonce: nonce || '', + fid: user?.fid?.toString() || '', + signers: JSON.stringify(signers), + user: JSON.stringify(user), + }; + + await backendSignIn('neynar', signInData); + } + } catch (error) { + console.error('āŒ Error updating session with signers:', error); + } + }, + [useBackendFlow, message, signature, nonce] + ); + + // Helper function to fetch user data from Neynar API + const fetchUserData = useCallback( + async (fid: number): Promise => { + try { + const response = await fetch(`/api/users?fids=${fid}`); + if (response.ok) { + const data = await response.json(); + return data.users?.[0] || null; + } + return null; + } catch (error) { + console.error('Error fetching user data:', error); + return null; + } + }, + [] + ); + + // Helper function to generate signed key request + const generateSignedKeyRequest = useCallback( + async (signerUuid: string, publicKey: string) => { + try { + // Prepare request body + const requestBody: { + signerUuid: string; + publicKey: string; + sponsor?: { sponsored_by_neynar: boolean }; + } = { + signerUuid, + publicKey, + }; + + const response = await fetch('/api/auth/signer/signed_key', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error( + `Failed to generate signed key request: ${errorData.error}` + ); + } + + const data = await response.json(); + + return data; + } catch (error) { + console.error('āŒ Error generating signed key request:', error); + // throw error; + } + }, + [] + ); + + // Helper function to fetch all signers + const fetchAllSigners = useCallback( + async (message: string, signature: string) => { + try { + setSignersLoading(true); + + const endpoint = useBackendFlow + ? `/api/auth/session-signers?message=${encodeURIComponent( + message + )}&signature=${signature}` + : `/api/auth/signers?message=${encodeURIComponent( + message + )}&signature=${signature}`; + + const response = await fetch(endpoint); + const signerData = await response.json(); + + if (response.ok) { + if (useBackendFlow) { + // For backend flow, update session with signers + if (signerData.signers && signerData.signers.length > 0) { + const user = + signerData.user || + (await fetchUserData(signerData.signers[0].fid)); + await updateSessionWithSigners(signerData.signers, user); + } + return signerData.signers; + } else { + // For frontend flow, store in localStorage + let user: StoredAuthState['user'] | null = null; + + if (signerData.signers && signerData.signers.length > 0) { + const fetchedUser = (await fetchUserData( + signerData.signers[0].fid + )) as StoredAuthState['user']; + user = fetchedUser; + } + + // Store signers in localStorage, preserving existing auth data + const updatedState: StoredAuthState = { + isAuthenticated: !!user, + signers: signerData.signers || [], + user, + }; + setItem(STORAGE_KEY, updatedState); + setStoredAuth(updatedState); + + return signerData.signers; + } + } else { + console.error('āŒ Failed to fetch signers'); + // throw new Error('Failed to fetch signers'); + } + } catch (error) { + console.error('āŒ Error fetching signers:', error); + // throw error; + } finally { + setSignersLoading(false); + } + }, + [useBackendFlow, fetchUserData, updateSessionWithSigners] + ); + + // Helper function to poll signer status + const startPolling = useCallback( + (signerUuid: string, message: string, signature: string) => { + // Clear any existing polling interval before starting a new one + if (pollingInterval) { + clearInterval(pollingInterval); + } + + let retryCount = 0; + const maxRetries = 10; // Maximum 10 retries (20 seconds total) + const maxPollingTime = 60000; // Maximum 60 seconds of polling + const startTime = Date.now(); + + const interval = setInterval(async () => { + // Check if we've been polling too long + if (Date.now() - startTime > maxPollingTime) { + clearInterval(interval); + setPollingInterval(null); + return; + } + + try { + const response = await fetch( + `/api/auth/signer?signerUuid=${signerUuid}` + ); + + if (!response.ok) { + // Check if it's a rate limit error + if (response.status === 429) { + clearInterval(interval); + setPollingInterval(null); + return; + } + + // Increment retry count for other errors + retryCount++; + if (retryCount >= maxRetries) { + clearInterval(interval); + setPollingInterval(null); + return; + } + + throw new Error(`Failed to poll signer status: ${response.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, pollingInterval] + ); + + // Cleanup polling on unmount + useEffect(() => { + return () => { + if (pollingInterval) { + clearInterval(pollingInterval); + } + signerFlowStartedRef.current = false; + }; + }, [pollingInterval]); + + // Generate nonce + useEffect(() => { + const generateNonce = async () => { + try { + const response = await fetch('/api/auth/nonce'); + if (response.ok) { + const data = await response.json(); + setNonce(data.nonce); + } else { + console.error('Failed to fetch nonce'); + } + } catch (error) { + console.error('Error generating nonce:', error); + } + }; + + generateNonce(); + }, []); + + // Load stored auth state on mount (only for frontend flow) + useEffect(() => { + if (!useBackendFlow) { + const stored = getItem(STORAGE_KEY); + if (stored && stored.isAuthenticated) { + setStoredAuth(stored); + } + } + }, [useBackendFlow]); + + // Success callback - this is critical! + const onSuccessCallback = useCallback( + async (res: UseSignInData) => { + if (!useBackendFlow) { + // Only handle localStorage for frontend flow + const existingAuth = getItem(STORAGE_KEY); + const user = res.fid ? await fetchUserData(res.fid) : null; + const authState: StoredAuthState = { + ...existingAuth, + isAuthenticated: true, + user: user as StoredAuthState['user'], + signers: existingAuth?.signers || [], // Preserve existing signers + }; + setItem(STORAGE_KEY, authState); + setStoredAuth(authState); + } + // For backend flow, the session will be handled by NextAuth + }, + [useBackendFlow, fetchUserData] + ); + + // Error callback + const onErrorCallback = useCallback((error?: Error | null) => { + console.error('āŒ Sign in error:', error); + }, []); + + const signInState = useSignIn({ + nonce: nonce || undefined, + onSuccess: onSuccessCallback, + onError: onErrorCallback, + }); + + const { + signIn: frontendSignIn, + signOut: frontendSignOut, + connect, + reconnect, + isSuccess, + isError, + error, + channelToken, + url, + data, + validSignature, + } = signInState; + + useEffect(() => { + setMessage(data?.message || null); + setSignature(data?.signature || null); + + // Reset the signer flow flag when message/signature change + if (data?.message && data?.signature) { + signerFlowStartedRef.current = false; + } + }, [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 && !isSignerFlowRunning && !signerFlowStartedRef.current) { + signerFlowStartedRef.current = true; + + const handleSignerFlow = async () => { + setIsSignerFlowRunning(true); + try { + const clientContext = context?.client as Record; + const isMobileContext = + clientContext?.platformType === 'mobile' && + clientContext?.clientFid === FARCASTER_FID; + + // Step 1: Change to loading state + setDialogStep('loading'); + + // Show dialog if not using backend flow or in browser farcaster + if ((useBackendFlow && !isMobileContext) || !useBackendFlow) + setShowDialog(true); + + // First, fetch existing signers + const signers = await fetchAllSigners(message, signature); + + if (useBackendFlow && isMobileContext) setSignersLoading(true); + + // Check if no signers exist or if we have empty signers + if (!signers || signers.length === 0) { + // Step 1: Create a signer + const newSigner = await createSigner(); + + // Step 2: Generate signed key request + const signedKeyData = await generateSignedKeyRequest( + newSigner.signer_uuid, + newSigner.public_key + ); + + // Step 3: Show QR code in access dialog for signer approval + setSignerApprovalUrl(signedKeyData.signer_approval_url); + + if (isMobileContext) { + setShowDialog(false); + await sdk.actions.openUrl( + signedKeyData.signer_approval_url.replace( + 'https://client.farcaster.xyz/deeplinks/signed-key-request', + 'https://farcaster.xyz/~/connect' + ) + ); + } 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); + } finally { + setIsSignerFlowRunning(false); + } + }; + + handleSignerFlow(); + } + }, [message, signature]); // Simplified dependencies + + // 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); + } + + // Reset signer flow flag + signerFlowStartedRef.current = false; + } catch (error) { + console.error('āŒ Error during sign out:', error); + // Optionally handle error state + } finally { + setSignersLoading(false); + } + }, [useBackendFlow, frontendSignOut, pollingInterval, session]); + + const authenticated = useBackendFlow + ? !!( + session?.provider === 'neynar' && + session?.user?.fid && + session?.signers && + session.signers.length > 0 + ) + : ((isSuccess && validSignature) || storedAuth?.isAuthenticated) && + !!(storedAuth?.signers && storedAuth.signers.length > 0); + + const userData = useBackendFlow + ? { + fid: session?.user?.fid, + username: session?.user?.username || '', + pfpUrl: session?.user?.pfp_url || '', + } + : { + fid: storedAuth?.user?.fid, + username: storedAuth?.user?.username || '', + pfpUrl: storedAuth?.user?.pfp_url || '', + }; + + // Show loading state while nonce is being fetched or signers are loading + if (!nonce || signersLoading) { + return ( +
+
+
+ + Loading... + +
+
+ ); + } + + return ( + <> + {authenticated ? ( + + ) : ( + + )} + + {/* Unified Auth Dialog */} + { + { + setShowDialog(false); + setDialogStep('signin'); + setSignerApprovalUrl(null); + if (pollingInterval) { + clearInterval(pollingInterval); + setPollingInterval(null); + } + }} + url={url} + isError={isError} + error={error} + step={dialogStep} + isLoading={signersLoading} + signerApprovalUrl={signerApprovalUrl} + /> + } + + ); +} diff --git a/src/components/ui/Share.tsx b/src/components/ui/Share.tsx index f276d23..526a255 100644 --- a/src/components/ui/Share.tsx +++ b/src/components/ui/Share.tsx @@ -1,9 +1,9 @@ 'use client'; import { useCallback, useState, useEffect } from 'react'; -import { type ComposeCast } from '@farcaster/frame-sdk'; -import { useMiniApp } from '@neynar/react'; import { Button } from './Button'; +import { useMiniApp } from '@neynar/react'; +import { type ComposeCast } from "@farcaster/miniapp-sdk"; interface EmbedConfig { path?: string; @@ -23,16 +23,9 @@ interface ShareButtonProps { isLoading?: boolean; } -export function ShareButton({ - buttonText, - cast, - className = '', - isLoading = false, -}: ShareButtonProps) { +export function ShareButton({ buttonText, cast, className = '', isLoading = false }: ShareButtonProps) { const [isProcessing, setIsProcessing] = useState(false); - const [bestFriends, setBestFriends] = useState< - { fid: number; username: string }[] | null - >(null); + const [bestFriends, setBestFriends] = useState<{ fid: number; username: string; }[] | null>(null); const [isLoadingBestFriends, setIsLoadingBestFriends] = useState(false); const { context, actions } = useMiniApp(); @@ -58,7 +51,7 @@ export function ShareButton({ if (cast.bestFriends) { if (bestFriends) { // Replace @N with usernames, or remove if no matching friend - finalText = finalText.replace(/@\d+/g, match => { + finalText = finalText.replace(/@\d+/g, (match) => { const friendIndex = parseInt(match.slice(1)) - 1; const friend = bestFriends[friendIndex]; if (friend) { @@ -74,20 +67,16 @@ export function ShareButton({ // Process embeds const processedEmbeds = await Promise.all( - (cast.embeds || []).map(async embed => { + (cast.embeds || []).map(async (embed) => { if (typeof embed === 'string') { return embed; } if (embed.path) { - const baseUrl = - process.env.NEXT_PUBLIC_URL || window.location.origin; + const baseUrl = process.env.NEXT_PUBLIC_URL || window.location.origin; const url = new URL(`${baseUrl}${embed.path}`); // Add UTM parameters - url.searchParams.set( - 'utm_source', - `share-cast-${context?.user?.fid || 'unknown'}`, - ); + url.searchParams.set('utm_source', `share-cast-${context?.user?.fid || 'unknown'}`); // If custom image generator is provided, use it if (embed.imageUrl) { @@ -98,7 +87,7 @@ export function ShareButton({ return url.toString(); } return embed.url || ''; - }), + }) ); // Open cast composer with all supported intents @@ -126,4 +115,4 @@ export function ShareButton({ {buttonText} ); -} +} \ No newline at end of file diff --git a/src/components/ui/tabs/ActionsTab.tsx b/src/components/ui/tabs/ActionsTab.tsx index af73f41..44cf94d 100644 --- a/src/components/ui/tabs/ActionsTab.tsx +++ b/src/components/ui/tabs/ActionsTab.tsx @@ -1,11 +1,12 @@ 'use client'; -import { useCallback, useState } from 'react'; -import { type Haptics } from '@farcaster/frame-sdk'; -import { useMiniApp } from '@neynar/react'; -import { Button } from '../Button'; -import { ShareButton } from '../Share'; -import { SignIn } from '../wallet/SignIn'; +import { useCallback, useState } from "react"; +import { useMiniApp } from "@neynar/react"; +import { ShareButton } from "../Share"; +import { Button } from "../Button"; +import { SignIn } from "../wallet/SignIn"; +import { type Haptics } from "@farcaster/miniapp-sdk"; +import { NeynarAuthButton } from '../NeynarAuthButton/index'; /** * ActionsTab component handles mini app actions like sharing, notifications, and haptic feedback. @@ -50,7 +51,7 @@ export function ActionsTab() { * @returns Promise that resolves when the notification is sent or fails */ const sendFarcasterNotification = useCallback(async () => { - setNotificationState(prev => ({ ...prev, sendStatus: '' })); + setNotificationState((prev) => ({ ...prev, sendStatus: '' })); if (!notificationDetails || !context) { return; } @@ -65,19 +66,22 @@ export function ActionsTab() { }), }); if (response.status === 200) { - setNotificationState(prev => ({ ...prev, sendStatus: 'Success' })); + setNotificationState((prev) => ({ ...prev, sendStatus: 'Success' })); return; } else if (response.status === 429) { - setNotificationState(prev => ({ ...prev, sendStatus: 'Rate limited' })); + setNotificationState((prev) => ({ + ...prev, + sendStatus: 'Rate limited', + })); return; } const responseText = await response.text(); - setNotificationState(prev => ({ + setNotificationState((prev) => ({ ...prev, sendStatus: `Error: ${responseText}`, })); } catch (error) { - setNotificationState(prev => ({ + setNotificationState((prev) => ({ ...prev, sendStatus: `Error: ${error}`, })); @@ -94,11 +98,11 @@ export function ActionsTab() { if (context?.user?.fid) { const userShareUrl = `${process.env.NEXT_PUBLIC_URL}/share/${context.user.fid}`; await navigator.clipboard.writeText(userShareUrl); - setNotificationState(prev => ({ ...prev, shareUrlCopied: true })); + setNotificationState((prev) => ({ ...prev, shareUrlCopied: true })); setTimeout( () => - setNotificationState(prev => ({ ...prev, shareUrlCopied: false })), - 2000, + setNotificationState((prev) => ({ ...prev, shareUrlCopied: false })), + 2000 ); } }, [context?.user?.fid]); @@ -119,10 +123,10 @@ export function ActionsTab() { // --- Render --- return ( -
+
{/* Share functionality */} {/* Authentication */} + {/* Neynar Authentication */} + + {/* Mini app actions */} - {/* Notification functionality */} {notificationState.sendStatus && ( -
+
Send notification result: {notificationState.sendStatus}
)} @@ -168,24 +175,24 @@ export function ActionsTab() { {/* Haptic feedback controls */} -
-
); -} +} \ No newline at end of file diff --git a/src/components/ui/tabs/HomeTab.tsx b/src/components/ui/tabs/HomeTab.tsx index ca536e6..b1d0c94 100644 --- a/src/components/ui/tabs/HomeTab.tsx +++ b/src/components/ui/tabs/HomeTab.tsx @@ -17,7 +17,7 @@ export function HomeTab() {

Put your content here!

-

Powered by Neynar 🪐

+

Powered by Neynar 🪐

); diff --git a/src/components/ui/wallet/SignIn.tsx b/src/components/ui/wallet/SignIn.tsx index 4e7c69f..46d3c33 100644 --- a/src/components/ui/wallet/SignIn.tsx +++ b/src/components/ui/wallet/SignIn.tsx @@ -1,10 +1,10 @@ 'use client'; -import { useCallback, useState } from 'react'; -import sdk, { SignIn as SignInCore } from '@farcaster/frame-sdk'; -import { signIn, signOut, getCsrfToken } from 'next-auth/react'; -import { useSession } from 'next-auth/react'; -import { Button } from '../Button'; +import { useCallback, useState } from "react"; +import { signIn, signOut, getCsrfToken } from "next-auth/react"; +import sdk, { SignIn as SignInCore } from "@farcaster/miniapp-sdk"; +import { useSession } from "next-auth/react"; +import { Button } from "../Button"; /** * SignIn component handles Farcaster authentication using Sign-In with Farcaster (SIWF). @@ -72,12 +72,12 @@ export function SignIn() { */ const handleSignIn = useCallback(async () => { try { - setAuthState(prev => ({ ...prev, signingIn: true })); + setAuthState((prev) => ({ ...prev, signingIn: true })); setSignInFailure(undefined); const nonce = await getNonce(); const result = await sdk.actions.signIn({ nonce }); setSignInResult(result); - await signIn('credentials', { + await signIn('farcaster', { message: result.message, signature: result.signature, redirect: false, @@ -89,38 +89,41 @@ export function SignIn() { } setSignInFailure('Unknown error'); } finally { - setAuthState(prev => ({ ...prev, signingIn: false })); + setAuthState((prev) => ({ ...prev, signingIn: false })); } }, [getNonce]); /** * Handles the sign-out process. * - * This function clears the NextAuth session and resets the local - * sign-in result state to complete the sign-out flow. + * This function clears the NextAuth session only if the current session + * is using the Farcaster provider, and resets the local sign-in result state. * * @returns Promise */ const handleSignOut = useCallback(async () => { try { - setAuthState(prev => ({ ...prev, signingOut: true })); - await signOut({ redirect: false }); + setAuthState((prev) => ({ ...prev, signingOut: true })); + // Only sign out if the current session is from Farcaster provider + if (session?.provider === 'farcaster') { + await signOut({ redirect: false }); + } setSignInResult(undefined); } finally { - setAuthState(prev => ({ ...prev, signingOut: false })); + setAuthState((prev) => ({ ...prev, signingOut: false })); } - }, []); + }, [session]); // --- Render --- return ( <> {/* Authentication Buttons */} - {status !== 'authenticated' && ( + {(status !== 'authenticated' || session?.provider !== 'farcaster') && ( )} - {status === 'authenticated' && ( + {status === 'authenticated' && session?.provider === 'farcaster' && ( @@ -128,9 +131,9 @@ export function SignIn() { {/* Session Information */} {session && ( -
-
Session
-
+
+
Session
+
{JSON.stringify(session, null, 2)}
@@ -138,21 +141,21 @@ export function SignIn() { {/* Error Display */} {signInFailure && !authState.signingIn && ( -
-
SIWF Result
-
{signInFailure}
+
+
SIWF Result
+
{signInFailure}
)} {/* Success Result Display */} {signInResult && !authState.signingIn && ( -
-
SIWF Result
-
+
+
SIWF Result
+
{JSON.stringify(signInResult, null, 2)}
)} ); -} +} \ No newline at end of file diff --git a/src/hooks/useDetectClickOutside.ts b/src/hooks/useDetectClickOutside.ts new file mode 100644 index 0000000..e6b1533 --- /dev/null +++ b/src/hooks/useDetectClickOutside.ts @@ -0,0 +1,18 @@ +import { useEffect } from 'react'; + +export function useDetectClickOutside( + ref: React.RefObject, + callback: () => void +) { + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (ref.current && !ref.current.contains(event.target as Node)) { + callback(); + } + } + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [ref, callback]); +} diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 1eda655..7a7661b 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -20,19 +20,19 @@ export const APP_URL = process.env.NEXT_PUBLIC_URL!; * The name of the mini app as displayed to users. * Used in titles, headers, and app store listings. */ -export const APP_NAME = 'Starter Kit'; +export const APP_NAME = 'shreyas-testing-mini-app'; /** * A brief description of the mini app's functionality. * Used in app store listings and metadata. */ -export const APP_DESCRIPTION = 'A demo of the Neynar Starter Kit'; +export const APP_DESCRIPTION = 'A Farcaster mini app created with Neynar'; /** * The primary category for the mini app. * Used for app store categorization and discovery. */ -export const APP_PRIMARY_CATEGORY = 'developer-tools'; +export const APP_PRIMARY_CATEGORY = ''; /** * Tags associated with the mini app. @@ -70,7 +70,7 @@ export const APP_SPLASH_BACKGROUND_COLOR = '#f7f7f7'; * Text displayed on the main action button. * Used for the primary call-to-action in the mini app. */ -export const APP_BUTTON_TEXT = 'Launch NSK'; +export const APP_BUTTON_TEXT = 'Launch Mini App'; // --- Integration Configuration --- /** @@ -102,3 +102,19 @@ export const USE_WALLET = true; * Useful for privacy-conscious users or development environments. */ export const ANALYTICS_ENABLED = true; + +// PLEASE DO NOT UPDATE THIS +export const SIGNED_KEY_REQUEST_VALIDATOR_EIP_712_DOMAIN = { + name: 'Farcaster SignedKeyRequestValidator', + version: '1', + chainId: 10, + verifyingContract: + '0x00000000fc700472606ed4fa22623acf62c60553' as `0x${string}`, +}; + +// PLEASE DO NOT UPDATE THIS +export const SIGNED_KEY_REQUEST_TYPE = [ + { name: 'requestFid', type: 'uint256' }, + { name: 'key', type: 'bytes' }, + { name: 'deadline', type: 'uint256' }, +]; diff --git a/src/lib/devices.ts b/src/lib/devices.ts new file mode 100644 index 0000000..f6757ec --- /dev/null +++ b/src/lib/devices.ts @@ -0,0 +1,27 @@ +function isAndroid(): boolean { + return ( + typeof navigator !== 'undefined' && /android/i.test(navigator.userAgent) + ); +} + +function isSmallIOS(): boolean { + return ( + typeof navigator !== 'undefined' && /iPhone|iPod/.test(navigator.userAgent) + ); +} + +function isLargeIOS(): boolean { + return ( + typeof navigator !== 'undefined' && + (/iPad/.test(navigator.userAgent) || + (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)) + ); +} + +function isIOS(): boolean { + return isSmallIOS() || isLargeIOS(); +} + +export function isMobile(): boolean { + return isAndroid() || isIOS(); +} diff --git a/src/lib/kv.ts b/src/lib/kv.ts index 9da23e8..f6d1de4 100644 --- a/src/lib/kv.ts +++ b/src/lib/kv.ts @@ -1,25 +1,23 @@ -import { FrameNotificationDetails } from '@farcaster/frame-sdk'; -import { Redis } from '@upstash/redis'; -import { APP_NAME } from './constants'; +import { FrameNotificationDetails } from "@farcaster/miniapp-sdk"; +import { Redis } from "@upstash/redis"; +import { APP_NAME } from "./constants"; // In-memory fallback storage const localStore = new Map(); // Use Redis if KV env vars are present, otherwise use in-memory const useRedis = process.env.KV_REST_API_URL && process.env.KV_REST_API_TOKEN; -const redis = useRedis - ? new Redis({ - url: process.env.KV_REST_API_URL!, - token: process.env.KV_REST_API_TOKEN!, - }) - : null; +const redis = useRedis ? new Redis({ + url: process.env.KV_REST_API_URL!, + token: process.env.KV_REST_API_TOKEN!, +}) : null; function getUserNotificationDetailsKey(fid: number): string { return `${APP_NAME}:user:${fid}`; } export async function getUserNotificationDetails( - fid: number, + fid: number ): Promise { const key = getUserNotificationDetailsKey(fid); if (redis) { @@ -30,7 +28,7 @@ export async function getUserNotificationDetails( export async function setUserNotificationDetails( fid: number, - notificationDetails: FrameNotificationDetails, + notificationDetails: FrameNotificationDetails ): Promise { const key = getUserNotificationDetailsKey(fid); if (redis) { @@ -41,7 +39,7 @@ export async function setUserNotificationDetails( } export async function deleteUserNotificationDetails( - fid: number, + fid: number ): Promise { const key = getUserNotificationDetailsKey(fid); if (redis) { @@ -49,4 +47,4 @@ export async function deleteUserNotificationDetails( } else { localStore.delete(key); } -} +} \ No newline at end of file diff --git a/src/lib/localStorage.ts b/src/lib/localStorage.ts new file mode 100644 index 0000000..0d86b65 --- /dev/null +++ b/src/lib/localStorage.ts @@ -0,0 +1,25 @@ +export function setItem(key: string, value: T) { + try { + localStorage.setItem(key, JSON.stringify(value)); + } catch (error) { + console.warn('Failed to save item:', error); + } +} + +export function getItem(key: string): T | null { + try { + const stored = localStorage.getItem(key); + return stored ? JSON.parse(stored) : null; + } catch (error) { + console.warn('Failed to load item:', error); + return null; + } +} + +export function removeItem(key: string) { + try { + localStorage.removeItem(key); + } catch (error) { + console.warn('Failed to remove item:', error); + } +} diff --git a/src/lib/notifs.ts b/src/lib/notifs.ts index 0098ed5..53adbef 100644 --- a/src/lib/notifs.ts +++ b/src/lib/notifs.ts @@ -1,18 +1,18 @@ import { SendNotificationRequest, sendNotificationResponseSchema, -} from '@farcaster/frame-sdk'; -import { getUserNotificationDetails } from '~/lib/kv'; -import { APP_URL } from './constants'; +} from "@farcaster/miniapp-sdk"; +import { getUserNotificationDetails } from "~/lib/kv"; +import { APP_URL } from "./constants"; type SendMiniAppNotificationResult = | { - state: 'error'; + state: "error"; error: unknown; } - | { state: 'no_token' } - | { state: 'rate_limit' } - | { state: 'success' }; + | { state: "no_token" } + | { state: "rate_limit" } + | { state: "success" }; export async function sendMiniAppNotification({ fid, @@ -25,13 +25,13 @@ export async function sendMiniAppNotification({ }): Promise { const notificationDetails = await getUserNotificationDetails(fid); if (!notificationDetails) { - return { state: 'no_token' }; + return { state: "no_token" }; } const response = await fetch(notificationDetails.url, { - method: 'POST', + method: "POST", headers: { - 'Content-Type': 'application/json', + "Content-Type": "application/json", }, body: JSON.stringify({ notificationId: crypto.randomUUID(), @@ -48,17 +48,17 @@ export async function sendMiniAppNotification({ const responseBody = sendNotificationResponseSchema.safeParse(responseJson); if (responseBody.success === false) { // Malformed response - return { state: 'error', error: responseBody.error.errors }; + return { state: "error", error: responseBody.error.errors }; } if (responseBody.data.result.rateLimitedTokens.length) { // Rate limited - return { state: 'rate_limit' }; + return { state: "rate_limit" }; } - return { state: 'success' }; + return { state: "success" }; } else { // Error response - return { state: 'error', error: responseJson }; + return { state: "error", error: responseJson }; } -} +} \ No newline at end of file diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 793109a..2458ad4 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,6 +1,6 @@ -import { type ClassValue, clsx } from 'clsx'; -import { twMerge } from 'tailwind-merge'; -import { mnemonicToAccount } from 'viem/accounts'; +import { type ClassValue, clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; +import { mnemonicToAccount } from "viem/accounts"; import { APP_BUTTON_TEXT, APP_DESCRIPTION, @@ -12,8 +12,8 @@ import { APP_TAGS, APP_URL, APP_WEBHOOK_URL, -} from './constants'; -import { APP_SPLASH_URL } from './constants'; +} from "./constants"; +import { APP_SPLASH_URL } from "./constants"; interface MiniAppMetadata { version: string; @@ -43,25 +43,14 @@ export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } -export function getSecretEnvVars() { - const seedPhrase = process.env.SEED_PHRASE; - const fid = process.env.FID; - - if (!seedPhrase || !fid) { - return null; - } - - return { seedPhrase, fid }; -} - export function getMiniAppEmbedMetadata(ogImageUrl?: string) { return { - version: 'next', + version: "next", imageUrl: ogImageUrl ?? APP_OG_IMAGE_URL, button: { title: APP_BUTTON_TEXT, action: { - type: 'launch_frame', + type: "launch_frame", name: APP_NAME, url: APP_URL, splashImageUrl: APP_SPLASH_URL, @@ -80,77 +69,37 @@ export async function getFarcasterMetadata(): Promise { if (process.env.MINI_APP_METADATA) { try { const metadata = JSON.parse(process.env.MINI_APP_METADATA); - console.log('Using pre-signed mini app metadata from environment'); + console.log("Using pre-signed mini app metadata from environment"); return metadata; } catch (error) { console.warn( - 'Failed to parse MINI_APP_METADATA from environment:', - error, + "Failed to parse MINI_APP_METADATA from environment:", + error ); } } if (!APP_URL) { - throw new Error('NEXT_PUBLIC_URL not configured'); + throw new Error("NEXT_PUBLIC_URL not configured"); } // Get the domain from the URL (without https:// prefix) const domain = new URL(APP_URL).hostname; - console.log('Using domain for manifest:', domain); - - const secretEnvVars = getSecretEnvVars(); - if (!secretEnvVars) { - console.warn( - 'No seed phrase or FID found in environment variables -- generating unsigned metadata', - ); - } - - let accountAssociation; - if (secretEnvVars) { - // Generate account from seed phrase - const account = mnemonicToAccount(secretEnvVars.seedPhrase); - const custodyAddress = account.address; - - const header = { - fid: parseInt(secretEnvVars.fid), - type: 'custody', - key: custodyAddress, - }; - const encodedHeader = Buffer.from(JSON.stringify(header), 'utf-8').toString( - 'base64', - ); - - const payload = { - domain, - }; - const encodedPayload = Buffer.from( - JSON.stringify(payload), - 'utf-8', - ).toString('base64url'); - - const signature = await account.signMessage({ - message: `${encodedHeader}.${encodedPayload}`, - }); - const encodedSignature = Buffer.from(signature, 'utf-8').toString( - 'base64url', - ); - - accountAssociation = { - header: encodedHeader, - payload: encodedPayload, - signature: encodedSignature, - }; - } + console.log("Using domain for manifest:", domain); return { - accountAssociation, + accountAssociation: { + header: "", + payload: "", + signature: "", + }, frame: { - version: '1', - name: APP_NAME ?? 'Neynar Starter Kit', + version: "1", + name: APP_NAME ?? "Neynar Starter Kit", iconUrl: APP_ICON_URL, homeUrl: APP_URL, imageUrl: APP_OG_IMAGE_URL, - buttonTitle: APP_BUTTON_TEXT ?? 'Launch Mini App', + buttonTitle: APP_BUTTON_TEXT ?? "Launch Mini App", splashImageUrl: APP_SPLASH_URL, splashBackgroundColor: APP_SPLASH_BACKGROUND_COLOR, webhookUrl: APP_WEBHOOK_URL, @@ -159,4 +108,4 @@ export async function getFarcasterMetadata(): Promise { tags: APP_TAGS, }, }; -} +} \ No newline at end of file