From 127516e907769062d66f7ef36e40b76d330e5317 Mon Sep 17 00:00:00 2001 From: Will Sewell Date: Fri, 9 Jan 2026 16:49:50 +0000 Subject: [PATCH 1/2] Convert api to server actions --- src/app/actions/auth.ts | 151 ++++++++++++++++++ src/app/actions/sonar.ts | 76 +++++++++ src/app/api/auth/login/route.ts | 15 -- src/app/api/auth/logout/route.ts | 12 -- src/app/api/auth/session/route.ts | 25 --- src/app/api/auth/sonar/authorize/route.ts | 33 ---- src/app/api/auth/sonar/callback/route.ts | 88 ---------- src/app/api/auth/sonar/disconnect/route.ts | 18 --- src/app/api/sonar/entities/route.ts | 23 --- src/app/api/sonar/entity/route.ts | 35 ---- .../sonar/generate-purchase-permit/route.ts | 31 ---- src/app/api/sonar/pre-purchase-check/route.ts | 31 ---- .../components/auth/AuthenticationSection.tsx | 10 +- src/app/hooks/use-session.tsx | 8 +- src/app/hooks/use-sonar-entities.ts | 5 +- src/app/hooks/use-sonar-entity.ts | 6 +- src/app/hooks/use-sonar-purchase.ts | 25 ++- src/app/hooks/use-sonar-query.ts | 64 ++++---- src/app/oauth/callback/page.tsx | 14 +- src/lib/sonar.ts | 31 ++-- 20 files changed, 312 insertions(+), 389 deletions(-) create mode 100644 src/app/actions/auth.ts create mode 100644 src/app/actions/sonar.ts delete mode 100644 src/app/api/auth/login/route.ts delete mode 100644 src/app/api/auth/logout/route.ts delete mode 100644 src/app/api/auth/session/route.ts delete mode 100644 src/app/api/auth/sonar/authorize/route.ts delete mode 100644 src/app/api/auth/sonar/callback/route.ts delete mode 100644 src/app/api/auth/sonar/disconnect/route.ts delete mode 100644 src/app/api/sonar/entities/route.ts delete mode 100644 src/app/api/sonar/entity/route.ts delete mode 100644 src/app/api/sonar/generate-purchase-permit/route.ts delete mode 100644 src/app/api/sonar/pre-purchase-check/route.ts diff --git a/src/app/actions/auth.ts b/src/app/actions/auth.ts new file mode 100644 index 0000000..cc006a0 --- /dev/null +++ b/src/app/actions/auth.ts @@ -0,0 +1,151 @@ +"use server"; + +import { createSession, destroySession, getSession } from "@/lib/session"; +import { getTokenStore } from "@/lib/token-store"; +import { sonarConfig } from "@/lib/config"; +import { generatePKCEParams, buildAuthorizationUrl } from "@echoxyz/sonar-core"; +import { setPKCEVerifier, getPKCEVerifier, clearPKCEVerifier } from "@/lib/pkce-store"; +import { createSonarClient } from "@/lib/sonar"; +import { SonarTokens } from "@/lib/token-store"; + +/** + * Create a new session (login). + * Given this is an example app - no authentication is required. + */ +export async function login() { + const session = await createSession(); + return { + success: true, + userId: session.userId, + }; +} + +/** + * Destroy the current session (logout). + * Clears session cookie and any associated Sonar tokens. + */ +export async function logout() { + await destroySession(); + return { success: true }; +} + +/** + * Get current session status. + * Returns session info and Sonar connection status. + */ +export async function getSessionStatus() { + const session = await getSession(); + + if (!session) { + return { + authenticated: false, + sonarConnected: false, + }; + } + + const tokens = getTokenStore().getTokens(session.userId); + + return { + authenticated: true, + sonarConnected: !!tokens, + }; +} + +/** + * Generate Sonar OAuth authorization URL with PKCE + */ +export async function getSonarAuthorizationUrl() { + const session = await getSession(); + + if (!session) { + return { error: "Unauthorized" }; + } + + // Generate PKCE parameters (includes state token from sonar-core) + const { codeVerifier, codeChallenge, state } = await generatePKCEParams(); + + // Store code verifier and user ID linked to state token (will be retrieved in callback) + setPKCEVerifier(state, session.userId, codeVerifier); + + // Build authorization URL with PKCE + const authorizationUrl = buildAuthorizationUrl({ + clientUUID: sonarConfig.clientUUID, + redirectURI: sonarConfig.redirectURI, + state, + codeChallenge, + frontendURL: sonarConfig.frontendURL, + }); + + return { url: authorizationUrl.toString() }; +} + +/** + * Handle OAuth callback from Sonar + * Exchange authorization code for access/refresh tokens using PKCE + */ +export async function handleSonarCallback(code: string, state: string) { + // Verify session exists + const session = await getSession(); + if (!session) { + return { error: "Unauthorized", details: "No active session" }; + } + + try { + // Retrieve code verifier and session ID from cookie store using state token + const stateData = getPKCEVerifier(state); + if (!stateData) { + return { error: "Invalid state", details: "OAuth state token not found or expired" }; + } + + // Verify the state token belongs to the current session + if (stateData.userId !== session.userId) { + return { error: "Invalid session", details: "State token does not match current session" }; + } + + const { codeVerifier } = stateData; + + // Create a temporary client to exchange the authorization code + const client = createSonarClient(session.userId); + const tokenData = await client.exchangeAuthorizationCode({ + code, + codeVerifier, + redirectURI: sonarConfig.redirectURI, + }); + + const expiresAt = Math.floor(Date.now() / 1000) + tokenData.expires_in; + + const sonarTokens: SonarTokens = { + accessToken: tokenData.access_token, + refreshToken: tokenData.refresh_token, + expiresAt, + }; + + // Store tokens in token store + getTokenStore().setTokens(session.userId, sonarTokens); + + // Clear the code verifier (no longer needed) + clearPKCEVerifier(state); + + return { success: true }; + } catch (error) { + if (error instanceof Error) { + return { error: "OAuth callback failed", details: error.message }; + } + return { error: "OAuth callback failed" }; + } +} + +/** + * Disconnect Sonar account (remove stored tokens) + */ +export async function disconnectSonar() { + const session = await getSession(); + + if (!session) { + return { error: "Unauthorized" }; + } + + getTokenStore().clearTokens(session.userId); + + return { success: true }; +} diff --git a/src/app/actions/sonar.ts b/src/app/actions/sonar.ts new file mode 100644 index 0000000..e0d7356 --- /dev/null +++ b/src/app/actions/sonar.ts @@ -0,0 +1,76 @@ +"use server"; + +import { createSonarServerAction } from "@/lib/sonar"; +import { + ListAvailableEntitiesResponse, + ReadEntityResponse, + PrePurchaseCheckResponse, + GeneratePurchasePermitResponse, + APIError, +} from "@echoxyz/sonar-core"; + +type ListAvailableEntitiesInput = { saleUUID: string }; + +/** + * Fetch all entities for the authenticated user + */ +export const getEntities = createSonarServerAction( + async (client, { saleUUID }) => { + if (!saleUUID) { + throw new Error("Missing saleUUID"); + } + return client.listAvailableEntities({ saleUUID }); + } +); + +type ReadEntityInput = { saleUUID: string; walletAddress: string }; + +/** + * Fetch a specific entity by wallet address + */ +export const getEntity = createSonarServerAction( + async (client, { saleUUID, walletAddress }) => { + if (!saleUUID || !walletAddress) { + throw new Error("Missing saleUUID or walletAddress"); + } + + try { + return await client.readEntity({ saleUUID, walletAddress }); + } catch (error) { + // Special handling: 404 returns null entity instead of error + if (error instanceof APIError && error.status === 404) { + return { Entity: null } as unknown as ReadEntityResponse; + } + throw error; + } + } +); + +type PrePurchaseCheckInput = { saleUUID: string; entityID: string; walletAddress: string }; + +/** + * Perform pre-purchase check for an entity + */ +export const prePurchaseCheck = createSonarServerAction( + async (client, { saleUUID, entityID, walletAddress }) => { + if (!saleUUID || !entityID || !walletAddress) { + throw new Error("Missing required parameters"); + } + return client.prePurchaseCheck({ saleUUID, entityID, walletAddress }); + } +); + +type GeneratePurchasePermitInput = { saleUUID: string; entityID: string; walletAddress: string }; + +/** + * Generate a purchase permit for an entity + */ +export const generatePurchasePermit = createSonarServerAction< + GeneratePurchasePermitInput, + GeneratePurchasePermitResponse +>(async (client, { saleUUID, entityID, walletAddress }) => { + if (!saleUUID || !entityID || !walletAddress) { + throw new Error("Missing required parameters"); + } + return client.generatePurchasePermit({ saleUUID, entityID, walletAddress }); +}); diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts deleted file mode 100644 index dd46a7e..0000000 --- a/src/app/api/auth/login/route.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { NextResponse } from "next/server"; -import { createSession } from "@/lib/session"; - -/** - * Create a new session (login). - * Given this is an example app - no authentication is required. - */ -export async function POST() { - const session = await createSession(); - - return NextResponse.json({ - success: true, - userId: session.userId, - }); -} diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts deleted file mode 100644 index 7c5d8b6..0000000 --- a/src/app/api/auth/logout/route.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { NextResponse } from "next/server"; -import { destroySession } from "@/lib/session"; - -/** - * Destroy the current session (logout). - * Clears session cookie and any associated Sonar tokens. - */ -export async function POST() { - await destroySession(); - - return NextResponse.json({ success: true }); -} diff --git a/src/app/api/auth/session/route.ts b/src/app/api/auth/session/route.ts deleted file mode 100644 index 018335c..0000000 --- a/src/app/api/auth/session/route.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { NextResponse } from "next/server"; -import { getSession } from "@/lib/session"; -import { getTokenStore } from "@/lib/token-store"; - -/** - * Get current session status. - * Returns session info and Sonar connection status. - */ -export async function GET() { - const session = await getSession(); - - if (!session) { - return NextResponse.json({ - authenticated: false, - sonarConnected: false, - }); - } - - const tokens = getTokenStore().getTokens(session.userId); - - return NextResponse.json({ - authenticated: true, - sonarConnected: !!tokens, - }); -} diff --git a/src/app/api/auth/sonar/authorize/route.ts b/src/app/api/auth/sonar/authorize/route.ts deleted file mode 100644 index 6ad1ed8..0000000 --- a/src/app/api/auth/sonar/authorize/route.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { NextResponse } from "next/server"; -import { getSession } from "@/lib/session"; -import { sonarConfig } from "@/lib/config"; -import { generatePKCEParams, buildAuthorizationUrl } from "@echoxyz/sonar-core"; -import { setPKCEVerifier } from "@/lib/pkce-store"; - -/** - * Generate Sonar OAuth authorization URL with PKCE and redirect user - */ -export async function GET() { - const session = await getSession(); - - if (!session) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - // Generate PKCE parameters (includes state token from sonar-core) - const { codeVerifier, codeChallenge, state } = await generatePKCEParams(); - - // Store code verifier and user ID linked to state token (will be retrieved in callback) - await setPKCEVerifier(state, session.userId, codeVerifier); - - // Build authorization URL with PKCE - const authorizationUrl = buildAuthorizationUrl({ - clientUUID: sonarConfig.clientUUID, - redirectURI: sonarConfig.redirectURI, - state, - codeChallenge, - frontendURL: sonarConfig.frontendURL, - }); - - return NextResponse.json({ url: authorizationUrl.toString() }); -} diff --git a/src/app/api/auth/sonar/callback/route.ts b/src/app/api/auth/sonar/callback/route.ts deleted file mode 100644 index 8dd6553..0000000 --- a/src/app/api/auth/sonar/callback/route.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { getSession } from "@/lib/session"; -import { sonarConfig } from "@/lib/config"; -import { getTokenStore, SonarTokens } from "@/lib/token-store"; -import { getPKCEVerifier, clearPKCEVerifier } from "@/lib/pkce-store"; -import { createSonarClient } from "@/lib/sonar"; - -/** - * Handle OAuth callback from Sonar - * Exchange authorization code for access/refresh tokens using PKCE - */ -export async function GET(request: NextRequest) { - const searchParams = request.nextUrl.searchParams; - const code = searchParams.get("code"); - const state = searchParams.get("state"); - const error = searchParams.get("error"); - - // Handle OAuth provider errors - if (error) { - return NextResponse.json({ error: "OAuth authorization failed", details: error }, { status: 400 }); - } - - // Validate required parameters - if (!code || !state) { - return NextResponse.json( - { error: "Missing required parameters", details: "code and state are required" }, - { status: 400 } - ); - } - - // Verify session exists - const session = await getSession(); - if (!session) { - return NextResponse.json({ error: "Unauthorized", details: "No active session" }, { status: 401 }); - } - - try { - // Retrieve code verifier and session ID from cookie store using state token - const stateData = await getPKCEVerifier(state); - if (!stateData) { - return NextResponse.json( - { error: "Invalid state", details: "OAuth state token not found or expired" }, - { status: 400 } - ); - } - - // Verify the state token belongs to the current session - if (stateData.userId !== session.userId) { - return NextResponse.json( - { error: "Invalid session", details: "State token does not match current session" }, - { status: 401 } - ); - } - - const { codeVerifier } = stateData; - - // Create a temporary client to exchange the authorization code - const client = createSonarClient(session.userId); - const tokenData = await client.exchangeAuthorizationCode({ - code, - codeVerifier, - redirectURI: sonarConfig.redirectURI, - }); - - const expiresAt = Math.floor(Date.now() / 1000) + tokenData.expires_in; - - const sonarTokens: SonarTokens = { - accessToken: tokenData.access_token, - refreshToken: tokenData.refresh_token, - expiresAt, - }; - - // Store tokens in token store - getTokenStore().setTokens(session.userId, sonarTokens); - - // Clear the code verifier (no longer needed) - await clearPKCEVerifier(state); - - // Return success - frontend will navigate to home - return NextResponse.json({ success: true }); - } catch (error) { - // Return proper error response with status code - if (error instanceof Error) { - return NextResponse.json({ error: "OAuth callback failed", details: error.message }, { status: 500 }); - } - return NextResponse.json({ error: "OAuth callback failed" }, { status: 500 }); - } -} diff --git a/src/app/api/auth/sonar/disconnect/route.ts b/src/app/api/auth/sonar/disconnect/route.ts deleted file mode 100644 index bbbb795..0000000 --- a/src/app/api/auth/sonar/disconnect/route.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { NextResponse } from "next/server"; -import { getSession } from "@/lib/session"; -import { getTokenStore } from "@/lib/token-store"; - -/** - * Disconnect Sonar account (remove stored tokens) - */ -export async function POST() { - const session = await getSession(); - - if (!session) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - getTokenStore().clearTokens(session.userId); - - return NextResponse.json({ success: true }); -} diff --git a/src/app/api/sonar/entities/route.ts b/src/app/api/sonar/entities/route.ts deleted file mode 100644 index 4dcca54..0000000 --- a/src/app/api/sonar/entities/route.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { NextResponse } from "next/server"; -import { createSonarRouteHandler } from "@/lib/sonar"; - -type EntitiesRequest = { - saleUUID: string; -}; - -/** - * Proxy request to Sonar ReadEntities endpoint - * Returns all entities for the authenticated user - */ -export const POST = createSonarRouteHandler( - async (client, body) => { - const { saleUUID } = body; - - if (!saleUUID) { - return NextResponse.json({ error: "Missing saleUUID" }, { status: 400 }); - } - - const result = await client.listAvailableEntities({ saleUUID }); - return NextResponse.json(result); - } -); diff --git a/src/app/api/sonar/entity/route.ts b/src/app/api/sonar/entity/route.ts deleted file mode 100644 index f74f955..0000000 --- a/src/app/api/sonar/entity/route.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { NextResponse } from "next/server"; -import { createSonarRouteHandler } from "@/lib/sonar"; -import { APIError } from "@echoxyz/sonar-core"; - -type EntityRequest = { - saleUUID: string; - walletAddress: string; -}; - -/** - * Proxy request to Sonar ReadEntity endpoint - */ -export const POST = createSonarRouteHandler( - async (client, body) => { - const { saleUUID, walletAddress } = body; - - if (!saleUUID || !walletAddress) { - return NextResponse.json( - { error: "Missing saleUUID or walletAddress" }, - { status: 400 } - ); - } - - try { - const result = await client.readEntity({ saleUUID, walletAddress }); - return NextResponse.json(result); - } catch (error) { - // Special handling: 404 returns null entity instead of error - if (error instanceof APIError && error.status === 404) { - return NextResponse.json({ Entity: null }, { status: 200 }); - } - throw error; - } - } -); diff --git a/src/app/api/sonar/generate-purchase-permit/route.ts b/src/app/api/sonar/generate-purchase-permit/route.ts deleted file mode 100644 index 0514413..0000000 --- a/src/app/api/sonar/generate-purchase-permit/route.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { NextResponse } from "next/server"; -import { createSonarRouteHandler } from "@/lib/sonar"; - -type GeneratePurchasePermitRequest = { - saleUUID: string; - entityID: string; - walletAddress: string; -}; - -/** - * Proxy request to Sonar GenerateSalePurchasePermit endpoint - */ -export const POST = createSonarRouteHandler( - async (client, body) => { - const { saleUUID, entityID, walletAddress } = body; - - if (!saleUUID || !entityID || !walletAddress) { - return NextResponse.json( - { error: "Missing required parameters" }, - { status: 400 } - ); - } - - const result = await client.generatePurchasePermit({ - saleUUID, - entityID, - walletAddress, - }); - return NextResponse.json(result); - } -); diff --git a/src/app/api/sonar/pre-purchase-check/route.ts b/src/app/api/sonar/pre-purchase-check/route.ts deleted file mode 100644 index 7a6cb98..0000000 --- a/src/app/api/sonar/pre-purchase-check/route.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { NextResponse } from "next/server"; -import { createSonarRouteHandler } from "@/lib/sonar"; - -type PrePurchaseCheckRequest = { - saleUUID: string; - entityID: string; - walletAddress: string; -}; - -/** - * Proxy request to Sonar PrePurchaseCheck endpoint - */ -export const POST = createSonarRouteHandler( - async (client, body) => { - const { saleUUID, entityID, walletAddress } = body; - - if (!saleUUID || !entityID || !walletAddress) { - return NextResponse.json( - { error: "Missing required parameters" }, - { status: 400 } - ); - } - - const result = await client.prePurchaseCheck({ - saleUUID, - entityID, - walletAddress, - }); - return NextResponse.json(result); - } -); diff --git a/src/app/components/auth/AuthenticationSection.tsx b/src/app/components/auth/AuthenticationSection.tsx index 6ce44c7..253562c 100644 --- a/src/app/components/auth/AuthenticationSection.tsx +++ b/src/app/components/auth/AuthenticationSection.tsx @@ -1,16 +1,16 @@ "use client"; import { useSession } from "@/app/hooks/use-session"; +import { getSonarAuthorizationUrl, disconnectSonar } from "@/app/actions/auth"; export function AuthenticationSection() { const { authenticated, sonarConnected, loading, login, logout } = useSession(); const handleConnectSonar = async () => { try { - const response = await fetch("/api/auth/sonar/authorize"); - const data = await response.json(); - if (data.url) { - window.location.href = data.url; + const result = await getSonarAuthorizationUrl(); + if ("url" in result && result.url) { + window.location.href = result.url; } } catch (error) { console.error("Failed to get Sonar authorization URL:", error); @@ -19,7 +19,7 @@ export function AuthenticationSection() { const handleDisconnectSonar = async () => { try { - await fetch("/api/auth/sonar/disconnect", { method: "POST" }); + await disconnectSonar(); window.location.reload(); } catch (error) { console.error("Failed to disconnect Sonar:", error); diff --git a/src/app/hooks/use-session.tsx b/src/app/hooks/use-session.tsx index 15190e4..2041029 100644 --- a/src/app/hooks/use-session.tsx +++ b/src/app/hooks/use-session.tsx @@ -1,6 +1,7 @@ "use client"; import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from "react"; +import { login as loginAction, logout as logoutAction, getSessionStatus } from "@/app/actions/auth"; interface SessionState { authenticated: boolean; @@ -25,8 +26,7 @@ export function SessionProvider({ children }: { children: ReactNode }) { const refreshSession = useCallback(async () => { try { - const response = await fetch("/api/auth/session"); - const data = await response.json(); + const data = await getSessionStatus(); setState({ authenticated: data.authenticated, sonarConnected: data.sonarConnected, @@ -47,7 +47,7 @@ export function SessionProvider({ children }: { children: ReactNode }) { const login = useCallback(async () => { try { - await fetch("/api/auth/login", { method: "POST" }); + await loginAction(); await refreshSession(); } catch (error) { console.error("Login failed:", error); @@ -56,7 +56,7 @@ export function SessionProvider({ children }: { children: ReactNode }) { const logout = useCallback(async () => { try { - await fetch("/api/auth/logout", { method: "POST" }); + await logoutAction(); await refreshSession(); } catch (error) { console.error("Logout failed:", error); diff --git a/src/app/hooks/use-sonar-entities.ts b/src/app/hooks/use-sonar-entities.ts index 6a6b14e..294d839 100644 --- a/src/app/hooks/use-sonar-entities.ts +++ b/src/app/hooks/use-sonar-entities.ts @@ -3,12 +3,15 @@ import { ListAvailableEntitiesResponse } from "@echoxyz/sonar-core"; import { saleUUID } from "@/lib/config"; import { useSonarQuery } from "./use-sonar-query"; +import { getEntities } from "@/app/actions/sonar"; /** * Hook to fetch all Sonar entities for the authenticated user */ export function useSonarEntities() { - const { loading, data, error } = useSonarQuery("/api/sonar/entities", { saleUUID }); + const { loading, data, error } = useSonarQuery<{ saleUUID: string }, ListAvailableEntitiesResponse>(getEntities, { + saleUUID, + }); return { loading, diff --git a/src/app/hooks/use-sonar-entity.ts b/src/app/hooks/use-sonar-entity.ts index a1b6699..9265947 100644 --- a/src/app/hooks/use-sonar-entity.ts +++ b/src/app/hooks/use-sonar-entity.ts @@ -3,12 +3,16 @@ import { ReadEntityResponse } from "@echoxyz/sonar-core"; import { saleUUID } from "@/lib/config"; import { useSonarQuery } from "./use-sonar-query"; +import { getEntity } from "@/app/actions/sonar"; /** * Hook to fetch Sonar entity details for a specific wallet */ export function useSonarEntity(walletAddress?: string) { - const query = useSonarQuery("/api/sonar/entity", { + const query = useSonarQuery< + { saleUUID: string; walletAddress: string }, + ReadEntityResponse + >(getEntity, { saleUUID, walletAddress: walletAddress ?? "", }); diff --git a/src/app/hooks/use-sonar-purchase.ts b/src/app/hooks/use-sonar-purchase.ts index e6e816a..d92851b 100644 --- a/src/app/hooks/use-sonar-purchase.ts +++ b/src/app/hooks/use-sonar-purchase.ts @@ -10,6 +10,7 @@ import { UseSonarPurchaseResult } from "@echoxyz/sonar-react"; import { useSession } from "./use-session"; import { useCallback } from "react"; import { useSonarQuery } from "./use-sonar-query"; +import { prePurchaseCheck, generatePurchasePermit as generatePurchasePermitAction } from "@/app/actions/sonar"; /** * Hook for Sonar purchase flow @@ -21,7 +22,10 @@ export function useSonarPurchase(args: { }): UseSonarPurchaseResult { const { authenticated } = useSession(); - const { loading, data, error } = useSonarQuery("/api/sonar/pre-purchase-check", { + const { loading, data, error } = useSonarQuery< + { saleUUID: string; entityID: string; walletAddress: string }, + PrePurchaseCheckResponse + >(prePurchaseCheck, { saleUUID: args.saleUUID, entityID: args.entityID, walletAddress: args.walletAddress, @@ -32,22 +36,17 @@ export function useSonarPurchase(args: { throw new Error("Not authenticated"); } - const response = await fetch("/api/sonar/generate-purchase-permit", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - saleUUID: args.saleUUID, - entityID: args.entityID, - walletAddress: args.walletAddress, - }), + const result = await generatePurchasePermitAction({ + saleUUID: args.saleUUID, + entityID: args.entityID, + walletAddress: args.walletAddress, }); - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || `Request failed: ${response.status}`); + if (!result.success) { + throw new Error(result.error); } - return response.json(); + return result.data; }, [authenticated, args.saleUUID, args.entityID, args.walletAddress]); if (loading) { diff --git a/src/app/hooks/use-sonar-query.ts b/src/app/hooks/use-sonar-query.ts index e30a72c..f91877e 100644 --- a/src/app/hooks/use-sonar-query.ts +++ b/src/app/hooks/use-sonar-query.ts @@ -1,7 +1,8 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback } from "react"; import { useSession } from "./use-session"; +import { ServerActionResult } from "@/lib/sonar"; export type SonarQueryState = | { loading: true; data: undefined; error: undefined } @@ -10,57 +11,54 @@ export type SonarQueryState = | { loading: false; data: undefined; error: undefined }; // idle/skipped state /** - * Generic hook for Sonar API queries with shared session/auth handling + * Generic hook for Sonar server action queries with shared session/auth handling */ -export function useSonarQuery(endpoint: string, body: Record): SonarQueryState { +export function useSonarQuery( + action: (input: TInput) => Promise>, + input: TInput +): SonarQueryState { const { authenticated, sonarConnected, refreshSession } = useSession(); - const [state, setState] = useState>({ + const [state, setState] = useState>({ loading: true, data: undefined, error: undefined, }); - // Serialize body for stable dependency comparison - const serializedBody = JSON.stringify(body); + // Serialize input for stable dependency comparison + const serializedInput = JSON.stringify(input); - useEffect(() => { + const fetchData = useCallback(async () => { if (!authenticated || !sonarConnected) { setState({ loading: false, data: undefined, error: undefined }); return; } - const fetchData = async () => { - setState({ loading: true, data: undefined, error: undefined }); + setState({ loading: true, data: undefined, error: undefined }); - try { - const response = await fetch(endpoint, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: serializedBody, - }); + try { + const result = await action(JSON.parse(serializedInput) as TInput); - if (!response.ok) { - if (response.status === 401) { - await refreshSession(); - return; - } - const errorData = await response.json(); - throw new Error(errorData.error || `Request failed: ${response.status}`); + if (!result.success) { + if (result.unauthorized) { + await refreshSession(); + return; } - - const data = await response.json(); - setState({ loading: false, data, error: undefined }); - } catch (err) { - setState({ - loading: false, - data: undefined, - error: err instanceof Error ? err : new Error(String(err)), - }); + throw new Error(result.error); } - }; + setState({ loading: false, data: result.data, error: undefined }); + } catch (err) { + setState({ + loading: false, + data: undefined, + error: err instanceof Error ? err : new Error(String(err)), + }); + } + }, [authenticated, sonarConnected, action, serializedInput, refreshSession]); + + useEffect(() => { fetchData(); - }, [authenticated, sonarConnected, endpoint, serializedBody, refreshSession]); + }, [fetchData]); return state; } diff --git a/src/app/oauth/callback/page.tsx b/src/app/oauth/callback/page.tsx index 2e2e2cf..2e096bd 100644 --- a/src/app/oauth/callback/page.tsx +++ b/src/app/oauth/callback/page.tsx @@ -2,6 +2,7 @@ import { Suspense, useEffect, useState } from "react"; import { useSearchParams } from "next/navigation"; +import { handleSonarCallback } from "@/app/actions/auth"; /** * OAuth callback content - uses useSearchParams which requires Suspense @@ -11,7 +12,7 @@ function OAuthCallbackContent() { const [error, setError] = useState(null); useEffect(() => { - const handleCallback = async () => { + const processCallback = async () => { // Extract query parameters const code = searchParams.get("code"); const state = searchParams.get("state"); @@ -28,12 +29,11 @@ function OAuthCallbackContent() { } try { - // Call backend callback handler - const response = await fetch(`/api/auth/sonar/callback?code=${code}&state=${state}`); + // Call server action to handle callback + const result = await handleSonarCallback(code, state); - if (!response.ok) { - const errorData = await response.json().catch(() => ({ error: "Unknown error" })); - setError(errorData.error || "Failed to complete OAuth flow"); + if ("error" in result) { + setError(result.error || "Failed to complete OAuth flow"); return; } @@ -45,7 +45,7 @@ function OAuthCallbackContent() { } }; - handleCallback(); + processCallback(); }, [searchParams]); return ( diff --git a/src/lib/sonar.ts b/src/lib/sonar.ts index 2e50bee..9d88d18 100644 --- a/src/lib/sonar.ts +++ b/src/lib/sonar.ts @@ -1,4 +1,3 @@ -import { NextRequest, NextResponse } from "next/server"; import { getSession } from "@/lib/session"; import { getTokenStore, SonarTokens } from "@/lib/token-store"; import { sonarConfig } from "@/lib/config"; @@ -29,7 +28,6 @@ export function createSonarClient(userId: string): SonarClient { return client; } - // In-flight refresh promises by session ID - prevents concurrent refresh attempts const refreshPromises = new Map>(); @@ -68,23 +66,29 @@ async function refreshSonarToken(sessionId: string, refreshToken: string): Promi } } -type RouteHandler = (client: SonarClient, body: T) => Promise; +export type ServerActionResult = + | { success: true; data: T } + | { success: false; error: string; unauthorized?: boolean }; + +type ServerActionHandler = (client: SonarClient, input: I) => Promise; /** - * Creates a Sonar API route handler with authentication, token refresh, and error handling. + * Creates a Sonar server action with authentication, token refresh, and error handling. */ -export function createSonarRouteHandler(handler: RouteHandler): (request: NextRequest) => Promise { - return async (request: NextRequest) => { +export function createSonarServerAction( + handler: ServerActionHandler +): (input: I) => Promise> { + return async (input: I) => { // Check session authentication const session = await getSession(); if (!session) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + return { success: false, error: "Unauthorized", unauthorized: true }; } // Get tokens from store let tokens = getTokenStore().getTokens(session.userId); if (!tokens) { - return NextResponse.json({ error: "Sonar account not connected" }, { status: 401 }); + return { success: false, error: "Sonar account not connected", unauthorized: true }; } // Check if token needs refresh (within 5 minutes of expiry) @@ -94,22 +98,21 @@ export function createSonarRouteHandler(handler: RouteHandler): (request: tokens = await refreshSonarToken(session.userId, tokens.refreshToken); getTokenStore().setTokens(session.userId, tokens); } catch { - return NextResponse.json({ error: "Failed to refresh token" }, { status: 401 }); + return { success: false, error: "Failed to refresh token", unauthorized: true }; } } try { - const body = (await request.json()) as T; const client = createSonarClient(session.userId); - - return await handler(client, body); + const data = await handler(client, input); + return { success: true, data }; } catch (error) { if (error instanceof APIError) { - return NextResponse.json({ error: error.message }, { status: error.status }); + return { success: false, error: error.message }; } console.error("Error calling Sonar API"); - return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + return { success: false, error: "Internal server error" }; } }; } From 6b387ca76807d5e77e70bbf2616d0bb7468b6d1f Mon Sep 17 00:00:00 2001 From: Will Sewell Date: Fri, 9 Jan 2026 19:59:51 +0000 Subject: [PATCH 2/2] Just throw errors --- src/app/actions/auth.ts | 99 ++++++++----------- .../components/auth/AuthenticationSection.tsx | 6 +- src/app/hooks/use-sonar-purchase.ts | 8 +- src/app/hooks/use-sonar-query.ts | 26 ++--- src/app/oauth/callback/page.tsx | 11 +-- src/lib/errors.ts | 10 ++ src/lib/sonar.ts | 35 +++---- 7 files changed, 79 insertions(+), 116 deletions(-) create mode 100644 src/lib/errors.ts diff --git a/src/app/actions/auth.ts b/src/app/actions/auth.ts index cc006a0..13a07dd 100644 --- a/src/app/actions/auth.ts +++ b/src/app/actions/auth.ts @@ -12,28 +12,24 @@ import { SonarTokens } from "@/lib/token-store"; * Create a new session (login). * Given this is an example app - no authentication is required. */ -export async function login() { +export async function login(): Promise<{ userId: string }> { const session = await createSession(); - return { - success: true, - userId: session.userId, - }; + return { userId: session.userId }; } /** * Destroy the current session (logout). * Clears session cookie and any associated Sonar tokens. */ -export async function logout() { +export async function logout(): Promise { await destroySession(); - return { success: true }; } /** * Get current session status. * Returns session info and Sonar connection status. */ -export async function getSessionStatus() { +export async function getSessionStatus(): Promise<{ authenticated: boolean; sonarConnected: boolean }> { const session = await getSession(); if (!session) { @@ -54,11 +50,11 @@ export async function getSessionStatus() { /** * Generate Sonar OAuth authorization URL with PKCE */ -export async function getSonarAuthorizationUrl() { +export async function getSonarAuthorizationUrl(): Promise { const session = await getSession(); if (!session) { - return { error: "Unauthorized" }; + throw new Error("Unauthorized"); } // Generate PKCE parameters (includes state token from sonar-core) @@ -76,76 +72,65 @@ export async function getSonarAuthorizationUrl() { frontendURL: sonarConfig.frontendURL, }); - return { url: authorizationUrl.toString() }; + return authorizationUrl.toString(); } /** * Handle OAuth callback from Sonar * Exchange authorization code for access/refresh tokens using PKCE */ -export async function handleSonarCallback(code: string, state: string) { +export async function handleSonarCallback(code: string, state: string): Promise { // Verify session exists const session = await getSession(); if (!session) { - return { error: "Unauthorized", details: "No active session" }; + throw new Error("Unauthorized: No active session"); } - try { - // Retrieve code verifier and session ID from cookie store using state token - const stateData = getPKCEVerifier(state); - if (!stateData) { - return { error: "Invalid state", details: "OAuth state token not found or expired" }; - } - - // Verify the state token belongs to the current session - if (stateData.userId !== session.userId) { - return { error: "Invalid session", details: "State token does not match current session" }; - } - - const { codeVerifier } = stateData; - - // Create a temporary client to exchange the authorization code - const client = createSonarClient(session.userId); - const tokenData = await client.exchangeAuthorizationCode({ - code, - codeVerifier, - redirectURI: sonarConfig.redirectURI, - }); - - const expiresAt = Math.floor(Date.now() / 1000) + tokenData.expires_in; - - const sonarTokens: SonarTokens = { - accessToken: tokenData.access_token, - refreshToken: tokenData.refresh_token, - expiresAt, - }; + // Retrieve code verifier and session ID from cookie store using state token + const stateData = getPKCEVerifier(state); + if (!stateData) { + throw new Error("Invalid state: OAuth state token not found or expired"); + } - // Store tokens in token store - getTokenStore().setTokens(session.userId, sonarTokens); + // Verify the state token belongs to the current session + if (stateData.userId !== session.userId) { + throw new Error("Invalid session: State token does not match current session"); + } - // Clear the code verifier (no longer needed) - clearPKCEVerifier(state); + const { codeVerifier } = stateData; - return { success: true }; - } catch (error) { - if (error instanceof Error) { - return { error: "OAuth callback failed", details: error.message }; - } - return { error: "OAuth callback failed" }; - } + // Create a temporary client to exchange the authorization code + const client = createSonarClient(session.userId); + const tokenData = await client.exchangeAuthorizationCode({ + code, + codeVerifier, + redirectURI: sonarConfig.redirectURI, + }); + + const expiresAt = Math.floor(Date.now() / 1000) + tokenData.expires_in; + + const sonarTokens: SonarTokens = { + accessToken: tokenData.access_token, + refreshToken: tokenData.refresh_token, + expiresAt, + }; + + // Store tokens in token store + getTokenStore().setTokens(session.userId, sonarTokens); + + // Clear the code verifier (no longer needed) + clearPKCEVerifier(state); } /** * Disconnect Sonar account (remove stored tokens) */ -export async function disconnectSonar() { +export async function disconnectSonar(): Promise { const session = await getSession(); if (!session) { - return { error: "Unauthorized" }; + throw new Error("Unauthorized"); } getTokenStore().clearTokens(session.userId); - - return { success: true }; } diff --git a/src/app/components/auth/AuthenticationSection.tsx b/src/app/components/auth/AuthenticationSection.tsx index 253562c..11fbbf6 100644 --- a/src/app/components/auth/AuthenticationSection.tsx +++ b/src/app/components/auth/AuthenticationSection.tsx @@ -8,10 +8,8 @@ export function AuthenticationSection() { const handleConnectSonar = async () => { try { - const result = await getSonarAuthorizationUrl(); - if ("url" in result && result.url) { - window.location.href = result.url; - } + const url = await getSonarAuthorizationUrl(); + window.location.href = url; } catch (error) { console.error("Failed to get Sonar authorization URL:", error); } diff --git a/src/app/hooks/use-sonar-purchase.ts b/src/app/hooks/use-sonar-purchase.ts index d92851b..39afb70 100644 --- a/src/app/hooks/use-sonar-purchase.ts +++ b/src/app/hooks/use-sonar-purchase.ts @@ -36,17 +36,11 @@ export function useSonarPurchase(args: { throw new Error("Not authenticated"); } - const result = await generatePurchasePermitAction({ + return generatePurchasePermitAction({ saleUUID: args.saleUUID, entityID: args.entityID, walletAddress: args.walletAddress, }); - - if (!result.success) { - throw new Error(result.error); - } - - return result.data; }, [authenticated, args.saleUUID, args.entityID, args.walletAddress]); if (loading) { diff --git a/src/app/hooks/use-sonar-query.ts b/src/app/hooks/use-sonar-query.ts index f91877e..2c1ee11 100644 --- a/src/app/hooks/use-sonar-query.ts +++ b/src/app/hooks/use-sonar-query.ts @@ -2,7 +2,7 @@ import { useState, useEffect, useCallback } from "react"; import { useSession } from "./use-session"; -import { ServerActionResult } from "@/lib/sonar"; +import { UnauthorizedError } from "@/lib/errors"; export type SonarQueryState = | { loading: true; data: undefined; error: undefined } @@ -13,12 +13,9 @@ export type SonarQueryState = /** * Generic hook for Sonar server action queries with shared session/auth handling */ -export function useSonarQuery( - action: (input: TInput) => Promise>, - input: TInput -): SonarQueryState { +export function useSonarQuery(action: (input: I) => Promise, input: I): SonarQueryState { const { authenticated, sonarConnected, refreshSession } = useSession(); - const [state, setState] = useState>({ + const [state, setState] = useState>({ loading: true, data: undefined, error: undefined, @@ -36,18 +33,13 @@ export function useSonarQuery( setState({ loading: true, data: undefined, error: undefined }); try { - const result = await action(JSON.parse(serializedInput) as TInput); - - if (!result.success) { - if (result.unauthorized) { - await refreshSession(); - return; - } - throw new Error(result.error); - } - - setState({ loading: false, data: result.data, error: undefined }); + const data = await action(JSON.parse(serializedInput) as I); + setState({ loading: false, data, error: undefined }); } catch (err) { + if (err instanceof UnauthorizedError) { + await refreshSession(); + return; + } setState({ loading: false, data: undefined, diff --git a/src/app/oauth/callback/page.tsx b/src/app/oauth/callback/page.tsx index 2e096bd..ff4266e 100644 --- a/src/app/oauth/callback/page.tsx +++ b/src/app/oauth/callback/page.tsx @@ -30,18 +30,13 @@ function OAuthCallbackContent() { try { // Call server action to handle callback - const result = await handleSonarCallback(code, state); - - if ("error" in result) { - setError(result.error || "Failed to complete OAuth flow"); - return; - } + await handleSonarCallback(code, state); // Success - do a hard navigation to home // This ensures a full page load that picks up the updated session window.location.href = "/"; - } catch { - setError("Failed to process OAuth callback"); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to process OAuth callback"); } }; diff --git a/src/lib/errors.ts b/src/lib/errors.ts new file mode 100644 index 0000000..774fb05 --- /dev/null +++ b/src/lib/errors.ts @@ -0,0 +1,10 @@ +/** + * Error thrown when a user is not authenticated or their Sonar connection is invalid. + * Consumers can check for this error type to trigger re-authentication. + */ +export class UnauthorizedError extends Error { + constructor(message: string) { + super(message); + this.name = "UnauthorizedError"; + } +} diff --git a/src/lib/sonar.ts b/src/lib/sonar.ts index 9d88d18..9b32eaf 100644 --- a/src/lib/sonar.ts +++ b/src/lib/sonar.ts @@ -1,7 +1,11 @@ import { getSession } from "@/lib/session"; import { getTokenStore, SonarTokens } from "@/lib/token-store"; import { sonarConfig } from "@/lib/config"; -import { APIError, SonarClient } from "@echoxyz/sonar-core"; +import { SonarClient } from "@echoxyz/sonar-core"; +import { UnauthorizedError } from "@/lib/errors"; + +// Re-export for backwards compatibility +export { UnauthorizedError } from "@/lib/errors"; /** * Create a SonarClient instance for a specific user @@ -66,29 +70,24 @@ async function refreshSonarToken(sessionId: string, refreshToken: string): Promi } } -export type ServerActionResult = - | { success: true; data: T } - | { success: false; error: string; unauthorized?: boolean }; - type ServerActionHandler = (client: SonarClient, input: I) => Promise; /** * Creates a Sonar server action with authentication, token refresh, and error handling. + * Throws on errors instead of returning error objects. */ -export function createSonarServerAction( - handler: ServerActionHandler -): (input: I) => Promise> { +export function createSonarServerAction(handler: ServerActionHandler): (input: I) => Promise { return async (input: I) => { // Check session authentication const session = await getSession(); if (!session) { - return { success: false, error: "Unauthorized", unauthorized: true }; + throw new UnauthorizedError("Unauthorized"); } // Get tokens from store let tokens = getTokenStore().getTokens(session.userId); if (!tokens) { - return { success: false, error: "Sonar account not connected", unauthorized: true }; + throw new UnauthorizedError("Sonar account not connected"); } // Check if token needs refresh (within 5 minutes of expiry) @@ -98,21 +97,11 @@ export function createSonarServerAction( tokens = await refreshSonarToken(session.userId, tokens.refreshToken); getTokenStore().setTokens(session.userId, tokens); } catch { - return { success: false, error: "Failed to refresh token", unauthorized: true }; + throw new UnauthorizedError("Failed to refresh token"); } } - try { - const client = createSonarClient(session.userId); - const data = await handler(client, input); - return { success: true, data }; - } catch (error) { - if (error instanceof APIError) { - return { success: false, error: error.message }; - } - - console.error("Error calling Sonar API"); - return { success: false, error: "Internal server error" }; - } + const client = createSonarClient(session.userId); + return handler(client, input); }; }