Compare commits

...

71 Commits

Author SHA1 Message Date
Shreyaschorge
c872bdd4ee Fix terminology 2025-09-30 18:51:49 +05:30
veganbeef
c5ca1618ed bump @neynar/react version 2025-09-09 23:36:13 -07:00
veganbeef
db735d7568 fix: frame_* to miniapp_* 2025-09-09 23:20:35 -07:00
veganbeef
ccd27f53b3 fix: build failure 2025-09-09 23:07:53 -07:00
veganbeef
f14493e35b fix: deploy script dependency issue 2025-09-09 22:53:03 -07:00
veganbeef
3af6ee0e71 upgrade to @neynar/react v1.2.12 2025-09-09 22:25:23 -07:00
veganbeef
54646a5035 fix: support yarn and pnpm 2025-09-08 16:23:24 -07:00
veganbeef
be7d6b76ae fix: update @neynar/react version and add overrides for qix vulnerable packages 2025-09-08 16:00:14 -07:00
veganbeef
09ef2e374e feat: bump @neynar/react version 2025-08-21 11:31:40 -07:00
veganbeef
1cce5835d4 feat: bump neynar/react version 2025-08-11 17:21:09 -07:00
veganbeef
55c7c4b129 feat: udpate script to support --return-url flag [NEYN-6533] 2025-08-07 16:11:45 -07:00
veganbeef
055dc4adbd feat: integrate returnUrl to config [NEYN-6533] 2025-08-07 16:02:46 -07:00
veganbeef
98579bcea9 fix: constants format 2025-07-23 19:29:00 -07:00
veganbeef
ea7ee37e71 fix: siwn csrf config 2025-07-23 19:23:03 -07:00
veganbeef
feb9f3e161 fix: authkit provider 2025-07-23 18:57:24 -07:00
veganbeef
8c5b8d8329 move conditional next-auth code to alternate files 2025-07-23 18:54:08 -07:00
Shreyaschorge
d0f8c75a2e Merge pull request #24 from neynarxyz/sc/fix-401-for-siwn-auth
fix:401 for SIWN and remove farcaster login mechanism
2025-07-21 16:24:59 +05:30
Shreyaschorge
e115520aa7 fix 401 2025-07-21 16:23:09 +05:30
veganbeef
7dff4cd81a fix: remove NeynarAuthButton import conditionally 2025-07-18 18:57:31 -07:00
veganbeef
d1ec161f47 fix: sponsor signer depends on seed phrase 2025-07-18 17:29:51 -07:00
Shreyaschorge
572ab9aa44 Merge pull request #23 from neynarxyz/fix-deploy-and-manifest-issue
fix-deploy-and-dynamic-import-issue
2025-07-19 04:20:59 +05:30
Shreyaschorge
501bc84512 Merge branch 'main' into fix-deploy-and-manifest-issue 2025-07-19 03:52:05 +05:30
Shreyaschorge
85b39a6397 return fragment 2025-07-19 03:48:49 +05:30
Shreyaschorge
61df6d6a64 fix: Unknown file extension '.ts' issue 2025-07-19 03:12:54 +05:30
Shreyaschorge
9ee370628d if button exists then render it 2025-07-19 02:53:18 +05:30
Shreyaschorge
882e4f166f fix imports 2025-07-19 02:43:31 +05:30
veganbeef
e8fa822638 feat: add more flags to script 2025-07-18 10:56:16 -07:00
Shreyaschorge
bade04b785 fix-deploy-and-manifest-issue 2025-07-18 22:55:10 +05:30
veganbeef
d9c74f163b fix: conditional imports 2025-07-17 14:29:24 -07:00
Shreyaschorge
2edd1bd2ae Merge pull request #22 from neynarxyz/shreyas/neyn-5928-resolve-customer-issue-with-broken-nsk-script
Handle session provider rendering
2025-07-17 03:04:45 +05:30
Shreyaschorge
76ad200a22 Handle session provider rendering 2025-07-17 03:02:54 +05:30
veganbeef
86b79e7f3f fix: use session provider whenever next auth is included 2025-07-16 13:12:48 -07:00
Shreyaschorge
aac3a739cd Merge pull request #21 from neynarxyz/veganbeef/reapply-quick-auth
feat: reapply quickauth changes conditionally
2025-07-17 00:08:18 +05:30
Shreyaschorge
f20f65f966 Bump up patch version 2025-07-17 00:07:47 +05:30
Shreyaschorge
a287d55641 set SPONSOR_SIGNER=true 2025-07-16 23:54:44 +05:30
Shreyaschorge
53d6ce6a94 sponsoredSigner -> useSponsoredSigner 2025-07-16 23:27:05 +05:30
Shreyaschorge
ebb8068ade remove update-session route 2025-07-16 23:03:01 +05:30
veganbeef
8124fe5f6c feat: add back og properties 2025-07-16 09:49:19 -07:00
veganbeef
9ea40a5f92 feat: add requiredChains support 2025-07-16 09:46:35 -07:00
veganbeef
e61bc88aaa feat: reapply quickauth changes conditionally 2025-07-16 09:16:58 -07:00
veganbeef
8eabbd3ba1 bump version number for revert 2025-07-16 08:44:39 -07:00
Lucas Myers
f7392dc3cb Merge pull request #20 from neynarxyz/sc/revert-to-5fbd9
revert to 5fbd9
2025-07-16 08:43:43 -07:00
Shreyaschorge
bf563154ca Fix merge conflict issues 2025-07-16 17:50:03 +05:30
Shreyaschorge
196378daeb Revert "format"
This reverts commit 5fbd9c5c09.
2025-07-16 17:33:02 +05:30
Shreyaschorge
89f82253ca Revert "feat: replace next-auth with quick auth"
This reverts commit 86029b2bd9.
2025-07-16 17:32:21 +05:30
Shreyaschorge
c786cabe84 Revert "fix: add back auth-kit"
This reverts commit 16c433a13c.
2025-07-16 17:31:34 +05:30
Shreyaschorge
760efdb96b Revert "format"
This reverts commit 378ea65154.
2025-07-16 17:30:47 +05:30
Shreyaschorge
5fd0e21532 Revert "fix: SIWN dependencies"
This reverts commit 4ba9480832.
2025-07-16 17:28:36 +05:30
Shreyaschorge
0d43b35c28 Revert "Merge pull request #15 from neynarxyz/shreyas-formatting"
This reverts commit b1fdfc19a9, reversing
changes made to b9e2087bd8.
2025-07-16 17:21:12 +05:30
Shreyaschorge
349cdea489 Revert "Merge pull request #18 from neynarxyz/veganbeef/fix-siwn"
This reverts commit 78626c2dc7, reversing
changes made to b1fdfc19a9.
2025-07-16 17:20:36 +05:30
veganbeef
181c364de4 fix: sponsor signer env var 2025-07-15 13:08:55 -07:00
Lucas Myers
78626c2dc7 Merge pull request #18 from neynarxyz/veganbeef/fix-siwn
fix: add back auth-kit
2025-07-15 09:34:40 -07:00
veganbeef
b72198575a Merge branch 'main' into veganbeef/fix-siwn 2025-07-15 09:29:35 -07:00
Lucas Myers
b1fdfc19a9 Merge pull request #15 from neynarxyz/shreyas-formatting
Formatting
2025-07-15 09:25:35 -07:00
veganbeef
4ba9480832 fix: SIWN dependencies 2025-07-15 09:25:08 -07:00
Shreyaschorge
378ea65154 format 2025-07-15 21:38:47 +05:30
Shreyaschorge
b366d97b53 Merge branch 'main' into shreyas-formatting 2025-07-15 21:35:11 +05:30
veganbeef
16c433a13c fix: add back auth-kit 2025-07-14 13:24:55 -07:00
veganbeef
b9e2087bd8 fix: remove duplicate app directory 2025-07-14 13:12:47 -07:00
veganbeef
86029b2bd9 feat: replace next-auth with quick auth 2025-07-14 13:01:46 -07:00
Shreyaschorge
5fbd9c5c09 format 2025-07-15 01:00:59 +05:30
Shreyaschorge
3815f45b44 Merge branch 'main' into shreyas-formatting 2025-07-15 00:00:11 +05:30
Shreyaschorge
c713d53054 Merge pull request #17 from neynarxyz/shreyas/show-modal-on-top
Show Dialog on top to show I am using my phone button
2025-07-14 23:51:52 +05:30
Shreyaschorge
00fdd21044 remove unnecessary rules 2025-07-14 21:52:14 +05:30
Shreyaschorge
8475b28107 remove console logs 2025-07-14 20:51:03 +05:30
Shreyaschorge
e74b2581df Format after fixing conflicts 2025-07-14 20:04:44 +05:30
Shreyaschorge
505aa54b16 Merge branch 'main' into shreyas-formatting 2025-07-14 18:55:06 +05:30
Shreyaschorge
65419a76ee Remove unnecessary file 2025-07-08 00:48:13 +05:30
Shreyaschorge
6b0350d477 Remove AI generated docs 2025-07-07 16:12:17 +05:30
Shreyaschorge
2a1a3d7c40 Correct formating imports 2025-07-07 16:07:33 +05:30
Shreyaschorge
193dffe03a formatting 2025-07-07 14:10:47 +05:30
25 changed files with 2048 additions and 1102 deletions

View File

@@ -4,8 +4,3 @@ KV_REST_API_TOKEN=''
KV_REST_API_URL=''
NEXT_PUBLIC_URL='http://localhost:3000'
NEXTAUTH_URL='http://localhost:3000'
NEXTAUTH_SECRET=""
NEYNAR_API_KEY=""
NEYNAR_CLIENT_ID=""
USE_TUNNEL="false"

View File

@@ -1,3 +1,32 @@
{
"extends": ["next/core-web-vitals", "next/typescript"]
"extends": ["next/core-web-vitals", "next/typescript"],
"rules": {
// Disable img warnings since you're using them intentionally in specific contexts
"@next/next/no-img-element": "off",
// Allow @ts-ignore comments (though @ts-expect-error is preferred)
"@typescript-eslint/ban-ts-comment": "off",
// Allow explicit any types (sometimes necessary for dynamic imports and APIs)
"@typescript-eslint/no-explicit-any": "off",
// Allow unused variables that start with underscore
"@typescript-eslint/no-unused-vars": [
"warn",
{
"argsIgnorePattern": "^_",
"varsIgnorePattern": "^_",
"caughtErrorsIgnorePattern": "^_"
}
],
// Make display name warnings instead of errors for dynamic components
"react/display-name": "warn",
// Allow module assignment for dynamic imports
"@next/next/no-assign-module-variable": "warn",
// Make exhaustive deps a warning instead of error for complex hooks
"react-hooks/exhaustive-deps": "warn"
}
}

View File

