Merge branch 'main' into shreyas-formatting

This commit is contained in:
Shreyaschorge 2025-07-14 18:55:06 +05:30
commit 505aa54b16
No known key found for this signature in database
34 changed files with 2479 additions and 829 deletions

30
.github/workflows/publish.yml vendored Normal file
View File

@ -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

View File

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

View File

@ -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,9 +101,15 @@ export async function init(projectName = null, autoAcceptDefaults = false) {
break;
}
// Use provided API key if available, otherwise prompt for it
if (apiKey) {
neynarApiKey = apiKey;
} else {
if (!autoAcceptDefaults) {
console.log(
'\n🪐 Find your Neynar API key at: https://dev.neynar.com/app\n',
'\n🪐 Find your Neynar API key at: https://dev.neynar.com/app\n'
);
}
let neynarKeyAnswer;
if (autoAcceptDefaults) {
@ -137,17 +144,18 @@ export async function init(projectName = null, autoAcceptDefaults = false) {
if (useDemoKey.useDemo) {
console.warn(
'\n⚠ Note: the demo key is for development purposes only and is aggressively rate limited.',
'\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.',
'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}`,
`\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 (!neynarApiKey) {
if (autoAcceptDefaults) {
@ -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

View File

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

View File

@ -1,77 +1,39 @@
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
@ -79,43 +41,37 @@ async function loadEnvLocal() {
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,27 +331,27 @@ 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);
}
}

View File

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

View File

@ -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,49 +138,50 @@ async function checkRequiredEnvVars() {
const newLine = envContent ? '\n' : '';
fs.appendFileSync(
'.env',
`${newLine}${varConfig.name}="${value.trim()}"`,
`${newLine}${varConfig.name}="${value.trim()}"`
);
}
}
}
// 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([
// Ask about sponsor signer if SEED_PHRASE is provided
if (!process.env.SPONSOR_SIGNER) {
const { sponsorSigner } = await inquirer.prompt([
{
type: 'confirm',
name: 'storeSeedPhrase',
name: 'sponsorSigner',
message:
'Would you like to store this seed phrase in .env.local for future use?',
'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', `\nSEED_PHRASE="${seedPhrase}"`);
console.log('✅ Seed phrase stored in .env.local');
} else {
console.log(' Seed phrase will only be used for this deployment');
fs.appendFileSync(
'.env.local',
`\nSPONSOR_SIGNER="${sponsorSigner}"`
);
console.log('✅ Sponsor signer preference stored in .env.local');
}
}
}
}
// Load SPONSOR_SIGNER from .env.local if SEED_PHRASE exists but SPONSOR_SIGNER doesn't
if (
process.env.SEED_PHRASE &&
!process.env.SPONSOR_SIGNER &&
fs.existsSync('.env.local')
) {
const localEnv = dotenv.parse(fs.readFileSync('.env.local'));
if (localEnv.SPONSOR_SIGNER) {
process.env.SPONSOR_SIGNER = localEnv.SPONSOR_SIGNER;
}
}
}
async function getGitRemote() {
try {
const remoteUrl = execSync('git remote get-url origin', {
@ -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
let projectId;
try {
const projectJson = JSON.parse(
fs.readFileSync('.vercel/project.json', 'utf8'),
fs.readFileSync('.vercel/project.json', 'utf8')
);
const projectId = projectJson.projectId;
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,19 +593,20 @@ 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) {
try {
const inspectOutput = execSync(
`vercel project inspect ${projectId} 2>&1`,
{
cwd: projectRoot,
encoding: 'utf8',
},
}
);
const nameMatch = inspectOutput.match(/Name\s+([^\n]+)/);
@ -621,38 +621,32 @@ async function deployToVercel(useGitHub = false) {
domain = `${projectName}.vercel.app`;
console.log('🌐 Using project name for domain:', domain);
} else {
throw new Error(
'Could not determine project name from inspection output',
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) {
// Generate mini app metadata
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',
);
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;
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');

View File

@ -0,0 +1,16 @@
import { NextResponse } from 'next/server';
import { getNeynarClient } from '~/lib/neynar';
export async function GET() {
try {
const client = getNeynarClient();
const response = await client.fetchNonce();
return NextResponse.json(response);
} catch (error) {
console.error('Error fetching nonce:', error);
return NextResponse.json(
{ error: 'Failed to fetch nonce' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,43 @@
import { NextResponse } from 'next/server';
import { getNeynarClient } from '~/lib/neynar';
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const message = searchParams.get('message');
const signature = searchParams.get('signature');
if (!message || !signature) {
return NextResponse.json(
{ error: 'Message and signature are required' },
{ status: 400 }
);
}
const client = getNeynarClient();
const data = await client.fetchSigners({ message, signature });
const signers = data.signers;
// Fetch user data if signers exist
let user = null;
if (signers && signers.length > 0 && signers[0].fid) {
const {
users: [fetchedUser],
} = await client.fetchBulkUsers({
fids: [signers[0].fid],
});
user = fetchedUser;
}
return NextResponse.json({
signers,
user,
});
} catch (error) {
console.error('Error in session-signers API:', error);
return NextResponse.json(
{ error: 'Failed to fetch signers' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,42 @@
import { NextResponse } from 'next/server';
import { getNeynarClient } from '~/lib/neynar';
export async function POST() {
try {
const neynarClient = getNeynarClient();
const signer = await neynarClient.createSigner();
return NextResponse.json(signer);
} catch (error) {
console.error('Error fetching signer:', error);
return NextResponse.json(
{ error: 'Failed to fetch signer' },
{ status: 500 }
);
}
}
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const signerUuid = searchParams.get('signerUuid');
if (!signerUuid) {
return NextResponse.json(
{ error: 'signerUuid is required' },
{ status: 400 }
);
}
try {
const neynarClient = getNeynarClient();
const signer = await neynarClient.lookupSigner({
signerUuid,
});
return NextResponse.json(signer);
} catch (error) {
console.error('Error fetching signed key:', error);
return NextResponse.json(
{ error: 'Failed to fetch signed key' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,91 @@
import { NextResponse } from 'next/server';
import { getNeynarClient } from '~/lib/neynar';
import { mnemonicToAccount } from 'viem/accounts';
import {
SIGNED_KEY_REQUEST_TYPE,
SIGNED_KEY_REQUEST_VALIDATOR_EIP_712_DOMAIN,
} from '~/lib/constants';
const postRequiredFields = ['signerUuid', 'publicKey'];
export async function POST(request: Request) {
const body = await request.json();
// Validate required fields
for (const field of postRequiredFields) {
if (!body[field]) {
return NextResponse.json(
{ error: `${field} is required` },
{ status: 400 }
);
}
}
const { signerUuid, publicKey, redirectUrl } = body;
if (redirectUrl && typeof redirectUrl !== 'string') {
return NextResponse.json(
{ error: 'redirectUrl must be a string' },
{ status: 400 }
);
}
try {
// Get the app's account from seed phrase
const seedPhrase = process.env.SEED_PHRASE;
const shouldSponsor = process.env.SPONSOR_SIGNER === 'true';
if (!seedPhrase) {
return NextResponse.json(
{ error: 'App configuration missing (SEED_PHRASE or FID)' },
{ status: 500 }
);
}
const neynarClient = getNeynarClient();
const account = mnemonicToAccount(seedPhrase);
const {
user: { fid },
} = await neynarClient.lookupUserByCustodyAddress({
custodyAddress: account.address,
});
const appFid = fid;
// Generate deadline (24 hours from now)
const deadline = Math.floor(Date.now() / 1000) + 86400;
// Generate EIP-712 signature
const signature = await account.signTypedData({
domain: SIGNED_KEY_REQUEST_VALIDATOR_EIP_712_DOMAIN,
types: {
SignedKeyRequest: SIGNED_KEY_REQUEST_TYPE,
},
primaryType: 'SignedKeyRequest',
message: {
requestFid: BigInt(appFid),
key: publicKey,
deadline: BigInt(deadline),
},
});
const signer = await neynarClient.registerSignedKey({
appFid,
deadline,
signature,
signerUuid,
...(redirectUrl && { redirectUrl }),
...(shouldSponsor && { sponsor: { sponsored_by_neynar: true } }),
});
return NextResponse.json(signer);
} catch (error) {
console.error('Error registering signed key:', error);
return NextResponse.json(
{ error: 'Failed to register signed key' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,38 @@
import { NextResponse } from 'next/server';
import { getNeynarClient } from '~/lib/neynar';
const requiredParams = ['message', 'signature'];
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const params: Record<string, string | null> = {};
for (const param of requiredParams) {
params[param] = searchParams.get(param);
if (!params[param]) {
return NextResponse.json(
{
error: `${param} parameter is required`,
},
{ status: 400 }
);
}
}
const message = params.message as string;
const signature = params.signature as string;
try {
const client = getNeynarClient();
const data = await client.fetchSigners({ message, signature });
const signers = data.signers;
return NextResponse.json({
signers,
});
} catch (error) {
console.error('Error fetching signers:', error);
return NextResponse.json(
{ error: 'Failed to fetch signers' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,46 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '~/auth';
export async function POST(request: Request) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.fid) {
return NextResponse.json(
{ error: 'No authenticated session found' },
{ status: 401 }
);
}
const body = await request.json();
const { signers, user } = body;
if (!signers || !user) {
return NextResponse.json(
{ error: 'Signers and user are required' },
{ status: 400 }
);
}
// For NextAuth to update the session, we need to trigger the JWT callback
// This is typically done by calling the session endpoint with updated data
// However, we can't directly modify the session token from here
// Instead, we'll store the data temporarily and let the client refresh the session
// The session will be updated when the JWT callback is triggered
return NextResponse.json({
success: true,
message: 'Session update prepared',
signers,
user,
});
} catch (error) {
console.error('Error preparing session update:', error);
return NextResponse.json(
{ error: 'Failed to prepare session update' },
{ status: 500 }
);
}
}

View File

@ -1,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,29 +29,27 @@ 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 }
);
}

View File

@ -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,33 +56,33 @@ 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;
}

View File

@ -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,7 +32,7 @@ export function Providers({
backButtonEnabled={true}
>
<SafeFarcasterSolanaProvider endpoint={solanaEndpoint}>
{children}
<AuthKitProvider config={{}}>{children}</AuthKitProvider>
</SafeFarcasterSolanaProvider>
</MiniAppProvider>
</WagmiProvider>

View File

@ -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<Record<string, unknown>>;
power_badge?: boolean;
url?: string;
experimental?: {
neynar_user_score: number;
deprecation_notice: string;
};
score?: number;
};
signers?: {
object: 'signer';
signer_uuid: string;
public_key: string;
status: 'approved';
fid: number;
}[];
}
interface User {
provider?: string;
signers?: Array<{
object: 'signer';
signer_uuid: string;
public_key: string;
status: 'approved';
fid: number;
}>;
user?: {
object: 'user';
fid: number;
username: string;
display_name: string;
pfp_url: string;
custody_address: string;
profile: {
bio: {
text: string;
mentioned_profiles?: Array<{
object: 'user_dehydrated';
fid: number;
username: string;
display_name: string;
pfp_url: string;
custody_address: string;
}>;
mentioned_profiles_ranges?: Array<{
start: number;
end: number;
}>;
};
location?: {
latitude: number;
longitude: number;
address: {
city: string;
state: string;
country: string;
country_code: string;
};
};
};
follower_count: number;
following_count: number;
verifications: string[];
verified_addresses: {
eth_addresses: string[];
sol_addresses: string[];
primary: {
eth_address: string;
sol_address: string;
};
};
verified_accounts: Array<Record<string, unknown>>;
power_badge: boolean;
url?: string;
experimental?: {
neynar_user_score: number;
deprecation_notice: string;
};
score: number;
};
}
interface JWT {
provider?: string;
signers?: Array<{
object: 'signer';
signer_uuid: string;
public_key: string;
status: 'approved';
fid: number;
}>;
user?: {
object: 'user';
fid: number;
username: string;
display_name: string;
pfp_url: string;
custody_address: string;
profile: {
bio: {
text: string;
mentioned_profiles?: Array<{
object: 'user_dehydrated';
fid: number;
username: string;
display_name: string;
pfp_url: string;
custody_address: string;
}>;
mentioned_profiles_ranges?: Array<{
start: number;
end: number;
}>;
};
location?: {
latitude: number;
longitude: number;
address: {
city: string;
state: string;
country: string;
country_code: string;
};
};
};
follower_count: number;
following_count: number;
verifications: string[];
verified_addresses: {
eth_addresses: string[];
sol_addresses: string[];
primary: {
eth_address: string;
sol_address: string;
};
};
verified_accounts?: Array<Record<string, unknown>>;
power_badge?: boolean;
url?: string;
experimental?: {
neynar_user_score: number;
deprecation_notice: string;
};
score?: number;
};
}
}
@ -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',

View File

@ -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<boolean>(false);
const [checked, setChecked] = useState<boolean>(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);

View File

@ -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,8 +17,7 @@ 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);
@ -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,7 +79,9 @@ export default function Provider({ children }: { children: React.ReactNode }) {
return (
<WagmiProvider config={config}>
<QueryClientProvider client={queryClient}>
<CoinbaseWalletAutoConnect>{children}</CoinbaseWalletAutoConnect>
<CoinbaseWalletAutoConnect>
{children}
</CoinbaseWalletAutoConnect>
</QueryClientProvider>
</WagmiProvider>
);

View File

@ -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,8 +18,12 @@ export function Header({ neynarUser }: HeaderProps) {
return (
<div className="relative">
<div className="mt-4 mb-4 mx-4 px-2 py-2 bg-gray-100 dark:bg-gray-800 rounded-lg flex items-center justify-between border-[3px] border-double border-primary">
<div className="text-lg font-light">Welcome to {APP_NAME}!</div>
<div
className="mt-4 mb-4 mx-4 px-2 py-2 bg-gray-100 dark:bg-gray-800 rounded-lg flex items-center justify-between border-[3px] border-double border-primary"
>
<div className="text-lg font-light">
Welcome to {APP_NAME}!
</div>
{context?.user && (
<div
className="cursor-pointer"
@ -45,9 +49,7 @@ export function Header({ neynarUser }: HeaderProps) {
<div className="text-right">
<h3
className="font-bold text-sm hover:underline cursor-pointer inline-block"
onClick={() =>
sdk.actions.viewProfile({ fid: context.user.fid })
}
onClick={() => sdk.actions.viewProfile({ fid: context.user.fid })}
>
{context.user.displayName || context.user.username}
</h3>

View File

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

View File

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

View File

@ -0,0 +1,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<Record<string, unknown>>;
power_badge: boolean;
url?: string;
experimental?: {
neynar_user_score: number;
deprecation_notice: string;
};
score: number;
} | null;
signers: {
object: 'signer';
signer_uuid: string;
public_key: string;
status: 'approved';
fid: number;
}[];
}
// Main Custom SignInButton Component
export function NeynarAuthButton() {
const [nonce, setNonce] = useState<string | null>(null);
const [storedAuth, setStoredAuth] = useState<StoredAuthState | null>(null);
const [signersLoading, setSignersLoading] = useState(false);
const { context } = useMiniApp();
const { data: session } = useSession();
// New state for unified dialog flow
const [showDialog, setShowDialog] = useState(false);
const [dialogStep, setDialogStep] = useState<'signin' | 'access' | 'loading'>(
'loading'
);
const [signerApprovalUrl, setSignerApprovalUrl] = useState<string | null>(
null
);
const [pollingInterval, setPollingInterval] = useState<NodeJS.Timeout | null>(
null
);
const [message, setMessage] = useState<string | null>(null);
const [signature, setSignature] = useState<string | null>(null);
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<User | null> => {
try {
const response = await fetch(`/api/users?fids=${fid}`);
if (response.ok) {
const data = await response.json();
return data.users?.[0] || null;
}
return null;
} catch (error) {
console.error('Error fetching user data:', error);
return null;
}
},
[]
);
// Helper function to generate signed key request
const generateSignedKeyRequest = useCallback(
async (signerUuid: string, publicKey: string) => {
try {
// Prepare request body
const requestBody: {
signerUuid: string;
publicKey: string;
sponsor?: { sponsored_by_neynar: boolean };
} = {
signerUuid,
publicKey,
};
const response = await fetch('/api/auth/signer/signed_key', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(
`Failed to generate signed key request: ${errorData.error}`
);
}
const data = await response.json();
return data;
} catch (error) {
console.error('❌ Error generating signed key request:', error);
// throw error;
}
},
[]
);
// Helper function to fetch all signers
const fetchAllSigners = useCallback(
async (message: string, signature: string) => {
try {
setSignersLoading(true);
const endpoint = useBackendFlow
? `/api/auth/session-signers?message=${encodeURIComponent(
message
)}&signature=${signature}`
: `/api/auth/signers?message=${encodeURIComponent(
message
)}&signature=${signature}`;
const response = await fetch(endpoint);
const signerData = await response.json();
if (response.ok) {
if (useBackendFlow) {
// For backend flow, update session with signers
if (signerData.signers && signerData.signers.length > 0) {
const user =
signerData.user ||
(await fetchUserData(signerData.signers[0].fid));
await updateSessionWithSigners(signerData.signers, user);
}
return signerData.signers;
} else {
// For frontend flow, store in localStorage
let user: StoredAuthState['user'] | null = null;
if (signerData.signers && signerData.signers.length > 0) {
const fetchedUser = (await fetchUserData(
signerData.signers[0].fid
)) as StoredAuthState['user'];
user = fetchedUser;
}
// Store signers in localStorage, preserving existing auth data
const updatedState: StoredAuthState = {
isAuthenticated: !!user,
signers: signerData.signers || [],
user,
};
setItem<StoredAuthState>(STORAGE_KEY, updatedState);
setStoredAuth(updatedState);
return signerData.signers;
}
} else {
console.error('❌ Failed to fetch signers');
// throw new Error('Failed to fetch signers');
}
} catch (error) {
console.error('❌ Error fetching signers:', error);
// throw error;
} finally {
setSignersLoading(false);
}
},
[useBackendFlow, fetchUserData, updateSessionWithSigners]
);
// Helper function to poll signer status
const startPolling = useCallback(
(signerUuid: string, message: string, signature: string) => {
// 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<StoredAuthState>(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<StoredAuthState>(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<StoredAuthState>(STORAGE_KEY, authState);
setStoredAuth(authState);
}
// For backend flow, the session will be handled by NextAuth
},
[useBackendFlow, fetchUserData]
);
// Error callback
const onErrorCallback = useCallback((error?: Error | null) => {
console.error('❌ Sign in error:', error);
}, []);
const signInState = useSignIn({
nonce: nonce || undefined,
onSuccess: onSuccessCallback,
onError: onErrorCallback,
});
const {
signIn: frontendSignIn,
signOut: frontendSignOut,
connect,
reconnect,
isSuccess,
isError,
error,
channelToken,
url,
data,
validSignature,
} = signInState;
useEffect(() => {
setMessage(data?.message || null);
setSignature(data?.signature || null);
// 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<string, unknown>;
const isMobileContext =
clientContext?.platformType === 'mobile' &&
clientContext?.clientFid === FARCASTER_FID;
// Step 1: Change to loading state
setDialogStep('loading');
// Show dialog if not using backend flow or in browser farcaster
if ((useBackendFlow && !isMobileContext) || !useBackendFlow)
setShowDialog(true);
// First, fetch existing signers
const signers = await fetchAllSigners(message, signature);
if (useBackendFlow && isMobileContext) setSignersLoading(true);
// Check if no signers exist or if we have empty signers
if (!signers || signers.length === 0) {
// Step 1: Create a signer
const newSigner = await createSigner();
// Step 2: Generate signed key request
const signedKeyData = await generateSignedKeyRequest(
newSigner.signer_uuid,
newSigner.public_key
);
// Step 3: Show QR code in access dialog for signer approval
setSignerApprovalUrl(signedKeyData.signer_approval_url);
if (isMobileContext) {
setShowDialog(false);
await sdk.actions.openUrl(
signedKeyData.signer_approval_url.replace(
'https://client.farcaster.xyz/deeplinks/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 (
<div className="flex items-center justify-center">
<div className="flex items-center gap-3 px-4 py-2 bg-gray-100 dark:bg-gray-800 rounded-lg">
<div className="spinner w-4 h-4" />
<span className="text-sm text-gray-600 dark:text-gray-400">
Loading...
</span>
</div>
</div>
);
}
return (
<>
{authenticated ? (
<ProfileButton userData={userData} onSignOut={handleSignOut} />
) : (
<Button
onClick={useBackendFlow ? handleBackendSignIn : handleFrontEndSignIn}
disabled={!useBackendFlow && !url}
className={cn(
'btn btn-primary flex items-center gap-3',
'disabled:opacity-50 disabled:cursor-not-allowed',
'transform transition-all duration-200 active:scale-[0.98]',
!url && !useBackendFlow && 'cursor-not-allowed'
)}
>
{!useBackendFlow && !url ? (
<>
<div className="spinner-primary w-5 h-5" />
<span>Initializing...</span>
</>
) : (
<>
<span>Sign in with Neynar</span>
</>
)}
</Button>
)}
{/* Unified Auth Dialog */}
{
<AuthDialog
open={showDialog}
onClose={() => {
setShowDialog(false);
setDialogStep('signin');
setSignerApprovalUrl(null);
if (pollingInterval) {
clearInterval(pollingInterval);
setPollingInterval(null);
}
}}
url={url}
isError={isError}
error={error}
step={dialogStep}
isLoading={signersLoading}
signerApprovalUrl={signerApprovalUrl}
/>
}
</>
);
}

View File

@ -1,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

View File

@ -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 (
<div className="space-y-3 px-6 w-full max-w-md mx-auto">
<div className='space-y-3 px-6 w-full max-w-md mx-auto'>
{/* Share functionality */}
<ShareButton
buttonText="Share Mini App"
buttonText='Share Mini App'
cast={{
text: 'Check out this awesome frame @1 @2 @3! 🚀🪐',
bestFriends: true,
@ -130,36 +134,39 @@ export function ActionsTab() {
`${process.env.NEXT_PUBLIC_URL}/share/${context?.user?.fid || ''}`,
],
}}
className="w-full"
className='w-full'
/>
{/* Authentication */}
<SignIn />
{/* Neynar Authentication */}
<NeynarAuthButton />
{/* Mini app actions */}
<Button
onClick={() =>
actions.openUrl('https://www.youtube.com/watch?v=dQw4w9WgXcQ')
}
className="w-full"
className='w-full'
>
Open Link
</Button>
<Button onClick={actions.addMiniApp} disabled={added} className="w-full">
<Button onClick={actions.addMiniApp} disabled={added} className='w-full'>
Add Mini App to Client
</Button>
{/* Notification functionality */}
{notificationState.sendStatus && (
<div className="text-sm w-full">
<div className='text-sm w-full'>
Send notification result: {notificationState.sendStatus}
</div>
)}
<Button
onClick={sendFarcasterNotification}
disabled={!notificationDetails}
className="w-full"
className='w-full'
>
Send notification
</Button>
@ -168,24 +175,24 @@ export function ActionsTab() {
<Button
onClick={copyUserShareUrl}
disabled={!context?.user?.fid}
className="w-full"
className='w-full'
>
{notificationState.shareUrlCopied ? 'Copied!' : 'Copy share URL'}
</Button>
{/* Haptic feedback controls */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
<div className='space-y-2'>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300'>
Haptic Intensity
</label>
<select
value={selectedHapticIntensity}
onChange={e =>
onChange={(e) =>
setSelectedHapticIntensity(
e.target.value as Haptics.ImpactOccurredType,
e.target.value as Haptics.ImpactOccurredType
)
}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-primary"
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-primary'
>
<option value={'light'}>Light</option>
<option value={'medium'}>Medium</option>
@ -193,7 +200,7 @@ export function ActionsTab() {
<option value={'soft'}>Soft</option>
<option value={'rigid'}>Rigid</option>
</select>
<Button onClick={triggerHapticFeedback} className="w-full">
<Button onClick={triggerHapticFeedback} className='w-full'>
Trigger Haptic Feedback
</Button>
</div>

View File

@ -17,7 +17,7 @@ export function HomeTab() {
<div className="flex items-center justify-center h-[calc(100vh-200px)] px-6">
<div className="text-center w-full max-w-md mx-auto">
<p className="text-lg mb-2">Put your content here!</p>
<p className="text-sm text-gray-500">Powered by Neynar 🪐</p>
<p className="text-sm text-gray-500 dark:text-gray-400">Powered by Neynar 🪐</p>
</div>
</div>
);

View File

@ -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<void>
*/
const handleSignOut = useCallback(async () => {
try {
setAuthState(prev => ({ ...prev, signingOut: true }));
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') && (
<Button onClick={handleSignIn} disabled={authState.signingIn}>
Sign In with Farcaster
</Button>
)}
{status === 'authenticated' && (
{status === 'authenticated' && session?.provider === 'farcaster' && (
<Button onClick={handleSignOut} disabled={authState.signingOut}>
Sign out
</Button>
@ -128,9 +131,9 @@ export function SignIn() {
{/* Session Information */}
{session && (
<div className="my-2 p-2 text-xs overflow-x-scroll bg-gray-100 rounded-lg font-mono">
<div className="font-semibold text-gray-500 mb-1">Session</div>
<div className="whitespace-pre">
<div className="my-2 p-2 text-xs overflow-x-scroll bg-gray-100 dark:bg-gray-900 rounded-lg font-mono">
<div className="font-semibold text-gray-500 dark:text-gray-300 mb-1">Session</div>
<div className="whitespace-pre text-gray-700 dark:text-gray-200">
{JSON.stringify(session, null, 2)}
</div>
</div>
@ -138,17 +141,17 @@ export function SignIn() {
{/* Error Display */}
{signInFailure && !authState.signingIn && (
<div className="my-2 p-2 text-xs overflow-x-scroll bg-gray-100 rounded-lg font-mono">
<div className="font-semibold text-gray-500 mb-1">SIWF Result</div>
<div className="whitespace-pre">{signInFailure}</div>
<div className="my-2 p-2 text-xs overflow-x-scroll bg-gray-100 dark:bg-gray-900 rounded-lg font-mono">
<div className="font-semibold text-gray-500 dark:text-gray-300 mb-1">SIWF Result</div>
<div className="whitespace-pre text-gray-700 dark:text-gray-200">{signInFailure}</div>
</div>
)}
{/* Success Result Display */}
{signInResult && !authState.signingIn && (
<div className="my-2 p-2 text-xs overflow-x-scroll bg-gray-100 rounded-lg font-mono">
<div className="font-semibold text-gray-500 mb-1">SIWF Result</div>
<div className="whitespace-pre">
<div className="my-2 p-2 text-xs overflow-x-scroll bg-gray-100 dark:bg-gray-900 rounded-lg font-mono">
<div className="font-semibold text-gray-500 dark:text-gray-300 mb-1">SIWF Result</div>
<div className="whitespace-pre text-gray-700 dark:text-gray-200">
{JSON.stringify(signInResult, null, 2)}
</div>
</div>

View File

@ -0,0 +1,18 @@
import { useEffect } from 'react';
export function useDetectClickOutside<T extends HTMLElement>(
ref: React.RefObject<T | null>,
callback: () => void
) {
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (ref.current && !ref.current.contains(event.target as Node)) {
callback();
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [ref, callback]);
}

View File

@ -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' },
];

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

@ -0,0 +1,27 @@
function isAndroid(): boolean {
return (
typeof navigator !== 'undefined' && /android/i.test(navigator.userAgent)
);
}
function isSmallIOS(): boolean {
return (
typeof navigator !== 'undefined' && /iPhone|iPod/.test(navigator.userAgent)
);
}
function isLargeIOS(): boolean {
return (
typeof navigator !== 'undefined' &&
(/iPad/.test(navigator.userAgent) ||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1))
);
}
function isIOS(): boolean {
return isSmallIOS() || isLargeIOS();
}
export function isMobile(): boolean {
return isAndroid() || isIOS();
}

View File

@ -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<string, FrameNotificationDetails>();
// 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({
const redis = useRedis ? new Redis({
url: process.env.KV_REST_API_URL!,
token: process.env.KV_REST_API_TOKEN!,
})
: null;
}) : null;
function getUserNotificationDetailsKey(fid: number): string {
return `${APP_NAME}:user:${fid}`;
}
export async function getUserNotificationDetails(
fid: number,
fid: number
): Promise<FrameNotificationDetails | null> {
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<void> {
const key = getUserNotificationDetailsKey(fid);
if (redis) {
@ -41,7 +39,7 @@ export async function setUserNotificationDetails(
}
export async function deleteUserNotificationDetails(
fid: number,
fid: number
): Promise<void> {
const key = getUserNotificationDetailsKey(fid);
if (redis) {

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

@ -0,0 +1,25 @@
export function setItem<T>(key: string, value: T) {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch (error) {
console.warn('Failed to save item:', error);
}
}
export function getItem<T>(key: string): T | null {
try {
const stored = localStorage.getItem(key);
return stored ? JSON.parse(stored) : null;
} catch (error) {
console.warn('Failed to load item:', error);
return null;
}
}
export function removeItem(key: string) {
try {
localStorage.removeItem(key);
} catch (error) {
console.warn('Failed to remove item:', error);
}
}

View File

@ -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<SendMiniAppNotificationResult> {
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 };
}
}

View File

@ -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<MiniAppManifest> {
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,