diff --git a/src/app/(spaces)/PublicSpace.tsx b/src/app/(spaces)/PublicSpace.tsx index 3a5539c96..48d772a05 100644 --- a/src/app/(spaces)/PublicSpace.tsx +++ b/src/app/(spaces)/PublicSpace.tsx @@ -106,6 +106,8 @@ export default function PublicSpace({ const [currentUserFid, setCurrentUserFid] = useState(null); const [isSignedIntoFarcaster, setIsSignedIntoFarcaster] = useState(false); const { wallets } = useWallets(); + const currentIdentity = useAppStore((state) => state.account.getCurrentIdentity()); + const associatedFids = currentIdentity?.associatedFids || []; const { lastUpdatedAt: authManagerLastUpdatedAt, @@ -122,9 +124,20 @@ export default function PublicSpace({ }); }, [authManagerLastUpdatedAt]); - // Loads the current user's FID if they're signed into Farcaster + // Loads the current user's FID from associatedFids first, then from authenticator if signed in useEffect(() => { - if (!isSignedIntoFarcaster) return; + // First, try to get FID from associatedFids (works even without signer) + if (associatedFids.length > 0) { + setCurrentUserFid(associatedFids[0]); + return; + } + + // If no associated FIDs and user is signed into Farcaster, try to get from authenticator + if (!isSignedIntoFarcaster) { + setCurrentUserFid(null); + return; + } + authManagerCallMethod({ requestingFidgetId: "root", authenticatorId: FARCASTER_NOUNSPACE_AUTHENTICATOR_NAME, @@ -133,9 +146,11 @@ export default function PublicSpace({ }).then((authManagerResp) => { if (authManagerResp.result === "success") { setCurrentUserFid(authManagerResp.value as number); + } else { + setCurrentUserFid(null); } }); - }, [isSignedIntoFarcaster, authManagerLastUpdatedAt]); + }, [isSignedIntoFarcaster, authManagerLastUpdatedAt, associatedFids]); // Load editable spaces when user signs in useEffect(() => { diff --git a/src/common/data/stores/app/accounts/farcasterStore.ts b/src/common/data/stores/app/accounts/farcasterStore.ts index 43a5f129a..25fc8c955 100644 --- a/src/common/data/stores/app/accounts/farcasterStore.ts +++ b/src/common/data/stores/app/accounts/farcasterStore.ts @@ -17,11 +17,11 @@ type FarcasterActions = { getFidsForCurrentIdentity: () => Promise; registerFidForCurrentIdentity: ( fid: number, - signingKey: string, + signingKey?: string, // Takes in signMessage as it is a method // of the Authenticator and client doesn't // have direct access to the keys - signMessage: (messageHash: Uint8Array) => Promise, + signMessage?: (messageHash: Uint8Array) => Promise, ) => Promise; setFidsForCurrentIdentity: (fids: number[]) => void; addFidToCurrentIdentity: (fid: number) => void; @@ -59,23 +59,45 @@ export const farcasterStore = ( } }, registerFidForCurrentIdentity: async (fid, signingKey, signMessage) => { - const request: Omit = { + console.log("[registerFidForCurrentIdentity] Starting registration:", { fid, hasSigningKey: !!signingKey }); + if (signingKey && !signMessage) { + throw new Error("signMessage is required when signingKey is provided"); + } + const baseRequest: FidLinkToIdentityRequest = { fid, identityPublicKey: get().account.currentSpaceIdentityPublicKey!, timestamp: moment().toISOString(), - signingPublicKey: signingKey, - }; - const signedRequest: FidLinkToIdentityRequest = { - ...request, - signature: bytesToHex(await signMessage(hashObject(request))), + signingPublicKey: signingKey ?? null, + signature: null, }; - const { data } = await axiosBackend.post( - "/api/fid-link", - signedRequest, - ); - if (!isUndefined(data.value)) { - get().account.addFidToCurrentIdentity(data.value!.fid); - analytics.track(AnalyticsEvent.LINK_FID, { fid }); + const signedRequest: FidLinkToIdentityRequest = signingKey + ? { + ...baseRequest, + signature: bytesToHex(await signMessage!(hashObject(baseRequest))), + } + : baseRequest; + console.log("[registerFidForCurrentIdentity] Request payload:", { + fid: signedRequest.fid, + hasSigningPublicKey: !!signedRequest.signingPublicKey, + hasSignature: !!signedRequest.signature, + }); + try { + const { data } = await axiosBackend.post( + "/api/fid-link", + signedRequest, + ); + console.log("[registerFidForCurrentIdentity] API response:", data); + if (!isUndefined(data.value)) { + get().account.addFidToCurrentIdentity(data.value!.fid); + analytics.track(AnalyticsEvent.LINK_FID, { fid }); + console.log("[registerFidForCurrentIdentity] Successfully registered FID:", data.value.fid); + } else { + console.warn("[registerFidForCurrentIdentity] API response has no value:", data); + } + } catch (error: any) { + console.error("[registerFidForCurrentIdentity] Error:", error); + console.error("[registerFidForCurrentIdentity] Error response:", error.response?.data); + throw error; } }, }); diff --git a/src/common/providers/LoggedInStateProvider.tsx b/src/common/providers/LoggedInStateProvider.tsx index abca83355..268d190e4 100644 --- a/src/common/providers/LoggedInStateProvider.tsx +++ b/src/common/providers/LoggedInStateProvider.tsx @@ -6,6 +6,7 @@ import { SetupStep } from "@/common/data/stores/app/setup"; import useValueHistory from "@/common/lib/hooks/useValueHistory"; import requiredAuthenticators from "@/constants/requiredAuthenticators"; +import { FARCASTER_AUTHENTICATOR_NAME } from "@/fidgets/farcaster"; import { bytesToHex } from "@noble/ciphers/utils"; import { usePrivy } from "@privy-io/react-auth"; import { isEqual, isUndefined } from "lodash"; @@ -132,6 +133,73 @@ const LoggedInStateProvider: React.FC = ({ children }) => { } } + const inferFidFromWallet = async (): Promise => { + if (!user?.wallet?.address) { + console.log("[inferFidFromWallet] No wallet address available"); + return undefined; + } + try { + console.log("[inferFidFromWallet] Fetching FID for wallet:", user.wallet.address); + const response = await fetch( + `/api/farcaster/neynar/users?addresses=${user.wallet.address}`, + ); + if (!response.ok) { + console.log("[inferFidFromWallet] API response not OK:", response.status, response.statusText); + return undefined; + } + const data = await response.json(); + console.log("[inferFidFromWallet] API response data:", data); + const users = data?.users ?? []; + if (users.length === 0) { + console.log("[inferFidFromWallet] No users found in response"); + return undefined; + } + + const walletLower = user.wallet.address.toLowerCase(); + const matchingUser = users.find((u: any) => { + // Check verified_addresses.primary.eth_address + if (u.verified_addresses?.primary?.eth_address?.toLowerCase() === walletLower) { + return true; + } + // Check verified_addresses.eth_addresses array + if (u.verified_addresses?.eth_addresses?.some( + (addr: string) => addr.toLowerCase() === walletLower + )) { + return true; + } + // Check verifications array (fallback) + if (u.verifications?.some( + (addr: string) => addr.toLowerCase() === walletLower + )) { + return true; + } + return false; + }); + + const fid = matchingUser?.fid ?? users[0]?.fid; + console.log("[inferFidFromWallet] Found FID:", fid, "from matching user:", !!matchingUser); + return fid; + } catch (e) { + console.error("[inferFidFromWallet] Error inferring FID from wallet:", e); + return undefined; + } + }; + + const waitForAuthenticator = async ( + authenticatorName: string, + attempts = 10, + delay = 1000, + ) => { + for (let i = 0; i < attempts; i++) { + const initialized = await authenticatorManager.getInitializedAuthenticators(); + if (initialized.includes(authenticatorName)) { + return true; + } + await new Promise((resolve) => setTimeout(resolve, delay)); + } + return false; + }; + async function loadAuthenticators() { try { await loadPreKeys(); @@ -144,48 +212,89 @@ const LoggedInStateProvider: React.FC = ({ children }) => { } const installRequiredAuthenticators = async () => { - await authenticatorManager.installAuthenticators(requiredAuthenticators); - authenticatorManager.initializeAuthenticators(requiredAuthenticators); - setCurrentStep(SetupStep.REQUIRED_AUTHENTICATORS_INSTALLED); + if (requiredAuthenticators.length > 0) { + await authenticatorManager.installAuthenticators(requiredAuthenticators); + authenticatorManager.initializeAuthenticators(requiredAuthenticators); + setCurrentStep(SetupStep.REQUIRED_AUTHENTICATORS_INSTALLED); + } else { + // If no required authenticators, skip directly to initialized + setCurrentStep(SetupStep.AUTHENTICATORS_INITIALIZED); + } }; const registerAccounts = async () => { let currentIdentity = getCurrentIdentity()!; + console.log("[registerAccounts] Starting, current FIDs:", currentIdentity.associatedFids); if (currentIdentity.associatedFids.length > 0) { + console.log("[registerAccounts] FIDs already exist, skipping registration"); setCurrentStep(SetupStep.ACCOUNTS_REGISTERED); } else { + console.log("[registerAccounts] No FIDs found, loading from server..."); await loadFidsForCurrentIdentity(); currentIdentity = getCurrentIdentity()!; + console.log("[registerAccounts] After loadFidsForCurrentIdentity, FIDs:", currentIdentity.associatedFids); if (currentIdentity.associatedFids.length === 0) { - const fidResult = (await authenticatorManager.callMethod({ - requestingFidgetId: "root", - authenticatorId: "farcaster:nounspace", - methodName: "getAccountFid", - isLookup: true, - })) as { value: number }; - const publicKeyResult = (await authenticatorManager.callMethod({ - requestingFidgetId: "root", - authenticatorId: "farcaster:nounspace", - methodName: "getSignerPublicKey", - isLookup: true, - })) as { value: Uint8Array }; - const signForFid = async (messageHash) => { - const signResult = (await authenticatorManager.callMethod( - { + console.log("[registerAccounts] No FIDs from server, attempting to infer from wallet..."); + const fidFromWallet = await inferFidFromWallet(); + console.log("[registerAccounts] Inferred FID from wallet:", fidFromWallet); + if (!isUndefined(fidFromWallet)) { + try { + console.log("[registerAccounts] Registering FID without signer:", fidFromWallet); + await registerFidForCurrentIdentity(fidFromWallet); + console.log("[registerAccounts] FID registered, reloading..."); + await loadFidsForCurrentIdentity(); + currentIdentity = getCurrentIdentity()!; + console.log("[registerAccounts] After registration, FIDs:", currentIdentity.associatedFids); + } catch (e) { + console.error("[registerAccounts] Error registering FID from wallet:", e); + // Continue to fallback flow if registration fails + } + } + if (currentIdentity.associatedFids.length === 0) { + console.log("[registerAccounts] No FID found/inferred, falling back to signer flow..."); + await authenticatorManager.installAuthenticators([ + FARCASTER_AUTHENTICATOR_NAME, + ]); + authenticatorManager.initializeAuthenticators([ + FARCASTER_AUTHENTICATOR_NAME, + ]); + const signerReady = await waitForAuthenticator(FARCASTER_AUTHENTICATOR_NAME); + if (signerReady) { + try { + const fidResult = (await authenticatorManager.callMethod({ + requestingFidgetId: "root", + authenticatorId: FARCASTER_AUTHENTICATOR_NAME, + methodName: "getAccountFid", + isLookup: true, + })) as { value: number }; + const publicKeyResult = (await authenticatorManager.callMethod({ requestingFidgetId: "root", - authenticatorId: "farcaster:nounspace", - methodName: "signMessage", - isLookup: false, - }, - messageHash, - )) as { value: Uint8Array }; - return signResult.value; - }; - await registerFidForCurrentIdentity( - fidResult.value, - bytesToHex(publicKeyResult.value), - signForFid, - ); + authenticatorId: FARCASTER_AUTHENTICATOR_NAME, + methodName: "getSignerPublicKey", + isLookup: true, + })) as { value: Uint8Array }; + const signForFid = async (messageHash) => { + const signResult = (await authenticatorManager.callMethod( + { + requestingFidgetId: "root", + authenticatorId: FARCASTER_AUTHENTICATOR_NAME, + methodName: "signMessage", + isLookup: false, + }, + messageHash, + )) as { value: Uint8Array }; + return signResult.value; + }; + await registerFidForCurrentIdentity( + fidResult.value, + bytesToHex(publicKeyResult.value), + signForFid, + ); + } catch (e) { + console.error("Error registering FID with signer:", e); + } + } + } } } setCurrentStep(SetupStep.ACCOUNTS_REGISTERED); diff --git a/src/config/loaders/registry.ts b/src/config/loaders/registry.ts index 8749d3135..18214f671 100644 --- a/src/config/loaders/registry.ts +++ b/src/config/loaders/registry.ts @@ -42,6 +42,20 @@ export function resolveCommunityFromDomain( if (domain.endsWith('.vercel.app') && domain.includes('nounspace')) { return 'nouns'; } + + if (domain.endsWith('.vercel.app')) { + const subdomain = domain.replace('.vercel.app', ''); + const parts = subdomain.split('-').filter(Boolean); + const hashIndex = parts.findIndex( + (part, index) => index > 0 && index < parts.length - 1 && /^[a-z0-9]{7,}$/.test(part) + ); + if (hashIndex !== -1 && hashIndex < parts.length - 1) { + const candidate = parts.slice(hashIndex + 1).join('-'); + if (candidate) { + return candidate; + } + } + } // Support localhost subdomains for local testing // e.g., example.localhost:3000 -> example diff --git a/src/config/loaders/runtimeLoader.ts b/src/config/loaders/runtimeLoader.ts index 0e9bca475..dc93bd7c4 100644 --- a/src/config/loaders/runtimeLoader.ts +++ b/src/config/loaders/runtimeLoader.ts @@ -37,40 +37,21 @@ export class RuntimeConfigLoader implements ConfigLoader { } try { - // Fetch config from database - const { data, error } = await this.supabase - .rpc('get_active_community_config', { - p_community_id: context.communityId - }) - .single(); - - if (error || !data) { - throw new Error( - `❌ Failed to load config from database for community: ${context.communityId}. ` + - `Error: ${error?.message || 'No data returned'}` - ); - } - - // Type assertion for database response - const dbConfig = data as any; - - // Validate config structure - if (!dbConfig.brand || !dbConfig.assets) { - throw new Error( - `❌ Invalid config structure from database. ` + - `Missing required fields: brand, assets. ` + - `Ensure database is seeded correctly.` + return await this.loadFromDatabase(context.communityId); + } catch (error: any) { + const fallbackCommunityId = process.env.NEXT_PUBLIC_TEST_COMMUNITY || 'nouns'; + if ( + error instanceof ConfigNotFoundError && + context.communityId !== fallbackCommunityId + ) { + const fallbackConfig = await this.loadFromDatabase(fallbackCommunityId); + console.warn( + `[Config] Community config not found for "${context.communityId}", ` + + `falling back to "${fallbackCommunityId}"` ); + return fallbackConfig; } - // Add themes from shared file (themes are not in database) - const mappedConfig: SystemConfig = { - ...dbConfig, - theme: themes, // Themes come from shared file - }; - - return mappedConfig as SystemConfig; - } catch (error: any) { if (error.message) { throw error; } @@ -79,5 +60,48 @@ export class RuntimeConfigLoader implements ConfigLoader { ); } } + + private async loadFromDatabase(communityId: string): Promise { + if (!this.supabase) { + throw new Error( + `❌ Supabase credentials not configured. ` + + `NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY are required for runtime config loading.` + ); + } + + const { data, error } = await this.supabase + .rpc('get_active_community_config', { + p_community_id: communityId, + }) + .single(); + + if (error || !data) { + throw new ConfigNotFoundError(communityId, error?.message || 'No data returned'); + } + + const dbConfig = data as any; + + if (!dbConfig.brand || !dbConfig.assets) { + throw new Error( + `❌ Invalid config structure from database. ` + + `Missing required fields: brand, assets. ` + + `Ensure database is seeded correctly.` + ); + } + + return { + ...dbConfig, + theme: themes, + } as SystemConfig; + } } +class ConfigNotFoundError extends Error { + constructor(communityId: string, details: string) { + super( + `❌ Failed to load config from database for community: ${communityId}. ` + + `Error: ${details}` + ); + this.name = 'ConfigNotFoundError'; + } +} diff --git a/src/constants/requiredAuthenticators.ts b/src/constants/requiredAuthenticators.ts index 495a95cc1..d6d1738de 100644 --- a/src/constants/requiredAuthenticators.ts +++ b/src/constants/requiredAuthenticators.ts @@ -1 +1 @@ -export default ["farcaster:nounspace"]; +export default []; diff --git a/src/fidgets/farcaster/components/CastRow.tsx b/src/fidgets/farcaster/components/CastRow.tsx index 289e4f95f..20fc787a6 100644 --- a/src/fidgets/farcaster/components/CastRow.tsx +++ b/src/fidgets/farcaster/components/CastRow.tsx @@ -313,7 +313,8 @@ const CastAttributionSecondary = ({ cast }) => { const CastReactions = ({ cast }: { cast: CastWithInteractions }) => { const [didLike, setDidLike] = useState(cast.viewer_context?.liked ?? false); const [didRecast, setDidRecast] = useState(cast.viewer_context?.recasted ?? false); - const { signer, fid: userFid } = useFarcasterSigner("render-cast"); + const { signer, fid: userFid, requestSignerAuthorization } = + useFarcasterSigner("render-cast"); const { showToast } = useToastStore(); const { setModalOpen, getIsAccountReady } = useAppStore((state) => ({ setModalOpen: state.setup.setModalOpen, @@ -367,8 +368,8 @@ const CastReactions = ({ cast }: { cast: CastWithInteractions }) => { } // We check if we have the signer before proceeding - if (isUndefined(signer)) { - console.error("NO SIGNER"); + if (isUndefined(signer) || userFid < 0) { + await requestSignerAuthorization(); return; } diff --git a/src/fidgets/farcaster/components/CreateCast.tsx b/src/fidgets/farcaster/components/CreateCast.tsx index d357f5a0a..0e7daaa71 100644 --- a/src/fidgets/farcaster/components/CreateCast.tsx +++ b/src/fidgets/farcaster/components/CreateCast.tsx @@ -167,7 +167,8 @@ const CreateCast: React.FC = ({ const hasEmbeds = draft?.embeds && !!draft.embeds.length; const isReply = draft?.parentCastId !== undefined; - const { signer, isLoadingSigner, fid } = useFarcasterSigner("create-cast"); + const { signer, isLoadingSigner, fid, requestSignerAuthorization } = + useFarcasterSigner("create-cast"); const [initialChannels, setInitialChannels] = useState() as any; const [isEmojiPickerOpen, setIsEmojiPickerOpen] = useState(false); const [isEnhancing, setIsEnhancing] = useState(false); @@ -415,7 +416,12 @@ const CreateCast: React.FC = ({ ); const onSubmitPost = async (): Promise => { - if ((!draft?.text && !draft?.embeds?.length) || isUndefined(signer)) { + if (isUndefined(signer)) { + await requestSignerAuthorization(); + return false; + } + + if (!draft?.text && !draft?.embeds?.length) { console.error( "Submission failed: Missing text or embeds, or signer is undefined.", { @@ -724,6 +730,7 @@ const CreateCast: React.FC = ({ const getButtonText = () => { + if (!signer) return "Connect Farcaster"; if (isLoadingSigner) return "Not signed into Farcaster"; if (isPublishing) return "Publishing..."; if (submissionError) return "Retry"; diff --git a/src/fidgets/farcaster/index.tsx b/src/fidgets/farcaster/index.tsx index 66bfd6580..3825bad9c 100644 --- a/src/fidgets/farcaster/index.tsx +++ b/src/fidgets/farcaster/index.tsx @@ -2,10 +2,12 @@ import { AuthenticatorManager, useAuthenticatorManager, } from "@/authenticators/AuthenticatorManager"; +import { useAppStore } from "@/common/data/stores/app"; +import { bytesToHex } from "@noble/ciphers/utils"; import { HubError, SignatureScheme, Signer } from "@farcaster/core"; import { indexOf } from "lodash"; import { err, ok } from "neverthrow"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; export const FARCASTER_AUTHENTICATOR_NAME = "farcaster:nounspace"; @@ -61,45 +63,124 @@ export function useFarcasterSigner( authenticatorName: string = "farcaster:nounspace", ) { const authenticatorManager = useAuthenticatorManager(); - const [isLoadingSigner, setIsLoadingSigner] = useState(true); - useEffect(() => { - authenticatorManager - .getInitializedAuthenticators() - .then((initilizedAuths) => - setIsLoadingSigner( - indexOf(initilizedAuths, FARCASTER_AUTHENTICATOR_NAME) === -1, - ), - ); - }, [authenticatorManager.lastUpdatedAt]); + const { currentIdentityFids, registerFidForCurrentIdentity, loadFids } = + useAppStore((state) => ({ + currentIdentityFids: + state.account.getCurrentIdentity()?.associatedFids || [], + registerFidForCurrentIdentity: state.account.registerFidForCurrentIdentity, + loadFids: state.account.getFidsForCurrentIdentity, + })); + const [hasRequestedSigner, setHasRequestedSigner] = useState(false); + const [isLoadingSigner, setIsLoadingSigner] = useState(false); const [signer, setSigner] = useState(); - useEffect(() => { - createFarcasterSignerFromAuthenticatorManager( - authenticatorManager, - fidgetId, - authenticatorName, - ).then((signer) => setSigner(signer)); - }, [authenticatorManager.lastUpdatedAt]); const [fid, setFid] = useState(-1); + const requestSignerAuthorization = useCallback(async () => { + setHasRequestedSigner(true); + setIsLoadingSigner(true); + await authenticatorManager.installAuthenticators([authenticatorName]); + authenticatorManager.initializeAuthenticators([authenticatorName]); + }, [authenticatorManager, authenticatorName]); + useEffect(() => { + if (currentIdentityFids.length > 0) { + setFid(currentIdentityFids[0]); + } + }, [currentIdentityFids]); + + useEffect(() => { + if (!hasRequestedSigner) { + setIsLoadingSigner(false); + return; + } authenticatorManager - .callMethod({ + .getInitializedAuthenticators() + .then((initializedAuths) => { + const ready = indexOf(initializedAuths, FARCASTER_AUTHENTICATOR_NAME) !== -1; + setIsLoadingSigner(!ready); + if (ready) { + createFarcasterSignerFromAuthenticatorManager( + authenticatorManager, + fidgetId, + authenticatorName, + ).then((newSigner) => setSigner(newSigner)); + authenticatorManager + .callMethod({ + requestingFidgetId: fidgetId, + authenticatorId: FARCASTER_AUTHENTICATOR_NAME, + methodName: "getAccountFid", + isLookup: true, + }) + .then((methodResult) => { + if (methodResult.result === "success") { + return setFid(methodResult.value as number); + } + return setFid(-1); + }); + } + }); + }, [ + authenticatorManager, + authenticatorManager.lastUpdatedAt, + authenticatorName, + fidgetId, + hasRequestedSigner, + ]); + + useEffect(() => { + if (!hasRequestedSigner || !signer || fid < 0) return; + + const syncFidRegistration = async () => { + try { + const publicKeyResult = await authenticatorManager.callMethod({ requestingFidgetId: fidgetId, authenticatorId: FARCASTER_AUTHENTICATOR_NAME, - methodName: "getAccountFid", + methodName: "getSignerPublicKey", isLookup: true, - }) - .then((methodResult) => { - if (methodResult.result === "success") { - return setFid(methodResult.value as number); - } - return setFid(-1); }); - }, [authenticatorManager.lastUpdatedAt]); + const signForFid = async (messageHash: Uint8Array) => { + const signResult = await authenticatorManager.callMethod( + { + requestingFidgetId: fidgetId, + authenticatorId: FARCASTER_AUTHENTICATOR_NAME, + methodName: "signMessage", + isLookup: false, + }, + messageHash, + ); + return signResult.result === "success" + ? (signResult.value as Uint8Array) + : new Uint8Array(); + }; + if (publicKeyResult.result === "success") { + await registerFidForCurrentIdentity( + fid, + bytesToHex(publicKeyResult.value as Uint8Array), + signForFid, + ); + await loadFids(); + } + } catch (error) { + console.error("Error syncing FID registration with signer:", error); + } + }; + + syncFidRegistration(); + }, [ + authenticatorManager, + authenticatorManager.lastUpdatedAt, + fid, + fidgetId, + hasRequestedSigner, + loadFids, + registerFidForCurrentIdentity, + signer, + ]); return { authenticatorManager, isLoadingSigner, signer, fid, + requestSignerAuthorization, }; } diff --git a/src/fidgets/token/Directory/Directory.tsx b/src/fidgets/token/Directory/Directory.tsx index 980146aeb..e6a637996 100644 --- a/src/fidgets/token/Directory/Directory.tsx +++ b/src/fidgets/token/Directory/Directory.tsx @@ -160,7 +160,8 @@ const Directory: React.FC< [primaryFontFamily], ); - const { fid: viewerFid, signer } = useFarcasterSigner("Directory"); + const { fid: viewerFid, signer, requestSignerAuthorization } = + useFarcasterSigner("Directory"); const [directoryData, setDirectoryData] = useState(() => ({ members: data?.members ?? [], @@ -1062,6 +1063,7 @@ const Directory: React.FC< includeFilter={includeFilter} viewerFid={viewerFid} signer={signer} + requestSignerAuthorization={requestSignerAuthorization} /> ) : ( )} {/* Bottom pagination */} diff --git a/src/fidgets/token/Directory/components/DirectoryCardView.tsx b/src/fidgets/token/Directory/components/DirectoryCardView.tsx index f4cdaa7c3..492895879 100644 --- a/src/fidgets/token/Directory/components/DirectoryCardView.tsx +++ b/src/fidgets/token/Directory/components/DirectoryCardView.tsx @@ -37,6 +37,7 @@ export type DirectoryCardViewProps = { includeFilter: DirectoryIncludeOption; viewerFid: number; signer: DirectoryFollowButtonProps["signer"]; + requestSignerAuthorization?: () => Promise; }; export const DirectoryCardView: React.FC = ({ @@ -49,6 +50,7 @@ export const DirectoryCardView: React.FC = ({ includeFilter, viewerFid, signer, + requestSignerAuthorization, }) => { return (
@@ -156,6 +158,7 @@ export const DirectoryCardView: React.FC = ({ member={member} viewerFid={viewerFid} signer={signer} + requestSignerAuthorization={requestSignerAuthorization} className="pointer-events-auto px-3 py-1 text-xs font-semibold" />
diff --git a/src/fidgets/token/Directory/components/DirectoryFollowButton.tsx b/src/fidgets/token/Directory/components/DirectoryFollowButton.tsx index 032d40446..939d250fe 100644 --- a/src/fidgets/token/Directory/components/DirectoryFollowButton.tsx +++ b/src/fidgets/token/Directory/components/DirectoryFollowButton.tsx @@ -9,6 +9,7 @@ export type DirectoryFollowButtonProps = { viewerFid: number; signer: Parameters[2] | undefined; className?: string; + requestSignerAuthorization?: () => Promise; }; export const DirectoryFollowButton: React.FC = ({ @@ -16,6 +17,7 @@ export const DirectoryFollowButton: React.FC = ({ viewerFid, signer, className, + requestSignerAuthorization, }) => { const { setModalOpen, getIsAccountReady } = useAppStore((state) => ({ setModalOpen: state.setup.setModalOpen, @@ -53,6 +55,9 @@ export const DirectoryFollowButton: React.FC = ({ } if (!signer || memberFid === null || viewerFid <= 0) { + if (requestSignerAuthorization) { + await requestSignerAuthorization(); + } return; } diff --git a/src/fidgets/token/Directory/components/DirectoryListView.tsx b/src/fidgets/token/Directory/components/DirectoryListView.tsx index ecf2857a4..d6f6d2010 100644 --- a/src/fidgets/token/Directory/components/DirectoryListView.tsx +++ b/src/fidgets/token/Directory/components/DirectoryListView.tsx @@ -36,6 +36,7 @@ export type DirectoryListViewProps = { includeFilter: DirectoryIncludeOption; viewerFid: number; signer: DirectoryFollowButtonProps["signer"]; + requestSignerAuthorization?: () => Promise; }; export const DirectoryListView: React.FC = ({ @@ -47,6 +48,7 @@ export const DirectoryListView: React.FC = ({ includeFilter, viewerFid, signer, + requestSignerAuthorization, }) => { return (
    @@ -125,6 +127,7 @@ export const DirectoryListView: React.FC = ({ member={member} viewerFid={viewerFid} signer={signer} + requestSignerAuthorization={requestSignerAuthorization} className="px-3 py-1 text-xs font-semibold" />
    diff --git a/src/fidgets/ui/profile.tsx b/src/fidgets/ui/profile.tsx index 98f587948..35c013806 100644 --- a/src/fidgets/ui/profile.tsx +++ b/src/fidgets/ui/profile.tsx @@ -45,7 +45,8 @@ const Profile: React.FC> = ({ settings: { fid }, }) => { const isMobile = useIsMobile(); - const { fid: viewerFid, signer } = useFarcasterSigner("Profile"); + const { fid: viewerFid, signer, requestSignerAuthorization } = + useFarcasterSigner("Profile"); const { data: userData } = useLoadFarcasterUser( fid, viewerFid > 0 ? viewerFid : undefined @@ -65,7 +66,12 @@ const Profile: React.FC> = ({ // console.log("user", user); const toggleFollowing = async () => { - if (user && signer && viewerFid > 0) { + if (!signer || viewerFid <= 0) { + await requestSignerAuthorization(); + return; + } + + if (user) { setActionStatus("loading"); // Optimistically update the user's following state diff --git a/src/pages/api/farcaster/neynar/users.ts b/src/pages/api/farcaster/neynar/users.ts index d6ab2586f..c46e1e13f 100644 --- a/src/pages/api/farcaster/neynar/users.ts +++ b/src/pages/api/farcaster/neynar/users.ts @@ -1,29 +1,53 @@ +import neynar from "@/common/data/api/neynar"; import requestHandler from "@/common/data/api/requestHandler"; -import axios, { AxiosRequestConfig, isAxiosError } from "axios"; import { NextApiRequest, NextApiResponse } from "next/types"; +const getAddresses = (query: NextApiRequest["query"]) => { + const raw = query.addresses ?? query["addresses[]"]; + if (Array.isArray(raw)) return raw as string[]; + if (typeof raw === "string") return raw.split(",").map((s) => s.trim()); + return [] as string[]; +}; + +const getFids = (query: NextApiRequest["query"]) => { + const raw = query.fids; + if (Array.isArray(raw)) return raw.map((f) => Number(f)).filter((f) => !isNaN(f)); + if (typeof raw === "string") return raw.split(",").map((f) => Number(f.trim())).filter((f) => !isNaN(f)); + return [] as number[]; +}; + async function loadUsers(req: NextApiRequest, res: NextApiResponse) { try { - const options: AxiosRequestConfig = { - method: "GET", - url: "https://api.neynar.com/v2/farcaster/user/bulk", - headers: { - accept: "application/json", - api_key: process.env.NEYNAR_API_KEY!, - }, - params: req.query, - }; + const addresses = getAddresses(req.query).filter(Boolean); + const fids = getFids(req.query); - const { data } = await axios.request(options); - res.status(200).json(data); - } catch (e) { - if (isAxiosError(e)) { - res - .status(e.response!.data.status || 500) - .json(e.response!.data || "An unknown error occurred"); + // Support both addresses and fids + if (addresses.length > 0) { + const response = await neynar.fetchBulkUsersByEthOrSolAddress({ + addresses, + }); + // Transform the response to match the expected format + // Neynar returns Record, we need to flatten it + const users = Object.values(response).flat(); + return res.status(200).json({ users }); + } else if (fids.length > 0) { + const response = await neynar.fetchBulkUsers({ + fids, + viewerFid: req.query.viewer_fid ? Number(req.query.viewer_fid) : undefined, + }); + return res.status(200).json(response); } else { - res.status(500).json("An unknown error occurred"); + return res.status(400).json({ + result: "error", + error: { message: "Missing addresses or fids parameter" }, + }); } + } catch (e: any) { + console.error("Error fetching users from Neynar:", e); + return res.status(500).json({ + result: "error", + error: { message: e?.message || "Failed to fetch users" }, + }); } } diff --git a/src/pages/api/fid-link.ts b/src/pages/api/fid-link.ts index 8892be26f..d1f7dde59 100644 --- a/src/pages/api/fid-link.ts +++ b/src/pages/api/fid-link.ts @@ -1,7 +1,7 @@ import requestHandler, { NounspaceResponse, } from "@/common/data/api/requestHandler"; -import { isSignable, validateSignable } from "@/common/lib/signedFiles"; +import { validateSignable } from "@/common/lib/signedFiles"; import { NextApiRequest, NextApiResponse } from "next"; import neynar from "@/common/data/api/neynar"; import createSupabaseServerClient from "@/common/data/database/supabase/clients/server"; @@ -12,20 +12,23 @@ export type FidLinkToIdentityRequest = { fid: number; identityPublicKey: string; timestamp: string; - signature: string; - signingPublicKey: string; + signature?: string | null; + signingPublicKey?: string | null; }; function isFidLinkToIdentityRequest( maybe: unknown, ): maybe is FidLinkToIdentityRequest { - if (!isSignable(maybe, "signingPublicKey")) { + if (maybe === null || typeof maybe !== "object" || Array.isArray(maybe)) { return false; } + + const candidate = maybe as Record; + return ( - typeof maybe["fid"] === "number" && - typeof maybe["timestamp"] === "string" && - typeof maybe["identityPublicKey"] === "string" + typeof candidate.fid === "number" && + typeof candidate.timestamp === "string" && + typeof candidate.identityPublicKey === "string" ); } @@ -33,8 +36,8 @@ export type FidLinkToIdentityResponse = NounspaceResponse<{ fid: number; identityPublicKey: string; created: string; - signature: string; - signingPublicKey: string; + signature: string | null; + signingPublicKey: string | null; isSigningKeyValid: boolean; }>; @@ -57,27 +60,65 @@ async function linkFidToIdentity( result: "error", error: { message: - "Registration request requires fid, timestamp, identityPublicKey, signingPublicKey, and a signature", + "Registration request requires fid, timestamp, and identityPublicKey", }, }); return; } - if (!validateSignable(reqBody, "signingPublicKey")) { - res.status(400).json({ - result: "error", - error: { - message: "Invalid signature", - }, - }); - return; - } - if (!checkSigningKeyValidForFid) { - res.status(400).json({ - result: "error", - error: { - message: `Signing key ${reqBody.signingPublicKey} is not valid for fid ${reqBody.fid}`, - }, - }); + const hasSigningKeyInfo = !!reqBody.signingPublicKey; + let signature: string | null = null; + let signingPublicKey: string | null = null; + let signingKeyLastValidatedAt: string | null = null; + if (hasSigningKeyInfo) { + if (typeof reqBody.signature !== "string") { + res.status(400).json({ + result: "error", + error: { + message: "Invalid signature", + }, + }); + return; + } + if (typeof reqBody.signingPublicKey !== "string") { + res.status(400).json({ + result: "error", + error: { + message: "Invalid signingPublicKey", + }, + }); + return; + } + if ( + !validateSignable( + { + ...reqBody, + signature: reqBody.signature, + }, + "signingPublicKey", + ) + ) { + res.status(400).json({ + result: "error", + error: { + message: "Invalid signature", + }, + }); + return; + } + if ( + !(await checkSigningKeyValidForFid(reqBody.fid, reqBody.signingPublicKey)) + ) { + res.status(400).json({ + result: "error", + error: { + message: `Signing key ${reqBody.signingPublicKey} is not valid for fid ${reqBody.fid}`, + }, + }); + return; + } + signingPublicKey = reqBody.signingPublicKey; + signature = reqBody.signature; + signingKeyLastValidatedAt = moment().toISOString(); } const { data: checkExistsData } = await createSupabaseServerClient() .from("fidRegistrations") @@ -100,10 +141,10 @@ async function linkFidToIdentity( .update({ created: reqBody.timestamp, identityPublicKey: reqBody.identityPublicKey, - isSigningKeyValid: true, - signature: reqBody.signature, - signingKeyLastValidatedAt: moment().toISOString(), - signingPublicKey: reqBody.signingPublicKey, + isSigningKeyValid: hasSigningKeyInfo, + signature, + signingKeyLastValidatedAt, + signingPublicKey, }) .eq("fid", reqBody.fid) .select(); @@ -127,10 +168,10 @@ async function linkFidToIdentity( fid: reqBody.fid, created: reqBody.timestamp, identityPublicKey: reqBody.identityPublicKey, - isSigningKeyValid: true, - signature: reqBody.signature, - signingKeyLastValidatedAt: moment().toISOString(), - signingPublicKey: reqBody.signingPublicKey, + isSigningKeyValid: hasSigningKeyInfo, + signature, + signingKeyLastValidatedAt, + signingPublicKey, }) .select(); if (error !== null) { @@ -172,8 +213,7 @@ async function lookUpFidsForIdentity( const { data, error } = await createSupabaseServerClient() .from("fidRegistrations") .select("fid") - .eq("identityPublicKey", identity) - .eq("isSigningKeyValid", true); + .eq("identityPublicKey", identity); if (error) { res.status(500).json({ result: "error", diff --git a/src/supabase/database.d.ts b/src/supabase/database.d.ts index 05f05182d..8a4af92e6 100644 --- a/src/supabase/database.d.ts +++ b/src/supabase/database.d.ts @@ -190,19 +190,19 @@ export type Database = { id: number identityPublicKey: string isSigningKeyValid: boolean - signature: string - signingKeyLastValidatedAt: string - signingPublicKey: string + signature: string | null + signingKeyLastValidatedAt: string | null + signingPublicKey: string | null } Insert: { created?: string fid: number id?: number identityPublicKey: string - isSigningKeyValid: boolean - signature: string - signingKeyLastValidatedAt: string - signingPublicKey: string + isSigningKeyValid?: boolean + signature?: string | null + signingKeyLastValidatedAt?: string | null + signingPublicKey?: string | null } Update: { created?: string @@ -210,9 +210,9 @@ export type Database = { id?: number identityPublicKey?: string isSigningKeyValid?: boolean - signature?: string - signingKeyLastValidatedAt?: string - signingPublicKey?: string + signature?: string | null + signingKeyLastValidatedAt?: string | null + signingPublicKey?: string | null } Relationships: [] } diff --git a/supabase/migrations/20241002120000_allow_null_signer_fields.sql b/supabase/migrations/20241002120000_allow_null_signer_fields.sql new file mode 100644 index 000000000..52b8ea2ad --- /dev/null +++ b/supabase/migrations/20241002120000_allow_null_signer_fields.sql @@ -0,0 +1,5 @@ +alter table "public"."fidRegistrations" + alter column "signature" drop not null, + alter column "signingPublicKey" drop not null, + alter column "signingKeyLastValidatedAt" drop not null, + alter column "isSigningKeyValid" set default false;