@@ -7,6 +7,11 @@ const args = process.argv.slice(2);
let projectName = null;
let autoAcceptDefaults = false;
let apiKey = null;
let noWallet = false;
let noTunnel = false;
let sponsoredSigner = false;
let seedPhrase = null;
let returnUrl = null;
// Check for -y flag
const yIndex = args.indexOf('-y');
@@ -45,6 +50,44 @@ if (yIndex !== -1) {
console.error('Error: -k/--api-key requires an API key');
process.exit(1);
}
} else if (arg === '--no-wallet') {
noWallet = true;
args.splice(i, 1); // Remove the flag
i--; // Adjust index since we removed 1 element
} else if (arg === '--no-tunnel') {
noTunnel = true;
args.splice(i, 1); // Remove the flag
i--; // Adjust index since we removed 1 element
} else if (arg === '--sponsored-signer') {
sponsoredSigner = true;
args.splice(i, 1); // Remove the flag
i--; // Adjust index since we removed 1 element
} else if (arg === '--seed-phrase') {
if (i + 1 < args.length) {
seedPhrase = args[i + 1];
if (seedPhrase.startsWith('-')) {
console.error('Error: Seed phrase 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: --seed-phrase requires a seed phrase');
process.exit(1);
}
} else if (arg === '-r' || arg === '--return-url') {
if (i + 1 < args.length) {
returnUrl = args[i + 1];
if (returnUrl.startsWith('-')) {
console.error('Error: Return URL 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: -r/--return-url requires a return URL');
process.exit(1);
}
}
}
@@ -56,7 +99,7 @@ if (autoAcceptDefaults && !projectName) {
process.exit(1);
}
init(projectName, autoAcceptDefaults, apiKey).catch((err) => {
init(projectName, autoAcceptDefaults, apiKey, noWallet, noTunnel, sponsoredSigner, seedPhrase, returnUrl).catch((err) => {
console.error('Error:', err);
process.exit(1);
});

View File

@@ -63,7 +63,16 @@ async function queryNeynarApp(apiKey) {
}
// Export the main CLI function for programmatic use
export async function init(projectName = null, autoAcceptDefaults = false, apiKey = null) {
export async function init(
projectName = null,
autoAcceptDefaults = false,
apiKey = null,
noWallet = false,
noTunnel = false,
sponsoredSigner = false,
seedPhrase = null,
returnUrl = null
) {
printWelcomeMessage();
// Ask about Neynar usage
@@ -219,17 +228,34 @@ export async function init(projectName = null, autoAcceptDefaults = false, apiKe
let answers;
if (autoAcceptDefaults) {
// Handle SIWN logic for autoAcceptDefaults
let seedPhraseValue = null;
let useSponsoredSignerValue = false;
// Only set seed phrase and sponsored signer if explicitly provided via flags
if (seedPhrase) {
// Validate the provided seed phrase
if (!seedPhrase || seedPhrase.trim().split(' ').length < 12) {
console.error('Error: Seed phrase must be at least 12 words');
process.exit(1);
}
seedPhraseValue = seedPhrase;
// If sponsoredSigner flag is provided, enable it; otherwise default to false
useSponsoredSignerValue = sponsoredSigner;
}
answers = {
projectName: projectName || defaultMiniAppName || 'my-farcaster-mini-app',
description: 'A Farcaster mini app created with Neynar',
primaryCategory: null,
tags: [],
buttonText: 'Launch Mini App',
useWallet: true,
useWallet: !noWallet,
useTunnel: true,
enableAnalytics: true,
seedPhrase: null,
sponsorSigner: false,
seedPhrase: seedPhraseValue,
useSponsoredSigner: useSponsoredSignerValue,
returnUrl: returnUrl,
};
} else {
// If autoAcceptDefaults is false but we have a projectName, we still need to ask for other options
@@ -312,74 +338,102 @@ export async function init(projectName = null, autoAcceptDefaults = false, apiKe
// Merge project name from the first prompt
answers.projectName = projectNamePrompt.projectName;
// Ask about wallet and transaction tooling
const walletAnswer = await inquirer.prompt([
{
type: 'confirm',
name: 'useWallet',
message:
'Would you like to include wallet and transaction tooling in your mini app?\n' +
'This includes:\n' +
'- EVM wallet connection\n' +
'- Transaction signing\n' +
'- Message signing\n' +
'- Chain switching\n' +
'- Solana support\n\n' +
'Include wallet and transaction features?',
default: true,
},
]);
answers.useWallet = walletAnswer.useWallet;
// Ask about localhost vs tunnel
const hostingAnswer = await inquirer.prompt([
{
type: 'confirm',
name: 'useTunnel',
message:
'Would you like to test on mobile and/or test the app with Warpcast developer tools?\n' +
`⚠️ ${yellow}${italic}Both mobile testing and the Warpcast debugger require setting up a tunnel to serve your app from localhost to the broader internet.\n${reset}` +
'Configure a tunnel for mobile testing and/or Warpcast developer tools?',
default: true,
},
]);
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([
// Ask about wallet and transaction tooling (skip if --no-wallet flag is used)
if (noWallet) {
answers.useWallet = false;
} else {
const walletAnswer = 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;
},
type: 'confirm',
name: 'useWallet',
message:
'Would you like to include wallet and transaction tooling in your mini app?\n' +
'This includes:\n' +
'- EVM wallet connection\n' +
'- Transaction signing\n' +
'- Message signing\n' +
'- Chain switching\n' +
'- Solana support\n\n' +
'Include wallet and transaction features?',
default: true,
},
]);
answers.useWallet = walletAnswer.useWallet;
}
// Ask about localhost vs tunnel
if (noTunnel) {
answers.useTunnel = false;
} else {
const hostingAnswer = await inquirer.prompt([
{
type: 'confirm',
name: 'useTunnel',
message:
'Would you like to test on mobile and/or test the app with Warpcast developer tools?\n' +
`⚠️ ${yellow}${italic}Both mobile testing and the Warpcast debugger require setting up a tunnel to serve your app from localhost to the broader internet.\n${reset}` +
'Configure a tunnel for mobile testing and/or Warpcast developer tools?',
default: true,
},
]);
answers.useTunnel = hostingAnswer.useTunnel;
}
// Ask about Sign In With Neynar (SIWN) - requires seed phrase
if (seedPhrase) {
// If --seed-phrase flag is used, validate it
if (!seedPhrase || seedPhrase.trim().split(' ').length < 12) {
console.error('Error: Seed phrase must be at least 12 words');
process.exit(1);
}
answers.seedPhrase = seedPhrase;
// If --sponsored-signer flag is also provided, enable it
answers.useSponsoredSigner = sponsoredSigner;
} else {
const siwnAnswer = await inquirer.prompt([
{
type: 'confirm',
name: 'useSIWN',
message:
'Would you like to enable Sign In With Neynar (SIWN)? This allows your mini app to write data to Farcaster on behalf of users.\n' +
'\n⚠ A seed phrase is required for this option.\n',
default: false,
},
]);
if (siwnAnswer.useSIWN) {
const { seedPhrase } = await inquirer.prompt([
{
type: 'password',
name: 'seedPhrase',
message: 'Enter your Farcaster custody account seed phrase (required for 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 sponsor signer if seed phrase is provided
const { sponsorSigner } = await inquirer.prompt([
{
type: 'confirm',
name: 'sponsorSigner',
message:
'You have provided a seed phrase, which enables Sign In With Neynar (SIWN).\n' +
'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,
},
]);
answers.useSponsoredSigner = sponsorSigner;
} else {
answers.useSponsoredSigner = false;
}
}
// Ask about analytics opt-out
@@ -453,14 +507,15 @@ export async function init(projectName = null, autoAcceptDefaults = false, apiKe
delete packageJson.devDependencies;
// Add dependencies
// question: remove auth-client?
packageJson.dependencies = {
'@farcaster/auth-client': '>=0.3.0 <1.0.0',
'@farcaster/auth-kit': '>=0.6.0 <1.0.0',
'@farcaster/miniapp-node': '>=0.1.5 <1.0.0',
'@farcaster/miniapp-sdk': '>=0.1.6 <1.0.0',
'@farcaster/miniapp-wagmi-connector': '^1.0.0',
'@farcaster/mini-app-solana': '>=0.0.17 <1.0.0',
'@neynar/react': '^1.2.5',
'@farcaster/quick-auth': '>=0.0.7 <1.0.0',
'@neynar/react': '^1.2.13',
'@radix-ui/react-label': '^2.1.1',
'@solana/wallet-adapter-react': '^0.15.38',
'@tanstack/react-query': '^5.61.0',
@@ -471,7 +526,6 @@ export async function init(projectName = null, autoAcceptDefaults = false, apiKe
'lucide-react': '^0.469.0',
mipd: '^0.0.7',
next: '^15',
'next-auth': '^4.24.11',
react: '^19',
'react-dom': '^19',
'tailwind-merge': '^2.6.0',
@@ -483,6 +537,7 @@ export async function init(projectName = null, autoAcceptDefaults = false, apiKe
};
packageJson.devDependencies = {
"@types/inquirer": "^9.0.8",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
@@ -490,12 +545,14 @@ export async function init(projectName = null, autoAcceptDefaults = false, apiKe
"crypto": "^1.0.1",
"eslint": "^8",
"eslint-config-next": "15.0.3",
"inquirer": "^10.2.2",
"localtunnel": "^2.0.2",
"pino-pretty": "^13.0.0",
"postcss": "^8",
"tailwindcss": "^3.4.1",
"typescript": "^5",
"ts-node": "^10.9.2"
"ts-node": "^10.9.2",
"tsx": "^4.20.5",
"typescript": "^5"
};
// Add Neynar SDK if selected
@@ -503,6 +560,44 @@ export async function init(projectName = null, autoAcceptDefaults = false, apiKe
packageJson.dependencies['@neynar/nodejs-sdk'] = '^2.19.0';
}
// Add auth-kit and next-auth dependencies if SIWN is enabled (seed phrase is present)
if (answers.seedPhrase) {
packageJson.dependencies['@farcaster/auth-kit'] = '>=0.6.0 <1.0.0';
packageJson.dependencies['next-auth'] = '^4.24.11';
}
// Add security overrides for vulnerable packages (compatible with npm, Yarn, and pnpm)
const securityOverrides = {
"backslash": "0.2.0",
"chalk-template": "1.1.0",
"supports-hyperlinks": "4.1.0",
"has-ansi": "6.0.0",
"simple-swizzle": "0.2.2",
"color-string": "2.1.0",
"error-ex": "1.3.2",
"color-name": "2.0.0",
"is-arrayish": "0.3.2",
"slice-ansi": "7.1.0",
"color-convert": "3.1.0",
"wrap-ansi": "9.0.0",
"ansi-regex": "6.2.0",
"supports-color": "10.2.0",
"strip-ansi": "7.1.0",
"chalk": "5.6.0",
"ansi-styles": "6.2.1"
};
// npm v8.3+ overrides
packageJson.overrides = securityOverrides;
// Yarn (v1 and Berry) resolutions
packageJson.resolutions = securityOverrides;
// pnpm overrides (namespaced)
packageJson.pnpm = {
overrides: securityOverrides
};
fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2));
// Handle .env file
@@ -559,13 +654,14 @@ export async function init(projectName = null, autoAcceptDefaults = false, apiKe
USE_WALLET: /^export const USE_WALLET\s*:\s*boolean\s*=\s*(true|false);$/m,
ANALYTICS_ENABLED:
/^export const ANALYTICS_ENABLED\s*:\s*boolean\s*=\s*(true|false);$/m,
RETURN_URL: /^export const RETURN_URL\s*:\s*string\s*\|\s*undefined\s*=\s*(undefined|['"`][^'"`]*['"`]);$/m,
};
// Update APP_NAME
constantsContent = safeReplace(
constantsContent,
patterns.APP_NAME,
`export const APP_NAME = '${escapeString(answers.projectName)}';`,
`export const APP_NAME: string = '${escapeString(answers.projectName)}';`,
'APP_NAME'
);
@@ -573,7 +669,7 @@ export async function init(projectName = null, autoAcceptDefaults = false, apiKe
constantsContent = safeReplace(
constantsContent,
patterns.APP_DESCRIPTION,
`export const APP_DESCRIPTION = '${escapeString(
`export const APP_DESCRIPTION: string = '${escapeString(
answers.description
)}';`,
'APP_DESCRIPTION'
@@ -583,7 +679,7 @@ export async function init(projectName = null, autoAcceptDefaults = false, apiKe
constantsContent = safeReplace(
constantsContent,
patterns.APP_PRIMARY_CATEGORY,
`export const APP_PRIMARY_CATEGORY = '${escapeString(
`export const APP_PRIMARY_CATEGORY: string = '${escapeString(
answers.primaryCategory || ''
)}';`,
'APP_PRIMARY_CATEGORY'
@@ -597,7 +693,7 @@ export async function init(projectName = null, autoAcceptDefaults = false, apiKe
constantsContent = safeReplace(
constantsContent,
patterns.APP_TAGS,
`export const APP_TAGS = ${tagsString};`,
`export const APP_TAGS: string[] = ${tagsString};`,
'APP_TAGS'
);
@@ -605,7 +701,7 @@ export async function init(projectName = null, autoAcceptDefaults = false, apiKe
constantsContent = safeReplace(
constantsContent,
patterns.APP_BUTTON_TEXT,
`export const APP_BUTTON_TEXT = '${escapeString(
`export const APP_BUTTON_TEXT: string = '${escapeString(
answers.buttonText || ''
)}';`,
'APP_BUTTON_TEXT'
@@ -615,7 +711,7 @@ export async function init(projectName = null, autoAcceptDefaults = false, apiKe
constantsContent = safeReplace(
constantsContent,
patterns.USE_WALLET,
`export const USE_WALLET = ${answers.useWallet};`,
`export const USE_WALLET: boolean = ${answers.useWallet};`,
'USE_WALLET'
);
@@ -623,19 +719,25 @@ export async function init(projectName = null, autoAcceptDefaults = false, apiKe
constantsContent = safeReplace(
constantsContent,
patterns.ANALYTICS_ENABLED,
`export const ANALYTICS_ENABLED = ${answers.enableAnalytics};`,
`export const ANALYTICS_ENABLED: boolean = ${answers.enableAnalytics};`,
'ANALYTICS_ENABLED'
);
// Update RETURN_URL
const returnUrlValue = answers.returnUrl ? `'${escapeString(answers.returnUrl)}'` : 'undefined';
constantsContent = safeReplace(
constantsContent,
patterns.RETURN_URL,
`export const RETURN_URL: string | undefined = ${returnUrlValue};`,
'RETURN_URL'
);
fs.writeFileSync(constantsPath, constantsContent);
} else {
console.log('⚠️ constants.ts not found, skipping constants update');
}
fs.appendFileSync(
envPath,
`\nNEXTAUTH_SECRET="${crypto.randomBytes(32).toString('hex')}"`
);
if (useNeynar && neynarApiKey && neynarClientId) {
fs.appendFileSync(envPath, `\nNEYNAR_API_KEY="${neynarApiKey}"`);
fs.appendFileSync(envPath, `\nNEYNAR_CLIENT_ID="${neynarClientId}"`);
@@ -645,9 +747,18 @@ export async function init(projectName = null, autoAcceptDefaults = false, apiKe
);
}
if (answers.seedPhrase) {
console.log('✅ Writing SEED_PHRASE and NEXTAUTH_SECRET to .env.local');
fs.appendFileSync(envPath, `\nSEED_PHRASE="${answers.seedPhrase}"`);
// Add NextAuth secret for SIWN
fs.appendFileSync(
envPath,
`\nNEXTAUTH_SECRET="${crypto.randomBytes(32).toString('hex')}"`
);
}
fs.appendFileSync(envPath, `\nUSE_TUNNEL="${answers.useTunnel}"`);
if (answers.useSponsoredSigner) {
fs.appendFileSync(envPath, `\nSPONSOR_SIGNER="true"`);
}
fs.unlinkSync(envExamplePath);
} else {
@@ -691,6 +802,87 @@ export async function init(projectName = null, autoAcceptDefaults = false, apiKe
fs.rmSync(binPath, { recursive: true, force: true });
}
// Handle SIWN-related files based on whether seed phrase is provided
if (!answers.seedPhrase) {
// Remove SIWN-related files when SIWN is not enabled (no seed phrase)
console.log('\nRemoving SIWN-related files (SIWN not enabled)...');
// Remove NeynarAuthButton directory
const neynarAuthButtonPath = path.join(projectPath, 'src', 'components', 'ui', 'NeynarAuthButton');
if (fs.existsSync(neynarAuthButtonPath)) {
fs.rmSync(neynarAuthButtonPath, { recursive: true, force: true });
}
// Remove NextAuth API routes
const nextAuthRoutePath = path.join(projectPath, 'src', 'app', 'api', 'auth', '[...nextauth]', 'route.ts');
if (fs.existsSync(nextAuthRoutePath)) {
fs.rmSync(nextAuthRoutePath, { force: true });
// Remove the directory if it's empty
const nextAuthDir = path.dirname(nextAuthRoutePath);
if (fs.readdirSync(nextAuthDir).length === 0) {
fs.rmSync(nextAuthDir, { recursive: true, force: true });
}
}
// Remove src/auth.ts file
const authFilePath = path.join(projectPath, 'src', 'auth.ts');
if (fs.existsSync(authFilePath)) {
fs.rmSync(authFilePath, { force: true });
}
// Remove SIWN-specific files
const actionsTabNeynarAuthPath = path.join(projectPath, 'src', 'components', 'ui', 'tabs', 'ActionsTab.NeynarAuth.tsx');
if (fs.existsSync(actionsTabNeynarAuthPath)) {
fs.rmSync(actionsTabNeynarAuthPath, { force: true });
}
const layoutNeynarAuthPath = path.join(projectPath, 'src', 'app', 'layout.NeynarAuth.tsx');
if (fs.existsSync(layoutNeynarAuthPath)) {
fs.rmSync(layoutNeynarAuthPath, { force: true });
}
const providersNeynarAuthPath = path.join(projectPath, 'src', 'app', 'providers.NeynarAuth.tsx');
if (fs.existsSync(providersNeynarAuthPath)) {
fs.rmSync(providersNeynarAuthPath, { force: true });
}
} else {
// Move SIWN-specific files to replace the regular versions when SIWN is enabled
console.log('\nMoving SIWN-specific files to replace regular versions (SIWN enabled)...');
// Move ActionsTab.NeynarAuth.tsx to ActionsTab.tsx
const actionsTabNeynarAuthPath = path.join(projectPath, 'src', 'components', 'ui', 'tabs', 'ActionsTab.NeynarAuth.tsx');
const actionsTabPath = path.join(projectPath, 'src', 'components', 'ui', 'tabs', 'ActionsTab.tsx');
if (fs.existsSync(actionsTabNeynarAuthPath)) {
if (fs.existsSync(actionsTabPath)) {
fs.rmSync(actionsTabPath, { force: true }); // Delete original
}
fs.renameSync(actionsTabNeynarAuthPath, actionsTabPath);
console.log('✅ Moved ActionsTab.NeynarAuth.tsx to ActionsTab.tsx');
}
// Move layout.NeynarAuth.tsx to layout.tsx
const layoutNeynarAuthPath = path.join(projectPath, 'src', 'app', 'layout.NeynarAuth.tsx');
const layoutPath = path.join(projectPath, 'src', 'app', 'layout.tsx');
if (fs.existsSync(layoutNeynarAuthPath)) {
if (fs.existsSync(layoutPath)) {
fs.rmSync(layoutPath, { force: true }); // Delete original
}
fs.renameSync(layoutNeynarAuthPath, layoutPath);
console.log('✅ Moved layout.NeynarAuth.tsx to layout.tsx');
}
// Move providers.NeynarAuth.tsx to providers.tsx
const providersNeynarAuthPath = path.join(projectPath, 'src', 'app', 'providers.NeynarAuth.tsx');
const providersPath = path.join(projectPath, 'src', 'app', 'providers.tsx');
if (fs.existsSync(providersNeynarAuthPath)) {
if (fs.existsSync(providersPath)) {
fs.rmSync(providersPath, { force: true }); // Delete original
}
fs.renameSync(providersNeynarAuthPath, providersPath);
console.log('✅ Moved providers.NeynarAuth.tsx to providers.tsx');
}
}
// Initialize git repository
console.log('\nInitializing git repository...');
execSync('git init', { cwd: projectPath });

1125
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "@neynar/create-farcaster-mini-app",
"version": "1.6.2",
"version": "1.8.13",
"type": "module",
"private": false,
"access": "public",
@@ -35,7 +35,7 @@
"build:raw": "next build",
"start": "next start",
"lint": "next lint",
"deploy:vercel": "ts-node scripts/deploy.ts",
"deploy:vercel": "tsx scripts/deploy.ts",
"deploy:raw": "vercel --prod",
"cleanup": "node scripts/cleanup.js"
},
@@ -51,5 +51,17 @@
"@neynar/nodejs-sdk": "^2.19.0",
"@types/node": "^22.13.10",
"typescript": "^5.6.3"
},
"overrides": {
"chalk": "5.3.0",
"strip-ansi": "6.0.1",
"wrap-ansi": "8.1.0",
"ansi-styles": "6.2.3",
"color-convert": "2.0.1",
"color-name": "1.1.4",
"is-core-module": "2.13.1",
"error-ex": "1.3.2",
"simple-swizzle": "0.2.2",
"has-ansi": "5.0.1"
}
}
}

View File

@@ -7,7 +7,7 @@ import inquirer from 'inquirer';
import dotenv from 'dotenv';
import crypto from 'crypto';
import { Vercel } from '@vercel/sdk';
import { APP_NAME, APP_BUTTON_TEXT } from '../src/lib/constants';
import { APP_NAME, APP_BUTTON_TEXT } from '../src/lib/constants.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.join(__dirname, '..');
@@ -73,18 +73,20 @@ async function checkRequiredEnvVars(): Promise<void> {
name: 'NEXT_PUBLIC_MINI_APP_NAME',
message: 'Enter the name for your frame (e.g., My Cool Mini App):',
default: APP_NAME,
validate: (input: string) => input.trim() !== '' || 'Mini app name cannot be empty'
validate: (input: string) =>
input.trim() !== '' || 'Mini app name cannot be empty',
},
{
name: 'NEXT_PUBLIC_MINI_APP_BUTTON_TEXT',
message: 'Enter the text for your frame button:',
default: APP_BUTTON_TEXT ?? 'Launch Mini App',
validate: (input: string) => input.trim() !== '' || 'Button text cannot be empty'
}
validate: (input: string) =>
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) {
@@ -110,38 +112,150 @@ async function checkRequiredEnvVars(): Promise<void> {
const newLine = envContent ? '\n' : '';
fs.appendFileSync(
'.env',
`${newLine}${varConfig.name}="${value.trim()}"`
`${newLine}${varConfig.name}="${value.trim()}"`,
);
}
}
// Ask about sponsor signer if SEED_PHRASE is provided
if (!process.env.SPONSOR_SIGNER) {
const { sponsorSigner } = await inquirer.prompt([
{
type: 'confirm',
name: 'sponsorSigner',
message:
'Do you want to sponsor the signer? (This will be used in Sign In With Neynar)\n' +
'Note: If you choose to sponsor the signer, Neynar will sponsor it for you and you will be charged in CUs.\n' +
'For more information, see https://docs.neynar.com/docs/two-ways-to-sponsor-a-farcaster-signer-via-neynar#sponsor-signers',
default: false,
// Ask about SIWN if SEED_PHRASE is provided (moved outside the loop)
if (process.env.SEED_PHRASE && !process.env.SPONSOR_SIGNER) {
const { sponsorSigner } = await inquirer.prompt([
{
type: 'confirm',
name: 'sponsorSigner',
message:
'You have provided a seed phrase, which enables Sign In With Neynar (SIWN).\n' +
'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();
fs.appendFileSync(
'.env.local',
`\nSPONSOR_SIGNER="${sponsorSigner}"`,
);
console.log('✅ Sponsor signer preference stored in .env.local');
}
// Ask about required chains (moved outside the loop)
const { useRequiredChains } = await inquirer.prompt([
{
type: 'confirm',
name: 'useRequiredChains',
message:
'Does your mini app require support for specific blockchains?\n' +
'If yes, the host will only render your mini app if it supports all the chains you specify.\n' +
'If no, the mini app will be rendered regardless of chain support.',
default: false,
},
]);
let requiredChains: string[] = [];
if (useRequiredChains) {
const { selectedChains } = await inquirer.prompt([
{
type: 'checkbox',
name: 'selectedChains',
message: 'Select the required chains (CAIP-2 identifiers):',
choices: [
{ name: 'Ethereum Mainnet (eip155:1)', value: 'eip155:1' },
{ name: 'Polygon (eip155:137)', value: 'eip155:137' },
{ name: 'Arbitrum One (eip155:42161)', value: 'eip155:42161' },
{ name: 'Optimism (eip155:10)', value: 'eip155:10' },
{ name: 'Base (eip155:8453)', value: 'eip155:8453' },
{ name: 'Solana (solana:mainnet)', value: 'solana:mainnet' },
{ name: 'Solana Devnet (solana:devnet)', value: 'solana:devnet' },
],
},
]);
requiredChains = selectedChains;
}
// Update constants.ts with required chains
const constantsPath = path.join(projectRoot, 'src', 'lib', 'constants.ts');
if (fs.existsSync(constantsPath)) {
let constantsContent = fs.readFileSync(constantsPath, 'utf8');
// Replace the APP_REQUIRED_CHAINS line
const requiredChainsString = JSON.stringify(requiredChains);
constantsContent = constantsContent.replace(
/^export const APP_REQUIRED_CHAINS\s*:\s*string\[\]\s*=\s*\[[^\]]*\];$/m,
`export const APP_REQUIRED_CHAINS: string[] = ${requiredChainsString};`,
);
fs.writeFileSync(constantsPath, constantsContent);
console.log('✅ Required chains updated in constants.ts');
}
// Ask for account association
console.log(
`\n⚠ To complete your mini app manifest, you need to sign it using the Farcaster developer portal.`,
);
console.log(
'1. Go to: https://farcaster.xyz/~/developers/mini-apps/manifest',
);
console.log(
'2. Enter your app domain (you\'ll get this after deployment)',
);
console.log(
'3. Click "Transfer Ownership" and follow the instructions to sign the manifest.',
);
console.log(
'4. Copy the resulting accountAssociation JSON.',
);
console.log('5. Paste it below when prompted.');
console.log(
'\nNote: If you don\'t have the accountAssociation yet, you can press Ctrl+C to skip and add it later.',
);
try {
const { userAccountAssociation } = await inquirer.prompt([
{
type: 'editor',
name: 'userAccountAssociation',
message: 'Paste the accountAssociation JSON here (or press Ctrl+C to skip):',
validate: (input: string) => {
if (!input.trim()) {
return 'You can press Ctrl+C to skip this step';
}
try {
const parsed = JSON.parse(input);
if (parsed.header && parsed.payload && parsed.signature) {
return true;
}
return 'Invalid accountAssociation: must have header, payload, and signature';
} catch (e) {
return 'Invalid JSON';
}
},
]);
},
]);
const parsedAccountAssociation = JSON.parse(userAccountAssociation);
process.env.SPONSOR_SIGNER = sponsorSigner.toString();
if (storeSeedPhrase) {
fs.appendFileSync(
'.env.local',
`\nSPONSOR_SIGNER="${sponsorSigner}"`
);
console.log('✅ Sponsor signer preference stored in .env.local');
}
// Write APP_ACCOUNT_ASSOCIATION to constants.ts
if (fs.existsSync(constantsPath)) {
let constantsContent = fs.readFileSync(constantsPath, 'utf8');
// Replace the APP_ACCOUNT_ASSOCIATION line
const newAccountAssociation = `export const APP_ACCOUNT_ASSOCIATION: AccountAssociation | undefined = ${JSON.stringify(parsedAccountAssociation, null, 2)};`;
constantsContent = constantsContent.replace(
/^export const APP_ACCOUNT_ASSOCIATION\s*:\s*AccountAssociation \| undefined\s*=\s*[^;]*;/m,
newAccountAssociation,
);
fs.writeFileSync(constantsPath, constantsContent);
console.log('✅ APP_ACCOUNT_ASSOCIATION updated in constants.ts');
}
} catch (error) {
console.log('\n Skipping account association for now. You can add it later by updating APP_ACCOUNT_ASSOCIATION in src/lib/constants.ts');
}
}
// Load SPONSOR_SIGNER from .env.local if SEED_PHRASE exists but SPONSOR_SIGNER doesn't
// Load SPONSOR_SIGNER from .env.local if SEED_PHRASE exists (SIWN enabled) but SPONSOR_SIGNER doesn't
if (
process.env.SEED_PHRASE &&
!process.env.SPONSOR_SIGNER &&
@@ -171,8 +285,8 @@ async function getGitRemote(): Promise<string | null> {
async function checkVercelCLI(): Promise<boolean> {
try {
execSync('vercel --version', {
stdio: 'ignore'
execSync('vercel --version', {
stdio: 'ignore',
});
return true;
} catch (error: unknown) {
@@ -185,8 +299,8 @@ async function checkVercelCLI(): Promise<boolean> {
async function installVercelCLI(): Promise<void> {
console.log('Installing Vercel CLI...');
execSync('npm install -g vercel', {
stdio: 'inherit'
execSync('npm install -g vercel', {
stdio: 'inherit',
});
}
@@ -222,7 +336,9 @@ async function getVercelToken(): Promise<string | null> {
return null; // We'll fall back to CLI operations
} catch (error: unknown) {
if (error instanceof Error) {
throw new Error('Not logged in to Vercel CLI. Please run this script again to login.');
throw new Error(
'Not logged in to Vercel CLI. Please run this script again to login.',
);
}
throw error;
}
@@ -239,7 +355,7 @@ async function loginToVercel(): Promise<boolean> {
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'], {
@@ -247,14 +363,14 @@ async function loginToVercel(): Promise<boolean> {
});
await new Promise<void>((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++) {
@@ -263,10 +379,13 @@ async function loginToVercel(): Promise<boolean> {
console.log('✅ Successfully logged in to Vercel!');
return true;
} catch (error: unknown) {
if (error instanceof Error && error.message.includes('Account not found')) {
if (
error instanceof Error &&
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));
}
}
@@ -277,7 +396,12 @@ async function loginToVercel(): Promise<boolean> {
return false;
}
async function setVercelEnvVarSDK(vercelClient: Vercel, projectId: string, key: string, value: string | object): Promise<boolean> {
async function setVercelEnvVarSDK(
vercelClient: Vercel,
projectId: string,
key: string,
value: string | object,
): Promise<boolean> {
try {
let processedValue: string;
if (typeof value === 'object') {
@@ -287,17 +411,26 @@ async function setVercelEnvVarSDK(vercelClient: Vercel, projectId: string, key:
}
// Get existing environment variables
const existingVars = await vercelClient.projects.getEnvironmentVariables({
const existingVars = await vercelClient.projects.filterProjectEnvs({
idOrName: projectId,
});
const existingVar = existingVars.envs?.find((env: any) =>
env.key === key && env.target?.includes('production')
// Handle different response types
let envs: any[] = [];
if ('envs' in existingVars && Array.isArray(existingVars.envs)) {
envs = existingVars.envs;
} else if ('target' in existingVars && 'key' in existingVars) {
// Single environment variable response
envs = [existingVars];
}
const existingVar = envs.find(
(env: any) => env.key === key && env.target?.includes('production'),
);
if (existingVar) {
if (existingVar && existingVar.id) {
// Update existing variable
await vercelClient.projects.editEnvironmentVariable({
await vercelClient.projects.editProjectEnv({
idOrName: projectId,
id: existingVar.id,
requestBody: {
@@ -308,7 +441,7 @@ async function setVercelEnvVarSDK(vercelClient: Vercel, projectId: string, key:
console.log(`✅ Updated environment variable: ${key}`);
} else {
// Create new variable
await vercelClient.projects.createEnvironmentVariable({
await vercelClient.projects.createProjectEnv({
idOrName: projectId,
requestBody: {
key: key,
@@ -323,14 +456,21 @@ async function setVercelEnvVarSDK(vercelClient: Vercel, projectId: string, key:
return true;
} catch (error: unknown) {
if (error instanceof Error) {
console.warn(`⚠️ Warning: Failed to set environment variable ${key}:`, error.message);
console.warn(
`⚠️ Warning: Failed to set environment variable ${key}:`,
error.message,
);
return false;
}
throw error;
}
}
async function setVercelEnvVarCLI(key: string, value: string | object, projectRoot: string): Promise<boolean> {
async function setVercelEnvVarCLI(
key: string,
value: string | object,
projectRoot: string,
): Promise<boolean> {
try {
// Remove existing env var
try {
@@ -365,7 +505,7 @@ async function setVercelEnvVarCLI(key: string, value: string | object, projectRo
execSync(command, {
cwd: projectRoot,
stdio: 'pipe', // Changed from 'inherit' to avoid interactive prompts
env: process.env
env: process.env,
});
fs.unlinkSync(tempFilePath);
@@ -377,18 +517,26 @@ async function setVercelEnvVarCLI(key: string, value: string | object, projectRo
fs.unlinkSync(tempFilePath);
}
if (error instanceof Error) {
console.warn(`⚠️ Warning: Failed to set environment variable ${key}:`, error.message);
console.warn(
`⚠️ Warning: Failed to set environment variable ${key}:`,
error.message,
);
return false;
}
throw error;
}
}
async function setEnvironmentVariables(vercelClient: Vercel | null, projectId: string | null, envVars: Record<string, string | object>, projectRoot: string): Promise<Array<{ key: string; success: boolean }>> {
async function setEnvironmentVariables(
vercelClient: Vercel | null,
projectId: string | null,
envVars: Record<string, string | object>,
projectRoot: string,
): Promise<Array<{ key: string; success: boolean }>> {
console.log('\n📝 Setting up environment variables...');
const results: Array<{ key: string; success: boolean }> = [];
for (const [key, value] of Object.entries(envVars)) {
if (!value) continue;
@@ -408,29 +556,34 @@ async function setEnvironmentVariables(vercelClient: Vercel | null, projectId: s
}
// 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: Vercel | null, projectId: string, maxWaitTime = 300000): Promise<any> { // 5 minutes
async function waitForDeployment(
vercelClient: Vercel | null,
projectId: string,
maxWaitTime = 300000,
): Promise<any> {
// 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({
const deployments = await vercelClient?.deployments.getDeployments({
projectId: projectId,
limit: 1,
});
if (deployments?.deployments?.[0]) {
const deployment = deployments.deployments[0];
console.log(`📊 Deployment status: ${deployment.state}`);
@@ -445,10 +598,10 @@ async function waitForDeployment(vercelClient: Vercel | null, projectId: string,
}
// Still building, wait and check again
await new Promise((resolve) => setTimeout(resolve, 5000)); // Wait 5 seconds
await new Promise(resolve => setTimeout(resolve, 5000)); // Wait 5 seconds
} else {
console.log('⏳ No deployment found yet, waiting...');
await new Promise((resolve) => setTimeout(resolve, 5000));
await new Promise(resolve => setTimeout(resolve, 5000));
}
} catch (error: unknown) {
if (error instanceof Error) {
@@ -478,58 +631,60 @@ async function deployToVercel(useGitHub = false): Promise<void> {
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',
);
// 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' ? true : undefined
});
const vercelSetup = spawn('vercel', [], {
cwd: projectRoot,
stdio: 'inherit',
shell: process.platform === 'win32' ? true : undefined,
});
await new Promise<void>((resolve, reject) => {
vercelSetup.on('close', (code) => {
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)');
console.log('⚠️ Vercel setup command completed (this is normal)');
resolve(); // Don't reject, as this is often expected
}
});
vercelSetup.on('error', (error) => {
vercelSetup.on('error', error => {
console.log('⚠️ Vercel setup command completed (this is normal)');
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));
await new Promise(resolve => setTimeout(resolve, 2000));
// Load project info
let projectId: string;
try {
const projectJson = JSON.parse(
fs.readFileSync('.vercel/project.json', 'utf8')
fs.readFileSync('.vercel/project.json', 'utf8'),
);
projectId = projectJson.projectId;
} catch (error: unknown) {
if (error instanceof Error) {
throw new Error('Failed to load project info. Please ensure the Vercel project was created successfully.');
throw new Error(
'Failed to load project info. Please ensure the Vercel project was created successfully.',
);
}
throw error;
}
@@ -540,13 +695,15 @@ async function deployToVercel(useGitHub = false): Promise<void> {
const token = await getVercelToken();
if (token) {
vercelClient = new Vercel({
bearerToken: token
bearerToken: token,
});
console.log('✅ Initialized Vercel SDK client');
}
} catch (error: unknown) {
if (error instanceof Error) {
console.warn('⚠️ Could not initialize Vercel SDK, falling back to CLI operations');
console.warn(
'⚠️ Could not initialize Vercel SDK, falling back to CLI operations',
);
}
throw error;
}
@@ -558,15 +715,22 @@ async function deployToVercel(useGitHub = false): Promise<void> {
if (vercelClient) {
try {
const project = await vercelClient.projects.get({
idOrName: projectId,
});
projectName = project.name;
domain = `${projectName}.vercel.app`;
console.log('🌐 Using project name for domain:', domain);
const projects = await vercelClient.projects.getProjects({});
const project = projects.projects.find(
(p: any) => p.id === projectId || p.name === projectId,
);
if (project) {
projectName = project.name;
domain = `${projectName}.vercel.app`;
console.log('🌐 Using project name for domain:', domain);
} else {
throw new Error('Project not found');
}
} catch (error: unknown) {
if (error instanceof Error) {
console.warn('⚠️ Could not get project details via SDK, using CLI fallback');
console.warn(
'⚠️ Could not get project details via SDK, using CLI fallback',
);
}
throw error;
}
@@ -580,7 +744,7 @@ async function deployToVercel(useGitHub = false): Promise<void> {
{
cwd: projectRoot,
encoding: 'utf8',
}
},
);
const nameMatch = inspectOutput.match(/Name\s+([^\n]+)/);
@@ -596,7 +760,7 @@ async function deployToVercel(useGitHub = false): Promise<void> {
console.log('🌐 Using project name for domain:', domain);
} else {
console.warn(
'⚠️ Could not determine project name from inspection, using fallback'
'⚠️ Could not determine project name from inspection, using fallback',
);
// Use a fallback domain based on project ID
domain = `project-${projectId.slice(-8)}.vercel.app`;
@@ -618,19 +782,30 @@ async function deployToVercel(useGitHub = false): Promise<void> {
const nextAuthSecret =
process.env.NEXTAUTH_SECRET || crypto.randomBytes(32).toString('hex');
const vercelEnv = {
NEXTAUTH_SECRET: nextAuthSecret,
AUTH_SECRET: nextAuthSecret,
NEXTAUTH_URL: `https://${domain}`,
NEXT_PUBLIC_URL: `https://${domain}`,
...(process.env.NEYNAR_API_KEY && { NEYNAR_API_KEY: process.env.NEYNAR_API_KEY }),
...(process.env.NEYNAR_CLIENT_ID && { NEYNAR_CLIENT_ID: process.env.NEYNAR_CLIENT_ID }),
...(process.env.SPONSOR_SIGNER && { SPONSOR_SIGNER: process.env.SPONSOR_SIGNER }),
...(process.env.NEYNAR_API_KEY && {
NEYNAR_API_KEY: process.env.NEYNAR_API_KEY,
}),
...(process.env.NEYNAR_CLIENT_ID && {
NEYNAR_CLIENT_ID: process.env.NEYNAR_CLIENT_ID,
}),
...(process.env.SPONSOR_SIGNER && {
SPONSOR_SIGNER: process.env.SPONSOR_SIGNER,
}),
// Include NextAuth environment variables if SEED_PHRASE is present (SIWN enabled)
...(process.env.SEED_PHRASE && {
SEED_PHRASE: process.env.SEED_PHRASE,
NEXTAUTH_SECRET: nextAuthSecret,
AUTH_SECRET: nextAuthSecret,
NEXTAUTH_URL: `https://${domain}`,
}),
...Object.fromEntries(
Object.entries(process.env).filter(([key]) =>
key.startsWith('NEXT_PUBLIC_')
)
key.startsWith('NEXT_PUBLIC_'),
),
),
};
@@ -639,7 +814,7 @@ async function deployToVercel(useGitHub = false): Promise<void> {
vercelClient,
projectId,
vercelEnv,
projectRoot
projectRoot,
);
// Deploy the project
@@ -663,7 +838,7 @@ async function deployToVercel(useGitHub = false): Promise<void> {
});
await new Promise<void>((resolve, reject) => {
vercelDeploy.on('close', (code) => {
vercelDeploy.on('close', code => {
if (code === 0) {
console.log('✅ Vercel deployment command completed');
resolve();
@@ -673,7 +848,7 @@ async function deployToVercel(useGitHub = false): Promise<void> {
}
});
vercelDeploy.on('error', (error) => {
vercelDeploy.on('error', error => {
console.error('❌ Vercel deployment error:', error.message);
reject(error);
});
@@ -686,7 +861,10 @@ async function deployToVercel(useGitHub = false): Promise<void> {
deployment = await waitForDeployment(vercelClient, projectId);
} catch (error: unknown) {
if (error instanceof Error) {
console.warn('⚠️ Could not verify deployment completion:', error.message);
console.warn(
'⚠️ Could not verify deployment completion:',
error.message,
);
console.log(' Proceeding with domain verification...');
}
throw error;
@@ -700,10 +878,12 @@ async function deployToVercel(useGitHub = false): Promise<void> {
if (vercelClient && deployment) {
try {
actualDomain = deployment.url || domain;
console.log('🌐 Verified actual domain:', actualDomain);
console.log('🌐 Verified actual domain:', actualDomain);
} catch (error: unknown) {
if (error instanceof Error) {
console.warn('⚠️ Could not verify domain via SDK, using assumed domain');
console.warn(
'⚠️ Could not verify domain via SDK, using assumed domain',
);
}
throw error;
}
@@ -714,11 +894,20 @@ async function deployToVercel(useGitHub = false): Promise<void> {
console.log('🔄 Updating environment variables with correct domain...');
const updatedEnv: Record<string, string | object> = {
NEXTAUTH_URL: `https://${actualDomain}`,
NEXT_PUBLIC_URL: `https://${actualDomain}`,
};
await setEnvironmentVariables(vercelClient, projectId, updatedEnv, projectRoot);
// Include NextAuth URL if SEED_PHRASE is present (SIWN enabled)
if (process.env.SEED_PHRASE) {
updatedEnv.NEXTAUTH_URL = `https://${actualDomain}`;
}
await setEnvironmentVariables(
vercelClient,
projectId,
updatedEnv,
projectRoot,
);
console.log('\n📦 Redeploying with correct domain...');
const vercelRedeploy = spawn('vercel', ['deploy', '--prod'], {
@@ -728,7 +917,7 @@ async function deployToVercel(useGitHub = false): Promise<void> {
});
await new Promise<void>((resolve, reject) => {
vercelRedeploy.on('close', (code) => {
vercelRedeploy.on('close', code => {
if (code === 0) {
console.log('✅ Redeployment completed');
resolve();
@@ -738,7 +927,7 @@ async function deployToVercel(useGitHub = false): Promise<void> {
}
});
vercelRedeploy.on('error', (error) => {
vercelRedeploy.on('error', error => {
console.error('❌ Redeployment error:', error.message);
reject(error);
});
@@ -749,48 +938,20 @@ async function deployToVercel(useGitHub = false): Promise<void> {
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');
// Prompt user to sign manifest in browser and paste accountAssociation
console.log(`\n⚠ To complete your mini app manifest, you must sign it using the Farcaster developer portal.`);
console.log('1. Go to: https://farcaster.xyz/~/developers/mini-apps/manifest?domain=' + domain);
console.log('2. Click "Transfer Ownership" and follow the instructions to sign the manifest.');
console.log('3. Copy the resulting accountAssociation JSON from the browser.');
console.log('4. Paste it below when prompted.');
const { userAccountAssociation } = await inquirer.prompt([
{
type: 'editor',
name: 'userAccountAssociation',
message: 'Paste the accountAssociation JSON here:',
validate: (input: string) => {
try {
const parsed = JSON.parse(input);
if (parsed.header && parsed.payload && parsed.signature) {
return true;
}
return 'Invalid accountAssociation: must have header, payload, and signature';
} catch (e) {
return 'Invalid JSON';
}
}
}
]);
const parsedAccountAssociation = JSON.parse(userAccountAssociation);
// Write APP_ACCOUNT_ASSOCIATION to src/lib/constants.ts
const constantsPath = path.join(projectRoot, 'src', 'lib', 'constants.ts');
let constantsContent = fs.readFileSync(constantsPath, 'utf8');
// Replace the APP_ACCOUNT_ASSOCIATION line using a robust, anchored, multiline regex
const newAccountAssociation = `export const APP_ACCOUNT_ASSOCIATION: AccountAssociation | undefined = ${JSON.stringify(parsedAccountAssociation, null, 2)};`;
constantsContent = constantsContent.replace(
/^export const APP_ACCOUNT_ASSOCIATION\s*:\s*AccountAssociation \| undefined\s*=\s*[^;]*;/m,
newAccountAssociation
console.log(
'\n📝 You can manage your project at https://vercel.com/dashboard',
);
// Remind user about account association if not already set
console.log(
`\n💡 Remember: If you haven't already signed your manifest, go to:`,
);
console.log(
` https://farcaster.xyz/~/developers/mini-apps/manifest?domain=${domain}`,
);
console.log(
' to complete the ownership transfer and update APP_ACCOUNT_ASSOCIATION in src/lib/constants.ts',
);
fs.writeFileSync(constantsPath, constantsContent);
console.log('\n✅ APP_ACCOUNT_ASSOCIATION updated in src/lib/constants.ts');
} catch (error: unknown) {
if (error instanceof Error) {
console.error('\n❌ Deployment failed:', error.message);
@@ -804,7 +965,7 @@ async function main(): Promise<void> {
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');
@@ -818,9 +979,9 @@ async function main(): Promise<void> {
} catch (error: unknown) {
if (error instanceof Error) {
console.log('📦 Installing @vercel/sdk...');
execSync('npm install @vercel/sdk', {
execSync('npm install @vercel/sdk', {
cwd: projectRoot,
stdio: 'inherit'
stdio: 'inherit',
});
console.log('✅ @vercel/sdk installed successfully');
}
@@ -880,7 +1041,6 @@ async function main(): Promise<void> {
}
await deployToVercel(useGitHub);
} catch (error: unknown) {
if (error instanceof Error) {
console.error('\n❌ Error:', error.message);
@@ -890,4 +1050,4 @@ async function main(): Promise<void> {
}
}
main();
main();

View File

@@ -7,6 +7,7 @@ export async function GET() {
return NextResponse.json(config);
} catch (error) {
console.error('Error generating metadata:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
return NextResponse.json({ error: errorMessage }, { status: 500 });
}
}

View File

@@ -1,46 +0,0 @@
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

@@ -0,0 +1,46 @@
import { NextResponse } from 'next/server';
import { createClient, Errors } from '@farcaster/quick-auth';
const client = createClient();
export async function POST(request: Request) {
try {
const { token } = await request.json();
if (!token) {
return NextResponse.json({ error: 'Token is required' }, { status: 400 });
}
// Get domain from environment or request
const domain = process.env.NEXT_PUBLIC_URL
? new URL(process.env.NEXT_PUBLIC_URL).hostname
: request.headers.get('host') || 'localhost';
try {
// Use the official QuickAuth library to verify the JWT
const payload = await client.verifyJwt({
token,
domain,
});
return NextResponse.json({
success: true,
user: {
fid: payload.sub,
},
});
} catch (e) {
if (e instanceof Errors.InvalidTokenError) {
console.info('Invalid token:', e.message);
return NextResponse.json({ error: 'Invalid token' }, { status: 401 });
}
throw e;
}
} catch (error) {
console.error('Token validation error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 },
);
}
}

View File

@@ -56,7 +56,7 @@ 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 "miniapp_added":
if (event.notificationDetails) {
await setUserNotificationDetails(fid, event.notificationDetails);
await sendMiniAppNotification({
@@ -69,7 +69,7 @@ export async function POST(request: NextRequest) {
}
break;
case "frame_removed":
case "miniapp_removed":
await deleteUserNotificationDetails(fid);
break;

View File

@@ -0,0 +1,29 @@
import type { Metadata } from 'next';
import { getSession } from '~/auth';
import '~/app/globals.css';
import { Providers } from '~/app/providers';
import { APP_NAME, APP_DESCRIPTION } from '~/lib/constants';
export const metadata: Metadata = {
title: APP_NAME,
description: APP_DESCRIPTION,
};
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const session = await getSession();
return (
<html lang="en">
<body>
<Providers session={session}>
{children}
</Providers>
</body>
</html>
);
}

View File

@@ -1,9 +1,8 @@
import type { Metadata } from "next";
import type { Metadata } from 'next';
import { getSession } from "~/auth"
import "~/app/globals.css";
import { Providers } from "~/app/providers";
import { APP_NAME, APP_DESCRIPTION } from "~/lib/constants";
import '~/app/globals.css';
import { Providers } from '~/app/providers';
import { APP_NAME, APP_DESCRIPTION } from '~/lib/constants';
export const metadata: Metadata = {
title: APP_NAME,
@@ -14,13 +13,13 @@ export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const session = await getSession()
}>) {
return (
<html lang="en">
<body>
<Providers session={session}>{children}</Providers>
<Providers>
{children}
</Providers>
</body>
</html>
);

View File

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

View File

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

View File

@@ -217,74 +217,6 @@ function getDomainFromUrl(urlString: string | undefined): string {
export const authOptions: AuthOptions = {
// Configure one or more authentication providers
providers: [
CredentialsProvider({
id: 'farcaster',
name: 'Sign in with Farcaster',
credentials: {
message: {
label: 'Message',
type: 'text',
placeholder: '0x0',
},
signature: {
label: 'Signature',
type: 'text',
placeholder: '0x0',
},
nonce: {
label: 'Nonce',
type: 'text',
placeholder: 'Custom nonce (optional)',
},
// In a production app with a server, these should be fetched from
// your Farcaster data indexer rather than have them accepted as part
// of credentials.
// question: should these natively use the Neynar API?
name: {
label: 'Name',
type: 'text',
placeholder: '0x0',
},
pfp: {
label: 'Pfp',
type: 'text',
placeholder: '0x0',
},
},
async authorize(credentials, req) {
const nonce = req?.body?.csrfToken;
if (!nonce) {
console.error('No nonce or CSRF token provided');
return null;
}
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;
}
return {
id: fid.toString(),
name: credentials?.name || `User ${fid}`,
image: credentials?.pfp || null,
provider: 'farcaster',
};
},
}),
CredentialsProvider({
id: 'neynar',
name: 'Sign in with Neynar',
@@ -333,10 +265,18 @@ export const authOptions: AuthOptions = {
try {
// Validate the signature using Farcaster's auth client (same as Farcaster provider)
const appClient = createAppClient({
// USE your own RPC URL or else you might get 401 error
ethereum: viemConnector(),
});
const domain = getDomainFromUrl(process.env.NEXTAUTH_URL);
const baseUrl =
process.env.VERCEL_ENV === 'production'
? `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`
: process.env.VERCEL_URL
? `https://${process.env.VERCEL_URL}`
: process.env.NEXTAUTH_URL || `http://localhost:${process.env.PORT ?? 3000}`;
const domain = getDomainFromUrl(baseUrl);
const verifyResponse = await appClient.verifySignInMessage({
message: credentials?.message as string,
@@ -377,12 +317,7 @@ export const authOptions: AuthOptions = {
// 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') {
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;
@@ -404,26 +339,26 @@ export const authOptions: AuthOptions = {
name: `next-auth.session-token`,
options: {
httpOnly: true,
sameSite: 'none',
sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax',
path: '/',
secure: true,
secure: process.env.NODE_ENV === 'production',
},
},
callbackUrl: {
name: `next-auth.callback-url`,
options: {
sameSite: 'none',
sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax',
path: '/',
secure: true,
secure: process.env.NODE_ENV === 'production',
},
},
csrfToken: {
name: `next-auth.csrf-token`,
options: {
httpOnly: true,
sameSite: 'none',
sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax',
path: '/',
secure: true,
secure: process.env.NODE_ENV === 'production',
},
},
},

View File

@@ -1,5 +1,12 @@
'use client';
/**
* This authentication system is designed to work both in a regular web browser and inside a miniapp.
* In other words, it supports authentication when the miniapp context is not present (web browser) as well as when the app is running inside the miniapp.
* If you only need authentication for a web application, follow the Webapp flow;
* if you only need authentication inside a miniapp, follow the Miniapp flow.
*/
import '@farcaster/auth-kit/styles.css';
import { useSignIn, UseSignInData } from '@farcaster/auth-kit';
import { useCallback, useEffect, useState, useRef } from 'react';
@@ -10,11 +17,11 @@ 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,
signIn as miniappSignIn,
signOut as miniappSignOut,
useSession,
} from 'next-auth/react';
import sdk, { SignIn as SignInCore } from '@farcaster/frame-sdk';
import sdk, { SignIn as SignInCore } from '@farcaster/miniapp-sdk';
type User = {
fid: number;
@@ -116,7 +123,7 @@ export function NeynarAuthButton() {
const signerFlowStartedRef = useRef(false);
// Determine which flow to use based on context
const useBackendFlow = context !== undefined;
const useMiniappFlow = context !== undefined;
// Helper function to create a signer
const createSigner = useCallback(async () => {
@@ -137,16 +144,16 @@ export function NeynarAuthButton() {
}
}, []);
// Helper function to update session with signers (backend flow only)
// Helper function to update session with signers (miniapp flow only)
const updateSessionWithSigners = useCallback(
async (
signers: StoredAuthState['signers'],
user: StoredAuthState['user']
) => {
if (!useBackendFlow) return;
if (!useMiniappFlow) return;
try {
// For backend flow, we need to sign in again with the additional data
// For miniapp flow, we need to sign in again with the additional data
if (message && signature) {
const signInData = {
message,
@@ -158,13 +165,13 @@ export function NeynarAuthButton() {
user: JSON.stringify(user),
};
await backendSignIn('neynar', signInData);
await miniappSignIn('neynar', signInData);
}
} catch (error) {
console.error('❌ Error updating session with signers:', error);
}
},
[useBackendFlow, message, signature, nonce]
[useMiniappFlow, message, signature, nonce]
);
// Helper function to fetch user data from Neynar API
@@ -231,7 +238,7 @@ export function NeynarAuthButton() {
try {
setSignersLoading(true);
const endpoint = useBackendFlow
const endpoint = useMiniappFlow
? `/api/auth/session-signers?message=${encodeURIComponent(
message
)}&signature=${signature}`
@@ -243,8 +250,8 @@ export function NeynarAuthButton() {
const signerData = await response.json();
if (response.ok) {
if (useBackendFlow) {
// For backend flow, update session with signers
if (useMiniappFlow) {
// For miniapp flow, update session with signers
if (signerData.signers && signerData.signers.length > 0) {
const user =
signerData.user ||
@@ -253,7 +260,7 @@ export function NeynarAuthButton() {
}
return signerData.signers;
} else {
// For frontend flow, store in localStorage
// For webapp flow, store in localStorage
let user: StoredAuthState['user'] | null = null;
if (signerData.signers && signerData.signers.length > 0) {
@@ -285,7 +292,7 @@ export function NeynarAuthButton() {
setSignersLoading(false);
}
},
[useBackendFlow, fetchUserData, updateSessionWithSigners]
[useMiniappFlow, fetchUserData, updateSessionWithSigners]
);
// Helper function to poll signer status
@@ -308,7 +315,7 @@ export function NeynarAuthButton() {
setPollingInterval(null);
return;
}
try {
const response = await fetch(
`/api/auth/signer?signerUuid=${signerUuid}`
@@ -321,7 +328,7 @@ export function NeynarAuthButton() {
setPollingInterval(null);
return;
}
// Increment retry count for other errors
retryCount++;
if (retryCount >= maxRetries) {
@@ -329,7 +336,7 @@ export function NeynarAuthButton() {
setPollingInterval(null);
return;
}
throw new Error(`Failed to poll signer status: ${response.status}`);
}
@@ -384,21 +391,21 @@ export function NeynarAuthButton() {
generateNonce();
}, []);
// Load stored auth state on mount (only for frontend flow)
// Load stored auth state on mount (only for webapp flow)
useEffect(() => {
if (!useBackendFlow) {
if (!useMiniappFlow) {
const stored = getItem<StoredAuthState>(STORAGE_KEY);
if (stored && stored.isAuthenticated) {
setStoredAuth(stored);
}
}
}, [useBackendFlow]);
}, [useMiniappFlow]);
// Success callback - this is critical!
const onSuccessCallback = useCallback(
async (res: UseSignInData) => {
if (!useBackendFlow) {
// Only handle localStorage for frontend flow
if (!useMiniappFlow) {
// Only handle localStorage for webapp flow
const existingAuth = getItem<StoredAuthState>(STORAGE_KEY);
const user = res.fid ? await fetchUserData(res.fid) : null;
const authState: StoredAuthState = {
@@ -410,9 +417,9 @@ export function NeynarAuthButton() {
setItem<StoredAuthState>(STORAGE_KEY, authState);
setStoredAuth(authState);
}
// For backend flow, the session will be handled by NextAuth
// For miniapp flow, the session will be handled by NextAuth
},
[useBackendFlow, fetchUserData]
[useMiniappFlow, fetchUserData]
);
// Error callback
@@ -427,8 +434,8 @@ export function NeynarAuthButton() {
});
const {
signIn: frontendSignIn,
signOut: frontendSignOut,
signIn: webappSignIn,
signOut: webappSignOut,
connect,
reconnect,
isSuccess,
@@ -443,25 +450,30 @@ export function NeynarAuthButton() {
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
// Connect for webapp flow when nonce is available
useEffect(() => {
if (!useBackendFlow && nonce && !channelToken) {
if (!useMiniappFlow && nonce && !channelToken) {
connect();
}
}, [useBackendFlow, nonce, channelToken, connect]);
}, [useMiniappFlow, nonce, channelToken, connect]);
// Handle fetching signers after successful authentication
useEffect(() => {
if (message && signature && !isSignerFlowRunning && !signerFlowStartedRef.current) {
if (
message &&
signature &&
!isSignerFlowRunning &&
!signerFlowStartedRef.current
) {
signerFlowStartedRef.current = true;
const handleSignerFlow = async () => {
setIsSignerFlowRunning(true);
try {
@@ -473,14 +485,14 @@ export function NeynarAuthButton() {
// Step 1: Change to loading state
setDialogStep('loading');
// Show dialog if not using backend flow or in browser farcaster
if ((useBackendFlow && !isMobileContext) || !useBackendFlow)
// Show dialog if not using miniapp flow or in browser farcaster
if ((useMiniappFlow && !isMobileContext) || !useMiniappFlow)
setShowDialog(true);
// First, fetch existing signers
const signers = await fetchAllSigners(message, signature);
if (useBackendFlow && isMobileContext) setSignersLoading(true);
if (useMiniappFlow && isMobileContext) setSignersLoading(true);
// Check if no signers exist or if we have empty signers
if (!signers || signers.length === 0) {
@@ -533,10 +545,10 @@ export function NeynarAuthButton() {
}
}, [message, signature]); // Simplified dependencies
// Backend flow using NextAuth
const handleBackendSignIn = useCallback(async () => {
// Miniapp flow using NextAuth
const handleMiniappSignIn = useCallback(async () => {
if (!nonce) {
console.error('❌ No nonce available for backend sign-in');
console.error('❌ No nonce available for miniapp sign-in');
return;
}
@@ -551,7 +563,7 @@ export function NeynarAuthButton() {
nonce: nonce,
};
const nextAuthResult = await backendSignIn('neynar', signInData);
const nextAuthResult = await miniappSignIn('neynar', signInData);
if (nextAuthResult?.ok) {
setMessage(result.message);
setSignature(result.signature);
@@ -562,32 +574,34 @@ export function NeynarAuthButton() {
if (e instanceof SignInCore.RejectedByUser) {
console.log(' Sign-in rejected by user');
} else {
console.error('❌ Backend sign-in error:', e);
console.error('❌ Miniapp sign-in error:', e);
}
} finally {
setSignersLoading(false);
}
}, [nonce]);
const handleFrontEndSignIn = useCallback(() => {
const handleWebappSignIn = useCallback(() => {
if (isError) {
reconnect();
}
setDialogStep('signin');
setShowDialog(true);
frontendSignIn();
}, [isError, reconnect, frontendSignIn]);
webappSignIn();
}, [isError, reconnect, webappSignIn]);
const handleSignOut = useCallback(async () => {
try {
setSignersLoading(true);
if (useBackendFlow) {
if (useMiniappFlow) {
// Only sign out from NextAuth if the current session is from Neynar provider
if (session?.provider === 'neynar') {
await backendSignOut({ redirect: false });
await miniappSignOut({ redirect: false });
}
} else {
// Frontend flow sign out
frontendSignOut();
// Webapp flow sign out
webappSignOut();
removeItem(STORAGE_KEY);
setStoredAuth(null);
}
@@ -604,7 +618,7 @@ export function NeynarAuthButton() {
clearInterval(pollingInterval);
setPollingInterval(null);
}
// Reset signer flow flag
signerFlowStartedRef.current = false;
} catch (error) {
@@ -613,9 +627,9 @@ export function NeynarAuthButton() {
} finally {
setSignersLoading(false);
}
}, [useBackendFlow, frontendSignOut, pollingInterval, session]);
}, [useMiniappFlow, webappSignOut, pollingInterval, session]);
const authenticated = useBackendFlow
const authenticated = useMiniappFlow
? !!(
session?.provider === 'neynar' &&
session?.user?.fid &&
@@ -625,7 +639,7 @@ export function NeynarAuthButton() {
: ((isSuccess && validSignature) || storedAuth?.isAuthenticated) &&
!!(storedAuth?.signers && storedAuth.signers.length > 0);
const userData = useBackendFlow
const userData = useMiniappFlow
? {
fid: session?.user?.fid,
username: session?.user?.username || '',
@@ -657,16 +671,16 @@ export function NeynarAuthButton() {
<ProfileButton userData={userData} onSignOut={handleSignOut} />
) : (
<Button
onClick={useBackendFlow ? handleBackendSignIn : handleFrontEndSignIn}
disabled={!useBackendFlow && !url}
onClick={useMiniappFlow ? handleMiniappSignIn : handleWebappSignIn}
disabled={!useMiniappFlow && !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'
!url && !useMiniappFlow && 'cursor-not-allowed'
)}
>
{!useBackendFlow && !url ? (
{!useMiniappFlow && !url ? (
<>
<div className="spinner-primary w-5 h-5" />
<span>Initializing...</span>

View File

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

View File

@@ -1,13 +1,12 @@
'use client';
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 { APP_URL } from "~/lib/constants";
import { NeynarAuthButton } from '../NeynarAuthButton/index';
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 { APP_URL } from '~/lib/constants';
/**
* ActionsTab component handles mini app actions like sharing, notifications, and haptic feedback.
@@ -124,48 +123,45 @@ 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,
embeds: [`${APP_URL}/share/${context?.user?.fid || ''}`]
embeds: [`${APP_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>
@@ -174,14 +170,14 @@ 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
@@ -191,7 +187,7 @@ export function ActionsTab() {
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>
@@ -199,7 +195,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

@@ -1,22 +1,20 @@
'use client';
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";
import { useCallback, useState } from 'react';
import { SignIn as SignInCore } from '@farcaster/miniapp-sdk';
import { useQuickAuth } from '~/hooks/useQuickAuth';
import { Button } from '../Button';
/**
* SignIn component handles Farcaster authentication using Sign-In with Farcaster (SIWF).
* SignIn component handles Farcaster authentication using QuickAuth.
*
* This component provides a complete authentication flow for Farcaster users:
* - Generates nonces for secure authentication
* - Handles the SIWF flow using the Farcaster SDK
* - Manages NextAuth session state
* - Uses the built-in QuickAuth functionality from the Farcaster SDK
* - Manages authentication state in memory (no persistence)
* - Provides sign-out functionality
* - Displays authentication status and results
*
* The component integrates with both the Farcaster Frame SDK and NextAuth
* The component integrates with the Farcaster Frame SDK and QuickAuth
* to provide seamless authentication within mini apps.
*
* @example
@@ -36,52 +34,32 @@ export function SignIn() {
signingIn: false,
signingOut: false,
});
const [signInResult, setSignInResult] = useState<SignInCore.SignInResult>();
const [signInFailure, setSignInFailure] = useState<string>();
// --- Hooks ---
const { data: session, status } = useSession();
const { authenticatedUser, status, signIn, signOut } = useQuickAuth();
// --- Handlers ---
/**
* Generates a nonce for the sign-in process.
* Handles the sign-in process using QuickAuth.
*
* This function retrieves a CSRF token from NextAuth to use as a nonce
* for the SIWF authentication flow. The nonce ensures the authentication
* request is fresh and prevents replay attacks.
*
* @returns Promise<string> - The generated nonce token
* @throws Error if unable to generate nonce
*/
const getNonce = useCallback(async () => {
const nonce = await getCsrfToken();
if (!nonce) throw new Error('Unable to generate nonce');
return nonce;
}, []);
/**
* Handles the sign-in process using Farcaster SDK.
*
* This function orchestrates the complete SIWF flow:
* 1. Generates a nonce for security
* 2. Calls the Farcaster SDK to initiate sign-in
* 3. Submits the result to NextAuth for session management
* 4. Handles various error conditions including user rejection
* This function uses the built-in QuickAuth functionality:
* 1. Gets a token from QuickAuth (handles SIWF flow automatically)
* 2. Validates the token with our server
* 3. Updates the session state
*
* @returns Promise<void>
*/
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('farcaster', {
message: result.message,
signature: result.signature,
redirect: false,
});
const success = await signIn();
if (!success) {
setSignInFailure('Authentication failed');
}
} catch (e) {
if (e instanceof SignInCore.RejectedByUser) {
setSignInFailure('Rejected by user');
@@ -89,52 +67,49 @@ export function SignIn() {
}
setSignInFailure('Unknown error');
} finally {
setAuthState((prev) => ({ ...prev, signingIn: false }));
setAuthState(prev => ({ ...prev, signingIn: false }));
}
}, [getNonce]);
}, [signIn]);
/**
* Handles the sign-out process.
*
* This function clears the NextAuth session only if the current session
* is using the Farcaster provider, and resets the local sign-in result state.
* This function clears the QuickAuth session and resets the local state.
*
* @returns Promise<void>
*/
const handleSignOut = useCallback(async () => {
try {
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);
setAuthState(prev => ({ ...prev, signingOut: true }));
await signOut();
} finally {
setAuthState((prev) => ({ ...prev, signingOut: false }));
setAuthState(prev => ({ ...prev, signingOut: false }));
}
}, [session]);
}, [signOut]);
// --- Render ---
return (
<>
{/* Authentication Buttons */}
{(status !== 'authenticated' || session?.provider !== 'farcaster') && (
{status !== 'authenticated' && (
<Button onClick={handleSignIn} disabled={authState.signingIn}>
Sign In with Farcaster
</Button>
)}
{status === 'authenticated' && session?.provider === 'farcaster' && (
{status === 'authenticated' && (
<Button onClick={handleSignOut} disabled={authState.signingOut}>
Sign out
</Button>
)}
{/* Session Information */}
{session && (
{authenticatedUser && (
<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="font-semibold text-gray-500 dark:text-gray-300 mb-1">
Authenticated User
</div>
<div className="whitespace-pre text-gray-700 dark:text-gray-200">
{JSON.stringify(session, null, 2)}
{JSON.stringify(authenticatedUser, null, 2)}
</div>
</div>
)}
@@ -142,20 +117,14 @@ export function SignIn() {
{/* Error Display */}
{signInFailure && !authState.signingIn && (
<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 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="font-semibold text-gray-500 dark:text-gray-300 mb-1">
Authentication Error
</div>
<div className="whitespace-pre text-gray-700 dark:text-gray-200">
{JSON.stringify(signInResult, null, 2)}
{signInFailure}
</div>
</div>
)}
</>
);
}
}

207
src/hooks/useQuickAuth.ts Normal file
View File

@@ -0,0 +1,207 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { sdk } from '@farcaster/miniapp-sdk';
/**
* Represents the current authenticated user state
*/
interface AuthenticatedUser {
/** The user's Farcaster ID (FID) */
fid: number;
}
/**
* Possible authentication states for QuickAuth
*/
type QuickAuthStatus = 'loading' | 'authenticated' | 'unauthenticated';
/**
* Return type for the useQuickAuth hook
*/
interface UseQuickAuthReturn {
/** Current authenticated user data, or null if not authenticated */
authenticatedUser: AuthenticatedUser | null;
/** Current authentication status */
status: QuickAuthStatus;
/** Function to initiate the sign-in process using QuickAuth */
signIn: () => Promise<boolean>;
/** Function to sign out and clear the current authentication state */
signOut: () => Promise<void>;
/** Function to retrieve the current authentication token */
getToken: () => Promise<string | null>;
}
/**
* Custom hook for managing QuickAuth authentication state
*
* This hook provides a complete authentication flow using Farcaster's QuickAuth:
* - Automatically checks for existing authentication on mount
* - Validates tokens with the server-side API
* - Manages authentication state in memory (no persistence)
* - Provides sign-in/sign-out functionality
*
* QuickAuth tokens are managed in memory only, so signing out of the Farcaster
* client will automatically sign the user out of this mini app as well.
*
* @returns {UseQuickAuthReturn} Object containing user state and authentication methods
*
* @example
* ```tsx
* const { authenticatedUser, status, signIn, signOut } = useQuickAuth();
*
* if (status === 'loading') return <div>Loading...</div>;
* if (status === 'unauthenticated') return <button onClick={signIn}>Sign In</button>;
*
* return (
* <div>
* <p>Welcome, FID: {authenticatedUser?.fid}</p>
* <button onClick={signOut}>Sign Out</button>
* </div>
* );
* ```
*/
export function useQuickAuth(): UseQuickAuthReturn {
// Current authenticated user data
const [authenticatedUser, setAuthenticatedUser] =
useState<AuthenticatedUser | null>(null);
// Current authentication status
const [status, setStatus] = useState<QuickAuthStatus>('loading');
/**
* Validates a QuickAuth token with the server-side API
*
* @param {string} authToken - The JWT token to validate
* @returns {Promise<AuthenticatedUser | null>} User data if valid, null otherwise
*/
const validateTokenWithServer = async (
authToken: string,
): Promise<AuthenticatedUser | null> => {
try {
const validationResponse = await fetch('/api/auth/validate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: authToken }),
});
if (validationResponse.ok) {
const responseData = await validationResponse.json();
return responseData.user;
}
return null;
} catch (error) {
console.error('Token validation failed:', error);
return null;
}
};
/**
* Checks for existing authentication token and validates it on component mount
* This runs automatically when the hook is first used
*/
useEffect(() => {
const checkExistingAuthentication = async () => {
try {
// Attempt to retrieve existing token from QuickAuth SDK
const { token } = await sdk.quickAuth.getToken();
if (token) {
// Validate the token with our server-side API
const validatedUserSession = await validateTokenWithServer(token);
if (validatedUserSession) {
// Token is valid, set authenticated state
setAuthenticatedUser(validatedUserSession);
setStatus('authenticated');
} else {
// Token is invalid or expired, clear authentication state
setStatus('unauthenticated');
}
} else {
// No existing token found, user is not authenticated
setStatus('unauthenticated');
}
} catch (error) {
console.error('Error checking existing authentication:', error);
setStatus('unauthenticated');
}
};
checkExistingAuthentication();
}, []);
/**
* Initiates the QuickAuth sign-in process
*
* Uses sdk.quickAuth.getToken() to get a QuickAuth session token.
* If there is already a session token in memory that hasn't expired,
* it will be immediately returned, otherwise a fresh one will be acquired.
*
* @returns {Promise<boolean>} True if sign-in was successful, false otherwise
*/
const signIn = useCallback(async (): Promise<boolean> => {
try {
setStatus('loading');
// Get QuickAuth session token
const { token } = await sdk.quickAuth.getToken();
if (token) {
// Validate the token with our server-side API
const validatedUserSession = await validateTokenWithServer(token);
if (validatedUserSession) {
// Authentication successful, update user state
setAuthenticatedUser(validatedUserSession);
setStatus('authenticated');
return true;
}
}
// Authentication failed, clear user state
setStatus('unauthenticated');
return false;
} catch (error) {
console.error('Sign-in process failed:', error);
setStatus('unauthenticated');
return false;
}
}, []);
/**
* Signs out the current user and clears the authentication state
*
* Since QuickAuth tokens are managed in memory only, this simply clears
* the local user state. The actual token will be cleared when the
* user signs out of their Farcaster client.
*/
const signOut = useCallback(async (): Promise<void> => {
// Clear local user state
setAuthenticatedUser(null);
setStatus('unauthenticated');
}, []);
/**
* Retrieves the current authentication token from QuickAuth
*
* @returns {Promise<string | null>} The current auth token, or null if not authenticated
*/
const getToken = useCallback(async (): Promise<string | null> => {
try {
const { token } = await sdk.quickAuth.getToken();
return token;
} catch (error) {
console.error('Failed to retrieve authentication token:', error);
return null;
}
}, []);
return {
authenticatedUser,
status,
signIn,
signOut,
getToken,
};
}

View File

@@ -1,4 +1,4 @@
import { type AccountAssociation } from '@farcaster/miniapp-node';
import { type AccountAssociation } from '@farcaster/miniapp-core/src/manifest';
/**
* Application constants and configuration values.
@@ -65,21 +65,22 @@ export const APP_SPLASH_URL: string = `${APP_URL}/splash.png`;
* Background color for the splash screen.
* Used as fallback when splash image is loading.
*/
export const APP_SPLASH_BACKGROUND_COLOR: string = "#f7f7f7";
export const APP_SPLASH_BACKGROUND_COLOR: string = '#f7f7f7';
/**
* Account association for the mini app.
* Used to associate the mini app with a Farcaster account.
* If not provided, the mini app will be unsigned and have limited capabilities.
*/
export const APP_ACCOUNT_ASSOCIATION: AccountAssociation | undefined = undefined;
export const APP_ACCOUNT_ASSOCIATION: AccountAssociation | undefined =
undefined;
// --- UI Configuration ---
/**
* Text displayed on the main action button.
* Used for the primary call-to-action in the mini app.
*/
export const APP_BUTTON_TEXT: string = 'Launch NSK';
export const APP_BUTTON_TEXT: string = 'Launch Mini App';
// --- Integration Configuration ---
/**
@@ -89,7 +90,8 @@ export const APP_BUTTON_TEXT: string = 'Launch NSK';
* Neynar webhook endpoint. Otherwise, falls back to a local webhook
* endpoint for development and testing.
*/
export const APP_WEBHOOK_URL: string = process.env.NEYNAR_API_KEY && process.env.NEYNAR_CLIENT_ID
export const APP_WEBHOOK_URL: string =
process.env.NEYNAR_API_KEY && process.env.NEYNAR_CLIENT_ID
? `https://api.neynar.com/f/app/${process.env.NEYNAR_CLIENT_ID}/event`
: `${APP_URL}/api/webhook`;
@@ -100,7 +102,7 @@ export const APP_WEBHOOK_URL: string = process.env.NEYNAR_API_KEY && process.env
* When false, wallet functionality is completely hidden from the UI.
* Useful for mini apps that don't require wallet integration.
*/
export const USE_WALLET: boolean = true;
export const USE_WALLET: boolean = false;
/**
* Flag to enable/disable analytics tracking.
@@ -111,6 +113,26 @@ export const USE_WALLET: boolean = true;
*/
export const ANALYTICS_ENABLED: boolean = true;
/**
* Required chains for the mini app.
*
* Contains an array of CAIP-2 identifiers for blockchains that the mini app requires.
* If the host does not support all chains listed here, it will not render the mini app.
* If empty or undefined, the mini app will be rendered regardless of chain support.
*
* Supported chains: eip155:1, eip155:137, eip155:42161, eip155:10, eip155:8453,
* solana:mainnet, solana:devnet
*/
export const APP_REQUIRED_CHAINS: string[] = [];
/**
* Return URL for the mini app.
*
* If provided, the mini app will be rendered with a return URL to be rendered if the
* back button is pressed from the home page.
*/
export const RETURN_URL: string | undefined = undefined;
// PLEASE DO NOT UPDATE THIS
export const SIGNED_KEY_REQUEST_VALIDATOR_EIP_712_DOMAIN = {
name: 'Farcaster SignedKeyRequestValidator',

View File

@@ -1,16 +1,18 @@
import { FrameNotificationDetails } from "@farcaster/miniapp-sdk";
import { Redis } from "@upstash/redis";
import { APP_NAME } from "./constants";
import { MiniAppNotificationDetails } from '@farcaster/miniapp-sdk';
import { Redis } from '@upstash/redis';
import { APP_NAME } from './constants';
// In-memory fallback storage
const localStore = new Map<string, FrameNotificationDetails>();
const localStore = new Map<string, MiniAppNotificationDetails>();
// Use Redis if KV env vars are present, otherwise use in-memory
const useRedis = process.env.KV_REST_API_URL && process.env.KV_REST_API_TOKEN;
const redis = useRedis ? new Redis({
url: process.env.KV_REST_API_URL!,
token: process.env.KV_REST_API_TOKEN!,
}) : null;
const redis = useRedis
? new Redis({
url: process.env.KV_REST_API_URL!,
token: process.env.KV_REST_API_TOKEN!,
})
: null;
function getUserNotificationDetailsKey(fid: number): string {
return `${APP_NAME}:user:${fid}`;
@@ -18,17 +20,17 @@ function getUserNotificationDetailsKey(fid: number): string {
export async function getUserNotificationDetails(
fid: number
): Promise<FrameNotificationDetails | null> {
): Promise<MiniAppNotificationDetails | null> {
const key = getUserNotificationDetailsKey(fid);
if (redis) {
return await redis.get<FrameNotificationDetails>(key);
return await redis.get<MiniAppNotificationDetails>(key);
}
return localStore.get(key) || null;
}
export async function setUserNotificationDetails(
fid: number,
notificationDetails: FrameNotificationDetails
notificationDetails: MiniAppNotificationDetails
): Promise<void> {
const key = getUserNotificationDetailsKey(fid);
if (redis) {

View File

@@ -1,6 +1,6 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
import { type Manifest } from '@farcaster/miniapp-node';
import { Manifest } from '@farcaster/miniapp-core/src/manifest';
import {
APP_BUTTON_TEXT,
APP_DESCRIPTION,
@@ -10,7 +10,8 @@ import {
APP_PRIMARY_CATEGORY,
APP_SPLASH_BACKGROUND_COLOR,
APP_SPLASH_URL,
APP_TAGS, APP_URL,
APP_TAGS,
APP_URL,
APP_WEBHOOK_URL,
APP_ACCOUNT_ASSOCIATION,
} from './constants';
@@ -21,12 +22,15 @@ export function cn(...inputs: ClassValue[]) {
export function getMiniAppEmbedMetadata(ogImageUrl?: string) {
return {
version: "next",
version: 'next',
imageUrl: ogImageUrl ?? APP_OG_IMAGE_URL,
ogTitle: APP_NAME,
ogDescription: APP_DESCRIPTION,
ogImageUrl: 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,
@@ -42,20 +46,17 @@ export function getMiniAppEmbedMetadata(ogImageUrl?: string) {
export async function getFarcasterDomainManifest(): Promise<Manifest> {
return {
accountAssociation: APP_ACCOUNT_ASSOCIATION,
accountAssociation: APP_ACCOUNT_ASSOCIATION!,
miniapp: {
version: "1",
name: APP_NAME ?? "Neynar Starter Kit",
iconUrl: APP_ICON_URL,
version: '1',
name: APP_NAME ?? 'Neynar Starter Kit',
homeUrl: APP_URL,
iconUrl: APP_ICON_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,
description: APP_DESCRIPTION,
primaryCategory: APP_PRIMARY_CATEGORY,
tags: APP_TAGS,
},
};
}

View File

@@ -22,6 +22,13 @@
"~/*": ["./src/*"]
}
},
"ts-node": {
"esm": true,
"compilerOptions": {
"module": "ES2020",
"moduleResolution": "node"
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}