From f8d1ab89c1293a8b07fefb43a4e033eb7fc52aa1 Mon Sep 17 00:00:00 2001 From: Will Demaine Date: Tue, 9 Dec 2025 19:39:40 -0500 Subject: [PATCH 01/20] Convert to backend oauth example --- .env.local | 3 + .npmrc | 1 + package.json | 5 +- pnpm-lock.yaml | 323 ++++++++++++++---- src/app/Provider.tsx | 9 +- src/app/PurchasePanel.tsx | 6 +- src/app/api/auth/[...nextauth]/route.ts | 178 ++++++++++ src/app/api/auth/sonar/authorize/route.ts | 34 ++ src/app/api/auth/sonar/callback/route.ts | 90 +++++ src/app/api/auth/sonar/disconnect/route.ts | 19 ++ src/app/api/sonar/entity/route.ts | 61 ++++ .../sonar/generate-purchase-permit/route.ts | 56 +++ src/app/api/sonar/pre-purchase-check/route.ts | 56 +++ src/app/hooks/use-siwe.ts | 65 ++++ src/app/hooks/use-sonar-entity.ts | 71 ++++ src/app/hooks/use-sonar-purchase.ts | 149 ++++++++ src/app/oauth/callback/page.tsx | 122 +++---- src/app/page.tsx | 120 +++++-- src/lib/pkce-store.ts | 79 +++++ src/lib/siwe.ts | 75 ++++ src/lib/sonar-client.ts | 30 ++ src/lib/token-store.ts | 70 ++++ src/types/next-auth.d.ts | 28 ++ 23 files changed, 1477 insertions(+), 173 deletions(-) create mode 100644 .npmrc create mode 100644 src/app/api/auth/[...nextauth]/route.ts create mode 100644 src/app/api/auth/sonar/authorize/route.ts create mode 100644 src/app/api/auth/sonar/callback/route.ts create mode 100644 src/app/api/auth/sonar/disconnect/route.ts create mode 100644 src/app/api/sonar/entity/route.ts create mode 100644 src/app/api/sonar/generate-purchase-permit/route.ts create mode 100644 src/app/api/sonar/pre-purchase-check/route.ts create mode 100644 src/app/hooks/use-siwe.ts create mode 100644 src/app/hooks/use-sonar-entity.ts create mode 100644 src/app/hooks/use-sonar-purchase.ts create mode 100644 src/lib/pkce-store.ts create mode 100644 src/lib/siwe.ts create mode 100644 src/lib/sonar-client.ts create mode 100644 src/lib/token-store.ts create mode 100644 src/types/next-auth.d.ts diff --git a/.env.local b/.env.local index 0768e79..bdb6f6c 100644 --- a/.env.local +++ b/.env.local @@ -8,3 +8,6 @@ NEXT_PUBLIC_OAUTH_CLIENT_REDIRECT_URI=http://localhost:3000/oauth/callback # Your sale contract address NEXT_PUBLIC_SALE_CONTRACT_ADDRESS=0x897Cf7E2cAD4f716172CF82edE45eF7956B33Af0 + +NEXTAUTH_SECRET="TODO" # Generate something secure for this +NEXTAUTH_URL=http://localhost:3000 \ No newline at end of file diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..38f11c6 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +registry=https://registry.npmjs.org diff --git a/package.json b/package.json index 266ac51..405f85c 100644 --- a/package.json +++ b/package.json @@ -14,9 +14,12 @@ "@echoxyz/sonar-react": "^0.12.0", "@tanstack/react-query": "^5.90.2", "connectkit": "^1.9.1", - "next": "15.5.4", + "ethers": "^6.16.0", + "next": "^15.5.7", + "next-auth": "5.0.0-beta.30", "react": "^18.3.1", "react-dom": "^18.3.1", + "siwe": "^3.0.0", "wagmi": "^2.17.4" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 305383e..d761696 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,15 +23,24 @@ importers: connectkit: specifier: ^1.9.1 version: 1.9.1(@babel/core@7.28.5)(@tanstack/react-query@5.90.9(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react-is@17.0.2)(react@18.3.1)(viem@2.39.0(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76))(wagmi@2.19.4(@tanstack/query-core@5.90.9)(@tanstack/react-query@5.90.9(react@18.3.1))(@types/react@18.3.26)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react@18.3.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.39.0(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76))(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76)) + ethers: + specifier: ^6.16.0 + version: 6.16.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) next: - specifier: 15.5.4 - version: 15.5.4(@babel/core@7.28.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: ^15.5.7 + version: 15.5.7(@babel/core@7.28.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + next-auth: + specifier: 5.0.0-beta.30 + version: 5.0.0-beta.30(next@15.5.7(@babel/core@7.28.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) react: specifier: ^18.3.1 version: 18.3.1 react-dom: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) + siwe: + specifier: ^3.0.0 + version: 3.0.0(ethers@6.16.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) wagmi: specifier: ^2.17.4 version: 2.19.4(@tanstack/query-core@5.90.9)(@tanstack/react-query@5.90.9(react@18.3.1))(@types/react@18.3.26)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react@18.3.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.39.0(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76))(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76) @@ -109,6 +118,9 @@ packages: '@acemir/cssom@0.9.23': resolution: {integrity: sha512-2kJ1HxBKzPLbmhZpxBiTZggjtgCwKg1ma5RHShxvd6zgqhDEdEkzpiwe7jLkI2p2BrZvFCXIihdoMkl1H39VnA==} + '@adraffy/ens-normalize@1.10.1': + resolution: {integrity: sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw==} + '@adraffy/ens-normalize@1.11.1': resolution: {integrity: sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==} @@ -125,6 +137,20 @@ packages: '@asamuzakjp/nwsapi@2.3.9': resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@auth/core@0.41.0': + resolution: {integrity: sha512-Wd7mHPQ/8zy6Qj7f4T46vg3aoor8fskJm6g2Zyj064oQ3+p0xNZXAV60ww0hY+MbTesfu29kK14Zk5d5JTazXQ==} + peerDependencies: + '@simplewebauthn/browser': ^9.0.1 + '@simplewebauthn/server': ^9.0.2 + nodemailer: ^6.8.0 + peerDependenciesMeta: + '@simplewebauthn/browser': + optional: true + '@simplewebauthn/server': + optional: true + nodemailer: + optional: true + '@babel/code-frame@7.27.1': resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} @@ -928,56 +954,56 @@ packages: '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} - '@next/env@15.5.4': - resolution: {integrity: sha512-27SQhYp5QryzIT5uO8hq99C69eLQ7qkzkDPsk3N+GuS2XgOgoYEeOav7Pf8Tn4drECOVDsDg8oj+/DVy8qQL2A==} + '@next/env@15.5.7': + resolution: {integrity: sha512-4h6Y2NyEkIEN7Z8YxkA27pq6zTkS09bUSYC0xjd0NpwFxjnIKeZEeH591o5WECSmjpUhLn3H2QLJcDye3Uzcvg==} '@next/eslint-plugin-next@15.5.4': resolution: {integrity: sha512-SR1vhXNNg16T4zffhJ4TS7Xn7eq4NfKfcOsRwea7RIAHrjRpI9ALYbamqIJqkAhowLlERffiwk0FMvTLNdnVtw==} - '@next/swc-darwin-arm64@15.5.4': - resolution: {integrity: sha512-nopqz+Ov6uvorej8ndRX6HlxCYWCO3AHLfKK2TYvxoSB2scETOcfm/HSS3piPqc3A+MUgyHoqE6je4wnkjfrOA==} + '@next/swc-darwin-arm64@15.5.7': + resolution: {integrity: sha512-IZwtxCEpI91HVU/rAUOOobWSZv4P2DeTtNaCdHqLcTJU4wdNXgAySvKa/qJCgR5m6KI8UsKDXtO2B31jcaw1Yw==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@15.5.4': - resolution: {integrity: sha512-QOTCFq8b09ghfjRJKfb68kU9k2K+2wsC4A67psOiMn849K9ZXgCSRQr0oVHfmKnoqCbEmQWG1f2h1T2vtJJ9mA==} + '@next/swc-darwin-x64@15.5.7': + resolution: {integrity: sha512-UP6CaDBcqaCBuiq/gfCEJw7sPEoX1aIjZHnBWN9v9qYHQdMKvCKcAVs4OX1vIjeE+tC5EIuwDTVIoXpUes29lg==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@15.5.4': - resolution: {integrity: sha512-eRD5zkts6jS3VfE/J0Kt1VxdFqTnMc3QgO5lFE5GKN3KDI/uUpSyK3CjQHmfEkYR4wCOl0R0XrsjpxfWEA++XA==} + '@next/swc-linux-arm64-gnu@15.5.7': + resolution: {integrity: sha512-NCslw3GrNIw7OgmRBxHtdWFQYhexoUCq+0oS2ccjyYLtcn1SzGzeM54jpTFonIMUjNbHmpKpziXnpxhSWLcmBA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-arm64-musl@15.5.4': - resolution: {integrity: sha512-TOK7iTxmXFc45UrtKqWdZ1shfxuL4tnVAOuuJK4S88rX3oyVV4ZkLjtMT85wQkfBrOOvU55aLty+MV8xmcJR8A==} + '@next/swc-linux-arm64-musl@15.5.7': + resolution: {integrity: sha512-nfymt+SE5cvtTrG9u1wdoxBr9bVB7mtKTcj0ltRn6gkP/2Nu1zM5ei8rwP9qKQP0Y//umK+TtkKgNtfboBxRrw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-x64-gnu@15.5.4': - resolution: {integrity: sha512-7HKolaj+481FSW/5lL0BcTkA4Ueam9SPYWyN/ib/WGAFZf0DGAN8frNpNZYFHtM4ZstrHZS3LY3vrwlIQfsiMA==} + '@next/swc-linux-x64-gnu@15.5.7': + resolution: {integrity: sha512-hvXcZvCaaEbCZcVzcY7E1uXN9xWZfFvkNHwbe/n4OkRhFWrs1J1QV+4U1BN06tXLdaS4DazEGXwgqnu/VMcmqw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-linux-x64-musl@15.5.4': - resolution: {integrity: sha512-nlQQ6nfgN0nCO/KuyEUwwOdwQIGjOs4WNMjEUtpIQJPR2NUfmGpW2wkJln1d4nJ7oUzd1g4GivH5GoEPBgfsdw==} + '@next/swc-linux-x64-musl@15.5.7': + resolution: {integrity: sha512-4IUO539b8FmF0odY6/SqANJdgwn1xs1GkPO5doZugwZ3ETF6JUdckk7RGmsfSf7ws8Qb2YB5It33mvNL/0acqA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-win32-arm64-msvc@15.5.4': - resolution: {integrity: sha512-PcR2bN7FlM32XM6eumklmyWLLbu2vs+D7nJX8OAIoWy69Kef8mfiN4e8TUv2KohprwifdpFKPzIP1njuCjD0YA==} + '@next/swc-win32-arm64-msvc@15.5.7': + resolution: {integrity: sha512-CpJVTkYI3ZajQkC5vajM7/ApKJUOlm6uP4BknM3XKvJ7VXAvCqSjSLmM0LKdYzn6nBJVSjdclx8nYJSa3xlTgQ==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@15.5.4': - resolution: {integrity: sha512-1ur2tSHZj8Px/KMAthmuI9FMp/YFusMMGoRNJaRZMOlSkgvLjzosSdQI0cJAKogdHl3qXUQKL9MGaYvKwA7DXg==} + '@next/swc-win32-x64-msvc@15.5.7': + resolution: {integrity: sha512-gMzgBX164I6DN+9/PGA+9dQiwmTkE4TloBNx8Kv9UiGARsr9Nba7IpcBRA1iTV9vwlYnrE3Uy6I7Aj6qLjQuqw==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -990,6 +1016,9 @@ packages: resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==} engines: {node: ^14.21.3 || >=16} + '@noble/curves@1.2.0': + resolution: {integrity: sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==} + '@noble/curves@1.4.2': resolution: {integrity: sha512-TavHr8qycMChk8UwMld0ZDRvatedkzWfH8IiaeGCfymOP5i0hSCozz9vHOL0nkwk7HRMlFnAiKpS2jrUmSybcw==} @@ -1009,6 +1038,10 @@ packages: resolution: {integrity: sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==} engines: {node: ^14.21.3 || >=16} + '@noble/hashes@1.3.2': + resolution: {integrity: sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==} + engines: {node: '>= 16'} + '@noble/hashes@1.4.0': resolution: {integrity: sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==} engines: {node: '>= 16'} @@ -1041,6 +1074,9 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} + '@panva/hkdf@1.2.1': + resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==} + '@paulmillr/qr@0.2.1': resolution: {integrity: sha512-IHnV6A+zxU7XwmKFinmYjUcwlyK9+xkG3/s9KcQhI9BjQKycrJ1JRO+FbNYPwZiPKW3je/DR0k7w8/gLa5eaxQ==} deprecated: 'The package is now available as "qr": npm install qr' @@ -1492,6 +1528,21 @@ packages: '@solana/web3.js@1.98.4': resolution: {integrity: sha512-vv9lfnvjUsRiq//+j5pBdXig0IQdtzA0BRZ3bXEP4KaIyF1CcaydWqgyzQgfZMNIsWNWmG+AUHwPy4AHOD6gpw==} + '@spruceid/siwe-parser@3.0.0': + resolution: {integrity: sha512-Y92k63ilw/8jH9Ry4G2e7lQd0jZAvb0d/Q7ssSD0D9mp/Zt2aCXIc3g0ny9yhplpAx1QXHsMz/JJptHK/zDGdw==} + + '@stablelib/binary@1.0.1': + resolution: {integrity: sha512-ClJWvmL6UBM/wjkvv/7m5VP3GMr9t0osr4yVgLZsLCOz4hGN9gIAFEqnJ0TsSMAN+n840nf2cHZnA5/KFqHC7Q==} + + '@stablelib/int@1.0.1': + resolution: {integrity: sha512-byr69X/sDtDiIjIV6m4roLVWnNNlRGzsvxw+agj8CIEazqWGOQp2dTYgQhtyVXV9wpO6WyXRQUzLV/JRNumT2w==} + + '@stablelib/random@1.0.2': + resolution: {integrity: sha512-rIsE83Xpb7clHPVRlBj8qNe5L8ISQOzjghYQm/dZ7VaM2KHYwMW5adjQjrzTZCchFnNCNhkwtnOBa9HTMJCI8w==} + + '@stablelib/wipe@1.0.1': + resolution: {integrity: sha512-WfqfX/eXGiAd3RJe4VU2snh/ZPwtSjLG4ynQ/vYzvghTh7dHFcI1wl+nrkWG6lGhukOxOsUHfv8dUXr58D0ayg==} + '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} @@ -1646,6 +1697,9 @@ packages: '@types/node@20.19.25': resolution: {integrity: sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==} + '@types/node@22.7.5': + resolution: {integrity: sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==} + '@types/prop-types@15.7.15': resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} @@ -2021,6 +2075,9 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + aes-js@4.0.0-beta.5: + resolution: {integrity: sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==} + agent-base@7.1.4: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} @@ -2059,6 +2116,9 @@ packages: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} + apg-js@4.4.0: + resolution: {integrity: sha512-fefmXFknJmtgtNEXfPwZKYkMFX4Fyeyz+fNF6JWp87biGOPslJbCBVU158zvKRZfHBKnJDy8CMM40oLFGkXT8Q==} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -2159,8 +2219,8 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - baseline-browser-mapping@2.8.28: - resolution: {integrity: sha512-gYjt7OIqdM0PcttNYP2aVrr2G0bMALkBaoehD4BuRGjAOtipg0b6wHg1yNL+s5zSnLZZrGHOw4IrND8CD+3oIQ==} + baseline-browser-mapping@2.9.5: + resolution: {integrity: sha512-D5vIoztZOq1XM54LUdttJVc96ggEsIfju2JBvht06pSzpckp3C7HReun67Bghzrtdsq9XdMGbSSB3v3GhMNmAA==} hasBin: true bidi-js@1.0.3: @@ -2188,8 +2248,8 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} - browserslist@4.28.0: - resolution: {integrity: sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ==} + browserslist@4.28.1: + resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true @@ -2239,8 +2299,8 @@ packages: camelize@1.0.1: resolution: {integrity: sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==} - caniuse-lite@1.0.30001754: - resolution: {integrity: sha512-x6OeBXueoAceOmotzx3PO4Zpt4rzpeIFsSr6AAePTZxSkXiYDUmpypEl7e2+8NCd9bD7bXjqyef8CJYPC1jfxg==} + caniuse-lite@1.0.30001760: + resolution: {integrity: sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw==} chai@5.3.3: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} @@ -2501,8 +2561,8 @@ packages: resolution: {integrity: sha512-dS5cbA9rA2VR4Ybuvhg6jvdmp46ubLn3E+px8cG/35aEDNclrqoCjg6mt0HYZ/M+OoESS3jSkCrqk1kWAEhWAw==} engines: {bun: '>=1', deno: '>=2', node: '>=16'} - electron-to-chromium@1.5.252: - resolution: {integrity: sha512-53uTpjtRgS7gjIxZ4qCgFdNO2q+wJt/Z8+xAvxbCqXPJrY6h7ighUkadQmNMXH96crtpa6gPFNP7BF4UBGDuaA==} + electron-to-chromium@1.5.267: + resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==} emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -2729,6 +2789,10 @@ packages: ethereum-cryptography@2.2.1: resolution: {integrity: sha512-r/W8lkHSiTLxUxW8Rf3u4HGB0xQweG2RyETjywylKZSzLWoWAijRz8WCuOtJ6wah+avllXBqZuk29HCCvhEIRg==} + ethers@6.16.0: + resolution: {integrity: sha512-U1wulmetNymijEhpSEQ7Ct/P/Jw9/e7R1j5XIbPRydgV2DjLVMsULDlNksq3RQnFgKoLlZf88ijYtWEXcPa07A==} + engines: {node: '>=14.0.0'} + eventemitter2@6.4.9: resolution: {integrity: sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==} @@ -3493,8 +3557,24 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - next@15.5.4: - resolution: {integrity: sha512-xH4Yjhb82sFYQfY3vbkJfgSDgXvBB6a8xPs9i35k6oZJRoQRihZH+4s9Yo2qsWpzBmZ3lPXaJ2KPXLfkvW4LnA==} + next-auth@5.0.0-beta.30: + resolution: {integrity: sha512-+c51gquM3F6nMVmoAusRJ7RIoY0K4Ts9HCCwyy/BRoe4mp3msZpOzYMyb5LAYc1wSo74PMQkGDcaghIO7W6Xjg==} + peerDependencies: + '@simplewebauthn/browser': ^9.0.1 + '@simplewebauthn/server': ^9.0.2 + next: ^14.0.0-0 || ^15.0.0 || ^16.0.0 + nodemailer: ^7.0.7 + react: ^18.2.0 || ^19.0.0 + peerDependenciesMeta: + '@simplewebauthn/browser': + optional: true + '@simplewebauthn/server': + optional: true + nodemailer: + optional: true + + next@15.5.7: + resolution: {integrity: sha512-+t2/0jIJ48kUpGKkdlhgkv+zPTEOoXyr60qXe68eB/pl3CMJaLeIGjzp5D6Oqt25hCBiBTt8wEeeAzfJvUKnPQ==} engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} hasBin: true peerDependencies: @@ -3543,6 +3623,9 @@ packages: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} + oauth4webapi@3.8.3: + resolution: {integrity: sha512-pQ5BsX3QRTgnt5HxgHwgunIRaDXBdkT23tf8dfzmtTIL2LTpdmxgbpbBm0VgFWAIDlezQvQCTgnVIUmHupXHxw==} + obj-multiplex@1.0.0: resolution: {integrity: sha512-0GNJAOsHoBHeNTvl5Vt6IWnpUEcc3uSRxzBri7EDyIcMgYvnY2JL2qdeV5zTMjWQX5OHcD5amcW2HFfDh0gjIA==} @@ -3800,11 +3883,19 @@ packages: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} + preact-render-to-string@6.5.11: + resolution: {integrity: sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==} + peerDependencies: + preact: '>=10' + preact@10.24.2: resolution: {integrity: sha512-1cSoF0aCC8uaARATfrlz4VCBqE8LwZwRfLgkxJOQwAlQt6ayTmi0D9OF7nXid1POI5SZidFuG9CnlXbDfLqY/Q==} - preact@10.27.2: - resolution: {integrity: sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg==} + preact@10.24.3: + resolution: {integrity: sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==} + + preact@10.28.0: + resolution: {integrity: sha512-rytDAoiXr3+t6OIP3WGlDd0ouCUG1iCWzkcY3++Nreuoi17y6T5i/zRhe6uYfoVcxq6YU+sBtJouuRDsq8vvqA==} prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} @@ -4060,6 +4151,11 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + siwe@3.0.0: + resolution: {integrity: sha512-P2/ry7dHYJA6JJ5+veS//Gn2XDwNb3JMvuD6xiXX8L/PJ1SNVD4a3a8xqEbmANx+7kNQcD8YAh1B9bNKKvRy/g==} + peerDependencies: + ethers: ^5.6.8 || ^6.0.8 + socket.io-client@4.8.1: resolution: {integrity: sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==} engines: {node: '>=10.0.0'} @@ -4305,6 +4401,9 @@ packages: tslib@1.14.1: resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} + tslib@2.7.0: + resolution: {integrity: sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -4365,6 +4464,9 @@ packages: uncrypto@0.1.3: resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} + undici-types@6.19.8: + resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==} + undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -4436,8 +4538,8 @@ packages: uploadthing: optional: true - update-browserslist-db@1.1.4: - resolution: {integrity: sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==} + update-browserslist-db@1.2.2: + resolution: {integrity: sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA==} hasBin: true peerDependencies: browserslist: '>= 4.21.0' @@ -4792,6 +4894,8 @@ snapshots: '@acemir/cssom@0.9.23': {} + '@adraffy/ens-normalize@1.10.1': {} + '@adraffy/ens-normalize@1.11.1': {} '@alloc/quick-lru@5.2.0': {} @@ -4814,6 +4918,14 @@ snapshots: '@asamuzakjp/nwsapi@2.3.9': {} + '@auth/core@0.41.0': + dependencies: + '@panva/hkdf': 1.2.1 + jose: 6.1.1 + oauth4webapi: 3.8.3 + preact: 10.24.3 + preact-render-to-string: 6.5.11(preact@10.24.3) + '@babel/code-frame@7.27.1': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -4858,7 +4970,7 @@ snapshots: dependencies: '@babel/compat-data': 7.28.5 '@babel/helper-validator-option': 7.27.1 - browserslist: 4.28.0 + browserslist: 4.28.1 lru-cache: 5.1.1 semver: 6.3.1 @@ -4984,7 +5096,7 @@ snapshots: eth-json-rpc-filters: 6.0.1 eventemitter3: 5.0.1 keccak: 3.0.4 - preact: 10.27.2 + preact: 10.28.0 sha.js: 2.4.12 transitivePeerDependencies: - supports-color @@ -5666,40 +5778,44 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true - '@next/env@15.5.4': {} + '@next/env@15.5.7': {} '@next/eslint-plugin-next@15.5.4': dependencies: fast-glob: 3.3.1 - '@next/swc-darwin-arm64@15.5.4': + '@next/swc-darwin-arm64@15.5.7': optional: true - '@next/swc-darwin-x64@15.5.4': + '@next/swc-darwin-x64@15.5.7': optional: true - '@next/swc-linux-arm64-gnu@15.5.4': + '@next/swc-linux-arm64-gnu@15.5.7': optional: true - '@next/swc-linux-arm64-musl@15.5.4': + '@next/swc-linux-arm64-musl@15.5.7': optional: true - '@next/swc-linux-x64-gnu@15.5.4': + '@next/swc-linux-x64-gnu@15.5.7': optional: true - '@next/swc-linux-x64-musl@15.5.4': + '@next/swc-linux-x64-musl@15.5.7': optional: true - '@next/swc-win32-arm64-msvc@15.5.4': + '@next/swc-win32-arm64-msvc@15.5.7': optional: true - '@next/swc-win32-x64-msvc@15.5.4': + '@next/swc-win32-x64-msvc@15.5.7': optional: true '@noble/ciphers@1.2.1': {} '@noble/ciphers@1.3.0': {} + '@noble/curves@1.2.0': + dependencies: + '@noble/hashes': 1.3.2 + '@noble/curves@1.4.2': dependencies: '@noble/hashes': 1.4.0 @@ -5720,6 +5836,8 @@ snapshots: dependencies: '@noble/hashes': 1.8.0 + '@noble/hashes@1.3.2': {} + '@noble/hashes@1.4.0': {} '@noble/hashes@1.7.0': {} @@ -5742,6 +5860,8 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} + '@panva/hkdf@1.2.1': {} + '@paulmillr/qr@0.2.1': {} '@pkgjs/parseargs@0.11.0': @@ -6525,7 +6645,7 @@ snapshots: dependencies: '@babel/runtime': 7.28.4 '@noble/curves': 1.9.7 - '@noble/hashes': 1.4.0 + '@noble/hashes': 1.8.0 '@solana/buffer-layout': 4.0.1 '@solana/codecs-numbers': 2.3.0(typescript@5.9.3) agentkeepalive: 4.6.0 @@ -6544,6 +6664,24 @@ snapshots: - typescript - utf-8-validate + '@spruceid/siwe-parser@3.0.0': + dependencies: + '@noble/hashes': 1.8.0 + apg-js: 4.4.0 + + '@stablelib/binary@1.0.1': + dependencies: + '@stablelib/int': 1.0.1 + + '@stablelib/int@1.0.1': {} + + '@stablelib/random@1.0.2': + dependencies: + '@stablelib/binary': 1.0.1 + '@stablelib/wipe': 1.0.1 + + '@stablelib/wipe@1.0.1': {} + '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 @@ -6680,6 +6818,10 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/node@22.7.5': + dependencies: + undici-types: 6.19.8 + '@types/prop-types@15.7.15': {} '@types/react-dom@18.3.7(@types/react@18.3.26)': @@ -7527,6 +7669,8 @@ snapshots: acorn@8.15.0: {} + aes-js@4.0.0-beta.5: {} + agent-base@7.1.4: {} agentkeepalive@4.6.0: @@ -7559,6 +7703,8 @@ snapshots: normalize-path: 3.0.0 picomatch: 2.3.1 + apg-js@4.4.0: {} + argparse@2.0.1: {} aria-query@5.3.0: @@ -7691,7 +7837,7 @@ snapshots: base64-js@1.5.1: {} - baseline-browser-mapping@2.8.28: {} + baseline-browser-mapping@2.9.5: {} bidi-js@1.0.3: dependencies: @@ -7722,13 +7868,13 @@ snapshots: dependencies: fill-range: 7.1.1 - browserslist@4.28.0: + browserslist@4.28.1: dependencies: - baseline-browser-mapping: 2.8.28 - caniuse-lite: 1.0.30001754 - electron-to-chromium: 1.5.252 + baseline-browser-mapping: 2.9.5 + caniuse-lite: 1.0.30001760 + electron-to-chromium: 1.5.267 node-releases: 2.0.27 - update-browserslist-db: 1.1.4(browserslist@4.28.0) + update-browserslist-db: 1.2.2(browserslist@4.28.1) bs58@4.0.1: dependencies: @@ -7777,7 +7923,7 @@ snapshots: camelize@1.0.1: {} - caniuse-lite@1.0.30001754: {} + caniuse-lite@1.0.30001760: {} chai@5.3.3: dependencies: @@ -8024,7 +8170,7 @@ snapshots: '@noble/curves': 1.9.7 '@noble/hashes': 1.8.0 - electron-to-chromium@1.5.252: {} + electron-to-chromium@1.5.267: {} emoji-regex@8.0.0: {} @@ -8461,6 +8607,19 @@ snapshots: '@scure/bip32': 1.4.0 '@scure/bip39': 1.3.0 + ethers@6.16.0(bufferutil@4.0.9)(utf-8-validate@5.0.10): + dependencies: + '@adraffy/ens-normalize': 1.10.1 + '@noble/curves': 1.2.0 + '@noble/hashes': 1.3.2 + '@types/node': 22.7.5 + aes-js: 4.0.0-beta.5 + tslib: 2.7.0 + ws: 8.17.1(bufferutil@4.0.9)(utf-8-validate@5.0.10) + transitivePeerDependencies: + - bufferutil + - utf-8-validate + eventemitter2@6.4.9: {} eventemitter3@5.0.1: {} @@ -9206,24 +9365,30 @@ snapshots: natural-compare@1.4.0: {} - next@15.5.4(@babel/core@7.28.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + next-auth@5.0.0-beta.30(next@15.5.7(@babel/core@7.28.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1): dependencies: - '@next/env': 15.5.4 + '@auth/core': 0.41.0 + next: 15.5.7(@babel/core@7.28.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + + next@15.5.7(@babel/core@7.28.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@next/env': 15.5.7 '@swc/helpers': 0.5.15 - caniuse-lite: 1.0.30001754 + caniuse-lite: 1.0.30001760 postcss: 8.4.31 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) styled-jsx: 5.1.6(@babel/core@7.28.5)(react@18.3.1) optionalDependencies: - '@next/swc-darwin-arm64': 15.5.4 - '@next/swc-darwin-x64': 15.5.4 - '@next/swc-linux-arm64-gnu': 15.5.4 - '@next/swc-linux-arm64-musl': 15.5.4 - '@next/swc-linux-x64-gnu': 15.5.4 - '@next/swc-linux-x64-musl': 15.5.4 - '@next/swc-win32-arm64-msvc': 15.5.4 - '@next/swc-win32-x64-msvc': 15.5.4 + '@next/swc-darwin-arm64': 15.5.7 + '@next/swc-darwin-x64': 15.5.7 + '@next/swc-linux-arm64-gnu': 15.5.7 + '@next/swc-linux-arm64-musl': 15.5.7 + '@next/swc-linux-x64-gnu': 15.5.7 + '@next/swc-linux-x64-musl': 15.5.7 + '@next/swc-win32-arm64-msvc': 15.5.7 + '@next/swc-win32-x64-msvc': 15.5.7 sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' @@ -9245,6 +9410,8 @@ snapshots: normalize-path@3.0.0: {} + oauth4webapi@3.8.3: {} + obj-multiplex@1.0.0: dependencies: end-of-stream: 1.4.5 @@ -9329,8 +9496,8 @@ snapshots: ox@0.6.7(typescript@5.9.3)(zod@3.25.76): dependencies: '@adraffy/ens-normalize': 1.11.1 - '@noble/curves': 1.8.1 - '@noble/hashes': 1.7.1 + '@noble/curves': 1.9.7 + '@noble/hashes': 1.8.0 '@scure/bip32': 1.6.2 '@scure/bip39': 1.5.4 abitype: 1.0.8(typescript@5.9.3)(zod@3.25.76) @@ -9522,9 +9689,15 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + preact-render-to-string@6.5.11(preact@10.24.3): + dependencies: + preact: 10.24.3 + preact@10.24.2: {} - preact@10.27.2: {} + preact@10.24.3: {} + + preact@10.28.0: {} prelude-ls@1.2.1: {} @@ -9860,6 +10033,12 @@ snapshots: signal-exit@4.1.0: {} + siwe@3.0.0(ethers@6.16.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)): + dependencies: + '@spruceid/siwe-parser': 3.0.0 + '@stablelib/random': 1.0.2 + ethers: 6.16.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) + socket.io-client@4.8.1(bufferutil@4.0.9)(utf-8-validate@5.0.10): dependencies: '@socket.io/component-emitter': 3.1.2 @@ -10125,6 +10304,8 @@ snapshots: tslib@1.14.1: {} + tslib@2.7.0: {} + tslib@2.8.1: {} tsup@8.5.1(jiti@2.6.1)(postcss@8.5.6)(typescript@5.9.3): @@ -10209,6 +10390,8 @@ snapshots: uncrypto@0.1.3: {} + undici-types@6.19.8: {} + undici-types@6.21.0: {} undici-types@7.16.0: {} @@ -10250,9 +10433,9 @@ snapshots: optionalDependencies: idb-keyval: 6.2.2 - update-browserslist-db@1.1.4(browserslist@4.28.0): + update-browserslist-db@1.2.2(browserslist@4.28.1): dependencies: - browserslist: 4.28.0 + browserslist: 4.28.1 escalade: 3.2.0 picocolors: 1.1.1 diff --git a/src/app/Provider.tsx b/src/app/Provider.tsx index fa5566e..20e51cf 100644 --- a/src/app/Provider.tsx +++ b/src/app/Provider.tsx @@ -3,8 +3,7 @@ import { WagmiProvider, createConfig, http } from "wagmi"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { ConnectKitProvider, getDefaultConfig } from "connectkit"; -import { SonarProvider } from "@echoxyz/sonar-react"; -import { sonarConfig } from "./config"; +import { SessionProvider } from "next-auth/react"; import { sepolia } from "wagmi/chains"; const config = createConfig( @@ -20,7 +19,7 @@ const config = createConfig( // Required App Info appName: "Sonar Next.js example app", appDescription: - "Next.js app showing how to integrate with the Sonar API via the sonar-react and sonar-core libraries.", + "Next.js app showing how to integrate with the Sonar API via backend OAuth and NextAuth.", }), ); @@ -28,12 +27,12 @@ const queryClient = new QueryClient(); export const Provider = ({ children }: { children: React.ReactNode }) => { return ( - + {children} - + ); }; diff --git a/src/app/PurchasePanel.tsx b/src/app/PurchasePanel.tsx index 5c30e46..c98a3d1 100644 --- a/src/app/PurchasePanel.tsx +++ b/src/app/PurchasePanel.tsx @@ -1,14 +1,14 @@ "use client"; -import { PrePurchaseFailureReason, GeneratePurchasePermitResponse, EntityID } from "@echoxyz/sonar-core"; +import { EntityID, GeneratePurchasePermitResponse, PrePurchaseFailureReason } from "@echoxyz/sonar-core"; import { useState } from "react"; import { saleUUID } from "./config"; +import { useSaleContract } from "./hooks"; import { useSonarPurchase, UseSonarPurchaseResultNotReadyToPurchase, UseSonarPurchaseResultReadyToPurchase, -} from "@echoxyz/sonar-react"; -import { useSaleContract } from "./hooks"; +} from "./hooks/use-sonar-purchase"; function readinessConfig( sonarPurchaser: UseSonarPurchaseResultReadyToPurchase | UseSonarPurchaseResultNotReadyToPurchase diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..3476f3a --- /dev/null +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,178 @@ +import NextAuth from "next-auth"; +import CredentialsProvider from "next-auth/providers/credentials"; +import { getTokenStore, SonarTokens } from "@/lib/token-store"; +import { siwe } from "@/lib/siwe"; + +// Validate required environment variables +if (process.env.NODE_ENV === "production" && !process.env.NEXTAUTH_SECRET) { + throw new Error("NEXTAUTH_SECRET is required in production"); +} + +/** + * Custom Sonar OAuth provider configuration + * Note: NextAuth v5 uses a different provider structure + * We'll handle OAuth manually via API routes and use NextAuth for session management + */ +const sonarOAuthConfig = { + authorizationUrl: `${process.env.NEXT_PUBLIC_ECHO_FRONTEND_URL ?? "https://app.echo.xyz"}/oauth/authorize`, + tokenUrl: `${process.env.NEXT_PUBLIC_ECHO_API_URL ?? "https://api.echo.xyz"}/oauth/token`, + clientId: process.env.NEXT_PUBLIC_OAUTH_CLIENT_UUID ?? "", + clientSecret: process.env.OAUTH_CLIENT_SECRET ?? "", + redirectUri: process.env.NEXT_PUBLIC_OAUTH_CLIENT_REDIRECT_URI ?? "", +}; + +/** + * Refresh Sonar access token using refresh token + */ +export async function refreshSonarToken(refreshToken: string): Promise { + const tokenEndpoint = sonarOAuthConfig.tokenUrl; + const response = await fetch(tokenEndpoint, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: sonarOAuthConfig.clientId, + client_secret: sonarOAuthConfig.clientSecret, + }), + }); + + if (!response.ok) { + throw new Error("Failed to refresh token"); + } + + const data = await response.json(); + const expiresAt = Math.floor(Date.now() / 1000) + (data.expires_in || 3600); + + return { + accessToken: data.access_token, + refreshToken: data.refresh_token || refreshToken, + expiresAt, + }; +} + +const authOptions = { + providers: [ + // SIWE provider for wallet-based authentication + CredentialsProvider({ + id: "siwe", + name: "Sign in with Ethereum", + credentials: { + message: { label: "Message", type: "text" }, + signature: { label: "Signature", type: "text" }, + }, + async authorize(credentials) { + if (!credentials?.message || !credentials?.signature) { + return null; + } + + try { + const address = await siwe.verify( + credentials.message as string, + credentials.signature as string + ); + if (!address) { + return null; + } + + return { + id: address.toLowerCase(), + name: address, + }; + } catch { + return null; + } + }, + }), + ], + session: { + strategy: "jwt" as const, + }, + callbacks: { + async jwt(params: { + token: any; + account?: { provider: string } | null; + user?: any; + }) { + const { token, account, user } = params; + // Initial sign in + if (account && user) { + if (account.provider === "siwe") { + // SIWE sign in - set the user ID (wallet address) + token.sub = user.id; + } + } + + // Check if Sonar token needs refresh (tokens are stored separately, but we check expiry here) + if (token.sub) { + const storedTokens = getTokenStore().getTokens(token.sub); + if (storedTokens) { + const now = Math.floor(Date.now() / 1000); + // Refresh if token expires in less than 5 minutes + if (storedTokens.expiresAt - now < 300) { + try { + const newTokens = await refreshSonarToken(storedTokens.refreshToken); + getTokenStore().setTokens(token.sub, newTokens); + } catch (error) { + // Refresh failed - clear tokens and force re-auth + getTokenStore().clearTokens(token.sub); + } + } + } + } + + return token; + }, + async session(params: { session: any; token: any }) { + const { session, token } = params; + // Add Sonar connection status to session + if (token.sub) { + const storedTokens = getTokenStore().getTokens(token.sub); + session.user = { + ...session.user, + id: token.sub, + sonarConnected: !!storedTokens, + }; + } + return session; + }, + }, + pages: { + signIn: "/", + }, + cookies: { + sessionToken: { + name: `next-auth.session-token`, + options: { + httpOnly: true, + sameSite: "lax" as const, + path: "/", + secure: process.env.NODE_ENV === "production", + }, + }, + }, + secret: process.env.NEXTAUTH_SECRET, +}; + +// NextAuth v5 beta returns an object with handlers and auth function +const auth = NextAuth(authOptions); + +export const { GET, POST } = auth.handlers; +export const { auth: getAuth } = auth; +export { authOptions }; + +// Helper to get session in API routes +export async function getSession(req?: Request) { + // In NextAuth v5, we can use the handler to get the session + // For now, we'll use a workaround with cookies + if (typeof window === "undefined" && req) { + const cookies = req.headers.get("cookie") || ""; + // Parse session from cookies - this is a simplified approach + // In production, you might want to use NextAuth's built-in session handling + return null; // Will be handled by getServerSession in route handlers + } + return null; +} + diff --git a/src/app/api/auth/sonar/authorize/route.ts b/src/app/api/auth/sonar/authorize/route.ts new file mode 100644 index 0000000..5db4023 --- /dev/null +++ b/src/app/api/auth/sonar/authorize/route.ts @@ -0,0 +1,34 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getAuth } from "../../[...nextauth]/route"; +import { generatePKCEParams } from "@echoxyz/sonar-core"; +import { 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(request: NextRequest) { + const session = await getAuth(); + + if (!session?.user?.id) { + 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.user.id, codeVerifier); + + // Build authorization URL with PKCE + const authorizationUrl = buildAuthorizationUrl({ + clientUUID: process.env.NEXT_PUBLIC_OAUTH_CLIENT_UUID ?? "", + redirectURI: process.env.NEXT_PUBLIC_OAUTH_CLIENT_REDIRECT_URI ?? "", + state, + codeChallenge, + frontendURL: process.env.NEXT_PUBLIC_ECHO_FRONTEND_URL ?? "https://app.echo.xyz", + }); + + return NextResponse.redirect(authorizationUrl.toString()); +} + diff --git a/src/app/api/auth/sonar/callback/route.ts b/src/app/api/auth/sonar/callback/route.ts new file mode 100644 index 0000000..4585955 --- /dev/null +++ b/src/app/api/auth/sonar/callback/route.ts @@ -0,0 +1,90 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getAuth } from "../../[...nextauth]/route"; +import { getTokenStore, SonarTokens } from "@/lib/token-store"; +import { getPKCEVerifier, clearPKCEVerifier } from "@/lib/pkce-store"; +import { createSonarClient } from "@/lib/sonar-client"; + +/** + * 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 getAuth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized", details: "No active session" }, { status: 401 }); + } + + try { + // Retrieve code verifier and user 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.user.id) { + 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.user.id); + const tokenData = await client.exchangeAuthorizationCode({ + code, + codeVerifier, + redirectURI: process.env.NEXT_PUBLIC_OAUTH_CLIENT_REDIRECT_URI ?? "", + }); + + const expiresAt = Math.floor(Date.now() / 1000) + (tokenData.expires_in || 3600); + + const sonarTokens: SonarTokens = { + accessToken: tokenData.access_token, + refreshToken: tokenData.refresh_token, + expiresAt, + }; + + // Store tokens in token store + getTokenStore().setTokens(session.user.id, sonarTokens); + + // Clear the code verifier (no longer needed) + await clearPKCEVerifier(state); + + // Return success - frontend will navigate to home + // The page load will naturally trigger session refresh via NextAuth + // The session callback checks the token store on every request + 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 new file mode 100644 index 0000000..5c0de0c --- /dev/null +++ b/src/app/api/auth/sonar/disconnect/route.ts @@ -0,0 +1,19 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getAuth } from "../../[...nextauth]/route"; +import { getTokenStore } from "@/lib/token-store"; + +/** + * Disconnect Sonar account (remove stored tokens) + */ +export async function POST(request: NextRequest) { + const session = await getAuth(); + + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + getTokenStore().clearTokens(session.user.id); + + return NextResponse.json({ success: true }); +} + diff --git a/src/app/api/sonar/entity/route.ts b/src/app/api/sonar/entity/route.ts new file mode 100644 index 0000000..59e7f80 --- /dev/null +++ b/src/app/api/sonar/entity/route.ts @@ -0,0 +1,61 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getAuth, refreshSonarToken } from "../../auth/[...nextauth]/route"; +import { getTokenStore } from "@/lib/token-store"; +import { createSonarClient } from "@/lib/sonar-client"; +import { APIError } from "@echoxyz/sonar-core"; + +/** + * Proxy request to Sonar ReadEntity endpoint + */ +export async function POST(request: NextRequest) { + const session = await getAuth(); + + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await request.json(); + const { saleUUID, walletAddress } = body; + + if (!saleUUID || !walletAddress) { + return NextResponse.json({ error: "Missing saleUUID or walletAddress" }, { status: 400 }); + } + + try { + // Get tokens from store + let tokens = getTokenStore().getTokens(session.user.id); + if (!tokens) { + return NextResponse.json({ error: "Sonar account not connected" }, { status: 401 }); + } + + // Check if token needs refresh + const now = Math.floor(Date.now() / 1000); + if (tokens.expiresAt - now < 300) { + try { + tokens = await refreshSonarToken(tokens.refreshToken); + getTokenStore().setTokens(session.user.id, tokens); + } catch (error) { + return NextResponse.json({ error: "Failed to refresh token" }, { status: 401 }); + } + } + + // Create Sonar client and make the request + // The client will pick up the token from the store (including if it was just refreshed) + const client = createSonarClient(session.user.id); + const result = await client.readEntity({ saleUUID, walletAddress }); + + return NextResponse.json(result); + } catch (error) { + if (error instanceof APIError && error.status === 404) { + return NextResponse.json({ Entity: null }, { status: 200 }); + } + + if (error instanceof APIError) { + return NextResponse.json({ error: error.message }, { status: error.status }); + } + + console.error("Error calling Sonar API"); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} + diff --git a/src/app/api/sonar/generate-purchase-permit/route.ts b/src/app/api/sonar/generate-purchase-permit/route.ts new file mode 100644 index 0000000..46d5c3c --- /dev/null +++ b/src/app/api/sonar/generate-purchase-permit/route.ts @@ -0,0 +1,56 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getAuth, refreshSonarToken } from "../../auth/[...nextauth]/route"; +import { getTokenStore } from "@/lib/token-store"; +import { createSonarClient } from "@/lib/sonar-client"; +import { APIError } from "@echoxyz/sonar-core"; + +/** + * Proxy request to Sonar GenerateSalePurchasePermit endpoint + */ +export async function POST(request: NextRequest) { + const session = await getAuth(); + + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await request.json(); + const { saleUUID, entityID, walletAddress } = body; + + if (!saleUUID || !entityID || !walletAddress) { + return NextResponse.json({ error: "Missing required parameters" }, { status: 400 }); + } + + try { + // Get tokens from store + let tokens = getTokenStore().getTokens(session.user.id); + if (!tokens) { + return NextResponse.json({ error: "Sonar account not connected" }, { status: 401 }); + } + + // Check if token needs refresh + const now = Math.floor(Date.now() / 1000); + if (tokens.expiresAt - now < 300) { + try { + tokens = await refreshSonarToken(tokens.refreshToken); + getTokenStore().setTokens(session.user.id, tokens); + } catch (error) { + return NextResponse.json({ error: "Failed to refresh token" }, { status: 401 }); + } + } + + // Create Sonar client and make the request + const client = createSonarClient(session.user.id); + const result = await client.generatePurchasePermit({ saleUUID, entityID, walletAddress }); + + return NextResponse.json(result); + } catch (error) { + if (error instanceof APIError) { + return NextResponse.json({ error: error.message }, { status: error.status }); + } + + console.error("Error calling Sonar API"); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} + diff --git a/src/app/api/sonar/pre-purchase-check/route.ts b/src/app/api/sonar/pre-purchase-check/route.ts new file mode 100644 index 0000000..20bdd27 --- /dev/null +++ b/src/app/api/sonar/pre-purchase-check/route.ts @@ -0,0 +1,56 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getAuth, refreshSonarToken } from "../../auth/[...nextauth]/route"; +import { getTokenStore } from "@/lib/token-store"; +import { createSonarClient } from "@/lib/sonar-client"; +import { APIError } from "@echoxyz/sonar-core"; + +/** + * Proxy request to Sonar PrePurchaseCheck endpoint + */ +export async function POST(request: NextRequest) { + const session = await getAuth(); + + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await request.json(); + const { saleUUID, entityID, walletAddress } = body; + + if (!saleUUID || !entityID || !walletAddress) { + return NextResponse.json({ error: "Missing required parameters" }, { status: 400 }); + } + + try { + // Get tokens from store + let tokens = getTokenStore().getTokens(session.user.id); + if (!tokens) { + return NextResponse.json({ error: "Sonar account not connected" }, { status: 401 }); + } + + // Check if token needs refresh + const now = Math.floor(Date.now() / 1000); + if (tokens.expiresAt - now < 300) { + try { + tokens = await refreshSonarToken(tokens.refreshToken); + getTokenStore().setTokens(session.user.id, tokens); + } catch (error) { + return NextResponse.json({ error: "Failed to refresh token" }, { status: 401 }); + } + } + + // Create Sonar client and make the request + const client = createSonarClient(session.user.id); + const result = await client.prePurchaseCheck({ saleUUID, entityID, walletAddress }); + + return NextResponse.json(result); + } catch (error) { + if (error instanceof APIError) { + return NextResponse.json({ error: error.message }, { status: error.status }); + } + + console.error("Error calling Sonar API"); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} + diff --git a/src/app/hooks/use-siwe.ts b/src/app/hooks/use-siwe.ts new file mode 100644 index 0000000..88214aa --- /dev/null +++ b/src/app/hooks/use-siwe.ts @@ -0,0 +1,65 @@ +"use client"; + +import { useSignMessage } from "wagmi"; +import { signIn } from "next-auth/react"; +import { useAccount } from "wagmi"; + +/** + * Hook for Sign In With Ethereum + */ +export function useSIWE() { + const { address } = useAccount(); + const { signMessageAsync } = useSignMessage(); + + const signInWithEthereum = async () => { + if (!address) { + throw new Error("Wallet not connected"); + } + + // Create SIWE message + const domain = typeof window !== "undefined" ? window.location.host : "localhost"; + const origin = typeof window !== "undefined" ? window.location.origin : "http://localhost:3000"; + const statement = "Sign in with Ethereum to the app."; + // Generate cryptographically secure nonce + const nonce = crypto.randomUUID(); + const expirationTime = new Date(Date.now() + 1000 * 60 * 60 * 24).toISOString(); // 24 hours + + const message = `${domain} wants you to sign in with your Ethereum account: +${address} + +${statement} + +URI: ${origin} +Version: 1 +Chain ID: 11155111 +Nonce: ${nonce} +Issued At: ${new Date().toISOString()} +Expiration Time: ${expirationTime}`; + + try { + // Sign the message + const signature = await signMessageAsync({ message }); + + // Send to NextAuth + const result = await signIn("siwe", { + message, + signature, + redirect: false, + }); + + if (result?.error) { + throw new Error(result.error); + } + + return result; + } catch (error) { + console.error("SIWE error:", error); + throw error; + } + }; + + return { + signInWithEthereum, + }; +} + diff --git a/src/app/hooks/use-sonar-entity.ts b/src/app/hooks/use-sonar-entity.ts new file mode 100644 index 0000000..2e968b1 --- /dev/null +++ b/src/app/hooks/use-sonar-entity.ts @@ -0,0 +1,71 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useSession } from "next-auth/react"; +import { EntityDetails } from "@echoxyz/sonar-core"; +import { saleUUID } from "../config"; + +/** + * Hook to fetch Sonar entity details + * Replaces useSonarEntity from sonar-react + */ +export function useSonarEntity(walletAddress?: string) { + const { data: session } = useSession(); + const [loading, setLoading] = useState(false); + const [entity, setEntity] = useState(undefined); + const [error, setError] = useState(undefined); + + useEffect(() => { + if (!session?.user?.id || !walletAddress) { + setEntity(undefined); + setError(undefined); + setLoading(false); + return; + } + + const fetchEntity = async () => { + setLoading(true); + setError(undefined); + + try { + const response = await fetch("/api/sonar/entity", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + saleUUID, + walletAddress, + }), + }); + + if (!response.ok) { + if (response.status === 404) { + setEntity(undefined); + setLoading(false); + return; + } + const errorData = await response.json(); + throw new Error(errorData.error || "Failed to fetch entity"); + } + + const data = await response.json(); + setEntity(data.Entity); + } catch (err) { + setError(err instanceof Error ? err : new Error(String(err))); + setEntity(undefined); + } finally { + setLoading(false); + } + }; + + fetchEntity(); + }, [session?.user?.id, walletAddress]); + + return { + loading, + entity, + error, + }; +} + diff --git a/src/app/hooks/use-sonar-purchase.ts b/src/app/hooks/use-sonar-purchase.ts new file mode 100644 index 0000000..4cfb618 --- /dev/null +++ b/src/app/hooks/use-sonar-purchase.ts @@ -0,0 +1,149 @@ +"use client"; + +import { + EntityID, + GeneratePurchasePermitResponse, + PrePurchaseFailureReason, +} from "@echoxyz/sonar-core"; +import { useSession } from "next-auth/react"; +import { useCallback, useEffect, useState } from "react"; + +export type UseSonarPurchaseResultLoading = { + loading: true; + readyToPurchase: false; +}; + +export type UseSonarPurchaseResultReadyToPurchase = { + loading: false; + readyToPurchase: true; + generatePurchasePermit: () => Promise; + livenessCheckURL?: string; +}; + +export type UseSonarPurchaseResultNotReadyToPurchase = { + loading: false; + readyToPurchase: false; + failureReason: PrePurchaseFailureReason; + livenessCheckURL?: string; +}; + +export type UseSonarPurchaseResultError = { + loading: false; + readyToPurchase: false; + error: Error; +}; + +export type UseSonarPurchaseResult = + | UseSonarPurchaseResultLoading + | UseSonarPurchaseResultReadyToPurchase + | UseSonarPurchaseResultNotReadyToPurchase + | UseSonarPurchaseResultError; + +/** + * Hook for Sonar purchase flow + * Replaces useSonarPurchase from sonar-react + */ +export function useSonarPurchase(args: { + saleUUID: string; + entityID: EntityID; + walletAddress: string; +}): UseSonarPurchaseResult { + const { data: session } = useSession(); + const [state, setState] = useState({ + loading: true, + readyToPurchase: false, + }); + + const generatePurchasePermit = useCallback(async (): Promise => { + if (!session?.user?.id) { + 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, + }), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || "Failed to generate purchase permit"); + } + + const data = await response.json(); + return data; + }, [session?.user?.id, args.saleUUID, args.entityID, args.walletAddress]); + + useEffect(() => { + if (!session?.user?.id) { + setState({ + loading: false, + readyToPurchase: false, + error: new Error("Not authenticated"), + }); + return; + } + + const fetchPurchaseData = async () => { + setState({ + loading: true, + readyToPurchase: false, + }); + + try { + const response = await fetch("/api/sonar/pre-purchase-check", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + saleUUID: args.saleUUID, + entityID: args.entityID, + walletAddress: args.walletAddress, + }), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || "Failed to check purchase eligibility"); + } + + const data = await response.json(); + + if (data.ReadyToPurchase) { + setState({ + loading: false, + readyToPurchase: true, + generatePurchasePermit, + livenessCheckURL: data.LivenessCheckURL, + }); + return; + } + + setState({ + loading: false, + readyToPurchase: false, + failureReason: data.FailureReason as PrePurchaseFailureReason, + livenessCheckURL: data.LivenessCheckURL, + }); + } catch (err) { + setState({ + loading: false, + readyToPurchase: false, + error: err instanceof Error ? err : new Error(String(err)), + }); + } + }; + + fetchPurchaseData(); + }, [session?.user?.id, args.saleUUID, args.entityID, args.walletAddress, generatePurchasePermit]); + + return state; +} + diff --git a/src/app/oauth/callback/page.tsx b/src/app/oauth/callback/page.tsx index 16dabae..cbf525d 100644 --- a/src/app/oauth/callback/page.tsx +++ b/src/app/oauth/callback/page.tsx @@ -1,93 +1,79 @@ "use client"; -import { useEffect, useRef, useState } from "react"; -import { useSonarAuth } from "@echoxyz/sonar-react"; +import { useEffect, useState } from "react"; +import { useSearchParams, useRouter } from "next/navigation"; +import { useSession } from "next-auth/react"; +/** + * OAuth callback page - redirects to backend callback handler + * This page is kept for backward compatibility with existing OAuth redirect URIs + */ export default function OAuthCallback() { - const { authenticated, completeOAuth, ready } = useSonarAuth(); - const oauthCompletionTriggered = useRef(false); - const [oauthError, setOAuthError] = useState(null); - const [timedOut, setTimedOut] = useState(false); + const searchParams = useSearchParams(); + const router = useRouter(); + const { update } = useSession(); + const [error, setError] = useState(null); - // complete the oauth flow and exchange the code for an access token useEffect(() => { - const processOAuthCallback = async () => { - const params = new URLSearchParams(window.location.search); - const code = params.get("code"); - const state = params.get("state"); + const handleCallback = async () => { + // Extract query parameters + const code = searchParams.get("code"); + const state = searchParams.get("state"); + const oauthError = searchParams.get("error"); - // the user is already authenticated, nothing to do - if (!ready || authenticated || !code || !state) { + if (oauthError) { + setError(`OAuth error: ${oauthError}`); + setTimeout(() => router.push("/"), 3000); return; } - // ensuring the oauth completion isn't called multiple times since subsequent ones are expected to fail - if (oauthCompletionTriggered.current) { + if (!code || !state) { + setError("Missing authorization code or state"); + setTimeout(() => router.push("/"), 3000); return; } - oauthCompletionTriggered.current = true; try { - await completeOAuth({ code, state }); + // Call backend callback handler + const response = await fetch(`/api/auth/sonar/callback?code=${code}&state=${state}`); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({ error: "Unknown error" })); + setError(errorData.error || "Failed to complete OAuth flow"); + setTimeout(() => router.push("/"), 3000); + return; + } + + // Success - refresh NextAuth session to pick up new tokens + // The update() call triggers a new session fetch, which runs the session callback + // The session callback checks the token store and updates sonarConnected + await update(); // Force NextAuth to refresh the session (triggers session callback server-side) + + // Navigate to home - the session should now be updated + router.push("/"); } catch (err) { - setOAuthError(err instanceof Error ? err.message : null); + setError("Failed to process OAuth callback"); + setTimeout(() => router.push("/"), 3000); } }; - processOAuthCallback(); - }, [authenticated, completeOAuth, ready]); - - // fetch the user's available entities after they've been authenticated - useEffect(() => { - if (!ready || !authenticated) { - return; - } - window.location.href = "/"; - }, [authenticated, ready]); - - // set a timeout, so we don't keep the user waiting indefinitely in case of an unexpected issue - useEffect(() => { - setTimedOut(false); - const timeoutId = setTimeout(() => setTimedOut(true), 20000); - return () => clearTimeout(timeoutId); - }, [setTimedOut]); - - if (timedOut) { - return ( -
-
-

Timed out

- -
-
- ); - } - - if (oauthError) { - return ( -
-
-

{oauthError}

- -
-
- ); - } + handleCallback(); + }, [searchParams, router, update]); return (
-

Connecting to Echo

+ {error ? ( + <> +

Error

+

{error}

+

Redirecting to home...

+ + ) : ( + <> +

Connecting to Sonar...

+ + )}
); diff --git a/src/app/page.tsx b/src/app/page.tsx index 1aa473d..ffe556a 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,60 +1,124 @@ "use client"; +import { EntityDetails, SaleEligibility } from "@echoxyz/sonar-core"; import { ConnectKitButton } from "connectkit"; -import { useSonarAuth, useSonarEntity } from "@echoxyz/sonar-react"; -import { saleUUID, sonarHomeURL } from "./config"; -import SonarEntity from "./SonarEntity"; +import { signOut, useSession } from "next-auth/react"; +import { useState } from "react"; import { useAccount } from "wagmi"; +import { sonarHomeURL } from "./config"; +import { useSIWE } from "./hooks/use-siwe"; +import { useSonarEntity } from "./hooks/use-sonar-entity"; import PurchasePanel from "./PurchasePanel"; -import { EntityDetails, SaleEligibility } from "@echoxyz/sonar-core"; +import SonarEntity from "./SonarEntity"; const SonarAuthButton = ({ - authenticated, - login, - logout, + sonarConnected, + onConnect, + onDisconnect, }: { - authenticated: boolean; - login: () => void; - logout: () => void; + sonarConnected: boolean; + onConnect: () => void; + onDisconnect: () => void; }) => { return ( ); }; export default function Home() { + const { data: session, update } = useSession(); const { address } = useAccount(); - const { login, authenticated, logout } = useSonarAuth(); - const { loading, entity, error } = useSonarEntity({ - saleUUID: saleUUID, - walletAddress: address, - }); + const { signInWithEthereum } = useSIWE(); + const { loading, entity, error } = useSonarEntity(address); + const [isConnectingSonar, setIsConnectingSonar] = useState(false); + + const handleConnectSonar = async () => { + setIsConnectingSonar(true); + try { + window.location.href = "/api/auth/sonar/authorize"; + } catch (error) { + console.error("Failed to connect Sonar:", error); + setIsConnectingSonar(false); + } + }; + + const handleDisconnectSonar = async () => { + try { + await fetch("/api/auth/sonar/disconnect", { method: "POST" }); + // Reload page to refresh session - session callback will check token store + window.location.reload(); + } catch (error) { + console.error("Failed to disconnect Sonar:", error); + } + }; + + const handleSignIn = async () => { + try { + await signInWithEthereum(); + } catch (error) { + console.error("Failed to sign in:", error); + } + }; + + const sonarConnected = session?.user?.sonarConnected ?? false; + const walletConnected = !!address; + const appAuthenticated = !!session; return (
+
- + {!appAuthenticated && walletConnected && ( + + )} + {appAuthenticated && ( + <> + + + + )} +
+ {!walletConnected &&

Connect your wallet to continue

} + {walletConnected && !appAuthenticated && ( +

Sign in with Ethereum to continue

+ )}
+ {appAuthenticated && ( - {entity && address && entity.SaleEligibility === SaleEligibility.ELIGIBLE && ( + )} + {entity && address && entity.SaleEligibility === SaleEligibility.ELIGIBLE && appAuthenticated && ( )}
@@ -65,17 +129,21 @@ const SonarEntityPanel = ({ loading, entity, error, - authenticated, + sonarConnected, walletAddress, }: { loading: boolean; entity?: EntityDetails; error?: Error; - authenticated: boolean; + sonarConnected: boolean; walletAddress?: string; }) => { - if (!walletAddress || !authenticated) { - return

Connect your wallet and Sonar account to continue

; + if (!walletAddress) { + return

Connect your wallet to continue

; + } + + if (!sonarConnected) { + return

Connect your Sonar account to continue

; } if (loading) { diff --git a/src/lib/pkce-store.ts b/src/lib/pkce-store.ts new file mode 100644 index 0000000..94d7144 --- /dev/null +++ b/src/lib/pkce-store.ts @@ -0,0 +1,79 @@ +/** + * Temporary store for PKCE code verifiers and OAuth state tokens + * Uses encrypted cookies to persist across serverless function invocations + */ +import { cookies } from "next/headers"; + +const PKCE_COOKIE_PREFIX = "sonar_pkce_"; +const PKCE_COOKIE_TTL_SEC = 10 * 60; // 10 minutes + +interface StateEntry { + userId: string; + codeVerifier: string; + expiresAt: number; +} + +/** + * Store PKCE verifier and user ID linked to a state token in an encrypted cookie + */ +export async function setPKCEVerifier(state: string, userId: string, codeVerifier: string): Promise { + const cookieStore = await cookies(); + const expiresAt = Date.now() + PKCE_COOKIE_TTL_SEC * 1000; + + const entry: StateEntry = { + userId, + codeVerifier, + expiresAt, + }; + + // Store in cookie (Next.js cookies are automatically encrypted in production) + cookieStore.set(`${PKCE_COOKIE_PREFIX}${state}`, JSON.stringify(entry), { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + maxAge: PKCE_COOKIE_TTL_SEC, + path: "/", + }); +} + +/** + * Get PKCE verifier and user ID for a state token from cookie + */ +export async function getPKCEVerifier(state: string): Promise<{ userId: string; codeVerifier: string } | null> { + const cookieStore = await cookies(); + const cookieName = `${PKCE_COOKIE_PREFIX}${state}`; + const cookie = cookieStore.get(cookieName); + + if (!cookie?.value) { + return null; + } + + try { + const entry: StateEntry = JSON.parse(cookie.value); + + // Check if expired + if (entry.expiresAt < Date.now()) { + cookieStore.delete(cookieName); + return null; + } + + return { + userId: entry.userId, + codeVerifier: entry.codeVerifier, + }; + } catch (error) { + console.error("PKCEStore.getPKCEVerifier - parse error", { state, error }); + cookieStore.delete(cookieName); + return null; + } +} + +/** + * Clear state entry (called after successful token exchange) + */ +export async function clearPKCEVerifier(state: string): Promise { + const cookieStore = await cookies(); + const cookieName = `${PKCE_COOKIE_PREFIX}${state}`; + cookieStore.delete(cookieName); +} + diff --git a/src/lib/siwe.ts b/src/lib/siwe.ts new file mode 100644 index 0000000..56c19aa --- /dev/null +++ b/src/lib/siwe.ts @@ -0,0 +1,75 @@ +/** + * SIWE (Sign In With Ethereum) verification utilities + * Implements proper signature verification using the siwe library + */ + +import { SiweMessage } from "siwe"; + +/** + * Verify SIWE message signature + * @param message - The SIWE message string + * @param signature - The signature string (hex format with 0x prefix) + * @returns The verified Ethereum address if valid, null otherwise + */ +export const siwe = { + async verify(message: string, signature: string): Promise { + try { + // Parse the SIWE message + const siweMessage = new SiweMessage(message); + + // Verify domain matches expected domain (if configured) + const expectedDomain = process.env.NEXT_PUBLIC_SIWE_DOMAIN; + if (expectedDomain) { + // Allow both with and without port for localhost + const normalizedExpected = expectedDomain.split(":")[0]; + const normalizedParsed = siweMessage.domain.split(":")[0]; + if (normalizedExpected !== normalizedParsed) { + return null; + } + } + + // Validate expiration time if present + if (siweMessage.expirationTime) { + const expirationDate = new Date(siweMessage.expirationTime); + if (expirationDate.getTime() < Date.now()) { + return null; // Message has expired + } + } + + // Validate chain ID if present (optional - can be configured via env) + const expectedChainId = process.env.NEXT_PUBLIC_SIWE_CHAIN_ID; + if (expectedChainId && siweMessage.chainId) { + const expectedChainIdNum = parseInt(expectedChainId, 10); + if (siweMessage.chainId !== expectedChainIdNum) { + return null; // Chain ID mismatch + } + } + + // Verify the signature using the siwe library + // This performs cryptographic signature verification and validates message fields + const verifyOptions: Parameters[0] = { + signature: signature as `0x${string}`, + }; + + // Add domain validation if configured + if (expectedDomain) { + verifyOptions.domain = expectedDomain; + } + + const result = await siweMessage.verify(verifyOptions); + + // Check if verification was successful + if (!result.success) { + return null; + } + + // Return the verified address from the message + return siweMessage.address.toLowerCase(); + } catch (error) { + // Any error during validation or verification means the signature is invalid + // Don't log the error to avoid information leakage + return null; + } + }, +}; + diff --git a/src/lib/sonar-client.ts b/src/lib/sonar-client.ts new file mode 100644 index 0000000..5390f20 --- /dev/null +++ b/src/lib/sonar-client.ts @@ -0,0 +1,30 @@ +import { SonarClient } from "@echoxyz/sonar-core"; +import { getTokenStore } from "./token-store"; + +/** + * Create a SonarClient instance for a specific user + * Sets the access token from our server-side token store + */ +export function createSonarClient(userId: string): SonarClient { + const apiURL = process.env.NEXT_PUBLIC_ECHO_API_URL ?? "https://api.echo.xyz"; + + // Create a new client instance + const client = new SonarClient({ + apiURL, + opts: { + onUnauthorized: () => { + // Clear tokens on unauthorized + getTokenStore().clearTokens(userId); + }, + }, + }); + + // Set the token from our server-side store + const tokens = getTokenStore().getTokens(userId); + if (tokens?.accessToken) { + client.setToken(tokens.accessToken); + } + + return client; +} + diff --git a/src/lib/token-store.ts b/src/lib/token-store.ts new file mode 100644 index 0000000..2ad0437 --- /dev/null +++ b/src/lib/token-store.ts @@ -0,0 +1,70 @@ +/** + * Token store interface for managing Sonar OAuth tokens. + * This interface allows swapping between in-memory and persistent storage implementations. + */ +export interface SonarTokens { + accessToken: string; + refreshToken: string; + expiresAt: number; // Unix timestamp in seconds +} + +export interface TokenStore { + /** + * Store tokens for a user identifier + */ + setTokens(userId: string, tokens: SonarTokens): void; + + /** + * Retrieve tokens for a user identifier + */ + getTokens(userId: string): SonarTokens | null; + + /** + * Remove tokens for a user identifier + */ + clearTokens(userId: string): void; +} + +/** + * In-memory token store implementation. + * Tokens are stored in a Map and will be lost on server restart. + * This can be easily swapped for a database-backed implementation. + */ +class InMemoryTokenStore implements TokenStore { + private tokens: Map = new Map(); + + setTokens(userId: string, tokens: SonarTokens): void { + this.tokens.set(userId, tokens); + } + + getTokens(userId: string): SonarTokens | null { + return this.tokens.get(userId) || null; + } + + clearTokens(userId: string): void { + this.tokens.delete(userId); + } +} + +// Singleton instance - can be swapped for a different implementation +let tokenStoreInstance: TokenStore | null = null; + +/** + * Get the token store instance. + * This factory function allows swapping implementations without changing call sites. + */ +export function getTokenStore(): TokenStore { + if (!tokenStoreInstance) { + tokenStoreInstance = new InMemoryTokenStore(); + } + return tokenStoreInstance; +} + +/** + * Set a custom token store implementation. + * Useful for swapping to a database-backed store. + */ +export function setTokenStore(store: TokenStore): void { + tokenStoreInstance = store; +} + diff --git a/src/types/next-auth.d.ts b/src/types/next-auth.d.ts new file mode 100644 index 0000000..2bf0740 --- /dev/null +++ b/src/types/next-auth.d.ts @@ -0,0 +1,28 @@ +import "next-auth"; +import "next-auth/jwt"; + +declare module "next-auth" { + interface Session { + user: { + id: string; + name?: string | null; + email?: string | null; + image?: string | null; + sonarConnected?: boolean; + }; + } + + interface User { + id: string; + name?: string | null; + } +} + +declare module "next-auth/jwt" { + interface JWT { + sub?: string; + sonarAccessToken?: string; + sonarTokenExpiresAt?: number; + } +} + From 7e784e6a25cdf1fa6521862e6998c23e0e3d7b42 Mon Sep 17 00:00:00 2001 From: Will Sewell Date: Tue, 6 Jan 2026 17:06:35 +0000 Subject: [PATCH 02/20] Remove unused dep --- package.json | 1 - pnpm-lock.yaml | 30 ++++++++++++++++++------------ 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/package.json b/package.json index 76c8abf..2c67392 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,6 @@ "@echoxyz/sonar-react": "^0.12.0", "@tanstack/react-query": "^5.90.12", "connectkit": "^1.9.1", - "ethers": "^6.16.0", "next": "^15.5.7", "next-auth": "5.0.0-beta.30", "react": "^18.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index edd1309..b1267c9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,9 +23,6 @@ importers: connectkit: specifier: ^1.9.1 version: 1.9.1(@babel/core@7.28.5)(@tanstack/react-query@5.90.16(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react-is@17.0.2)(react@18.3.1)(viem@2.39.0(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76))(wagmi@2.19.5(@tanstack/query-core@5.90.16)(@tanstack/react-query@5.90.16(react@18.3.1))(@types/react@18.3.27)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react@18.3.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.39.0(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76))(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76)) - ethers: - specifier: ^6.16.0 - version: 6.16.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) next: specifier: ^15.5.7 version: 15.5.7(@babel/core@7.28.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -39,8 +36,8 @@ importers: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) siwe: - specifier: ^3.0.0 - version: 3.0.0(ethers@6.16.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + specifier: ^2.3.2 + version: 2.3.2(ethers@6.16.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) wagmi: specifier: ^2.19.5 version: 2.19.5(@tanstack/query-core@5.90.16)(@tanstack/react-query@5.90.16(react@18.3.1))(@types/react@18.3.27)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react@18.3.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.39.0(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76))(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76) @@ -1528,8 +1525,8 @@ packages: '@solana/web3.js@1.98.4': resolution: {integrity: sha512-vv9lfnvjUsRiq//+j5pBdXig0IQdtzA0BRZ3bXEP4KaIyF1CcaydWqgyzQgfZMNIsWNWmG+AUHwPy4AHOD6gpw==} - '@spruceid/siwe-parser@3.0.0': - resolution: {integrity: sha512-Y92k63ilw/8jH9Ry4G2e7lQd0jZAvb0d/Q7ssSD0D9mp/Zt2aCXIc3g0ny9yhplpAx1QXHsMz/JJptHK/zDGdw==} + '@spruceid/siwe-parser@2.1.2': + resolution: {integrity: sha512-d/r3S1LwJyMaRAKQ0awmo9whfXeE88Qt00vRj91q5uv5ATtWIQEGJ67Yr5eSZw5zp1/fZCXZYuEckt8lSkereQ==} '@stablelib/binary@1.0.1': resolution: {integrity: sha512-ClJWvmL6UBM/wjkvv/7m5VP3GMr9t0osr4yVgLZsLCOz4hGN9gIAFEqnJ0TsSMAN+n840nf2cHZnA5/KFqHC7Q==} @@ -4157,8 +4154,8 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} - siwe@3.0.0: - resolution: {integrity: sha512-P2/ry7dHYJA6JJ5+veS//Gn2XDwNb3JMvuD6xiXX8L/PJ1SNVD4a3a8xqEbmANx+7kNQcD8YAh1B9bNKKvRy/g==} + siwe@2.3.2: + resolution: {integrity: sha512-aSf+6+Latyttbj5nMu6GF3doMfv2UYj83hhwZgUF20ky6fTS83uVhkQABdIVnEuS8y1bBdk7p6ltb9SmlhTTlA==} peerDependencies: ethers: ^5.6.8 || ^6.0.8 @@ -4581,6 +4578,9 @@ packages: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} hasBin: true + valid-url@1.0.9: + resolution: {integrity: sha512-QQDsV8OnSf5Uc30CKSwG9lnhMPe6exHtTXLRYX8uMwKENy640pU+2BgBL0LRbDh/eYRahNCS7aewCx0wf3NYVA==} + valtio@1.13.2: resolution: {integrity: sha512-Qik0o+DSy741TmkqmRfjq+0xpZBXi/Y6+fXZLn0xNF1z/waFMbE3rkivv5Zcf9RrMUp6zswf2J7sbh2KBlba5A==} engines: {node: '>=12.20.0'} @@ -6670,10 +6670,12 @@ snapshots: - typescript - utf-8-validate - '@spruceid/siwe-parser@3.0.0': + '@spruceid/siwe-parser@2.1.2': dependencies: '@noble/hashes': 1.8.0 apg-js: 4.4.0 + uri-js: 4.4.1 + valid-url: 1.0.9 '@stablelib/binary@1.0.1': dependencies: @@ -10050,11 +10052,13 @@ snapshots: signal-exit@4.1.0: {} - siwe@3.0.0(ethers@6.16.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)): + siwe@2.3.2(ethers@6.16.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)): dependencies: - '@spruceid/siwe-parser': 3.0.0 + '@spruceid/siwe-parser': 2.1.2 '@stablelib/random': 1.0.2 ethers: 6.16.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) + uri-js: 4.4.1 + valid-url: 1.0.9 socket.io-client@4.8.1(bufferutil@4.0.9)(utf-8-validate@5.0.10): dependencies: @@ -10486,6 +10490,8 @@ snapshots: uuid@9.0.1: {} + valid-url@1.0.9: {} + valtio@1.13.2(@types/react@18.3.27)(react@18.3.1): dependencies: derive-valtio: 0.1.0(valtio@1.13.2(@types/react@18.3.27)(react@18.3.1)) From 18b1badf09c528819b48effb477627d40f21986f Mon Sep 17 00:00:00 2001 From: Will Sewell Date: Tue, 6 Jan 2026 17:07:03 +0000 Subject: [PATCH 03/20] Remove incorrect comment --- src/lib/pkce-store.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/lib/pkce-store.ts b/src/lib/pkce-store.ts index 94d7144..7988c16 100644 --- a/src/lib/pkce-store.ts +++ b/src/lib/pkce-store.ts @@ -14,7 +14,7 @@ interface StateEntry { } /** - * Store PKCE verifier and user ID linked to a state token in an encrypted cookie + * Store PKCE verifier and user ID linked to a state token in a cookie */ export async function setPKCEVerifier(state: string, userId: string, codeVerifier: string): Promise { const cookieStore = await cookies(); @@ -25,8 +25,7 @@ export async function setPKCEVerifier(state: string, userId: string, codeVerifie codeVerifier, expiresAt, }; - - // Store in cookie (Next.js cookies are automatically encrypted in production) + cookieStore.set(`${PKCE_COOKIE_PREFIX}${state}`, JSON.stringify(entry), { httpOnly: true, secure: process.env.NODE_ENV === "production", From 021c67fa78a448a738f1196c69d18c5a5be6d712 Mon Sep 17 00:00:00 2001 From: Will Sewell Date: Tue, 6 Jan 2026 17:11:33 +0000 Subject: [PATCH 04/20] Run prettier --- src/app/Provider.tsx | 3 +- src/app/api/auth/[...nextauth]/route.ts | 229 +++++++++--------- src/app/api/auth/sonar/authorize/route.ts | 35 ++- src/app/api/auth/sonar/callback/route.ts | 129 +++++----- src/app/api/auth/sonar/disconnect/route.ts | 13 +- src/app/api/sonar/entities/route.ts | 77 +++--- src/app/api/sonar/entity/route.ts | 85 ++++--- .../sonar/generate-purchase-permit/route.ts | 77 +++--- src/app/api/sonar/pre-purchase-check/route.ts | 77 +++--- .../components/auth/AuthenticationSection.tsx | 10 +- src/app/components/sale/PurchaseCard.tsx | 21 +- src/app/hooks/use-siwe.ts | 70 +++--- src/app/hooks/use-sonar-entities.ts | 91 ++++--- src/app/hooks/use-sonar-entity.ts | 103 ++++---- src/app/hooks/use-sonar-purchase.ts | 225 +++++++++-------- src/app/page.tsx | 22 +- src/lib/pkce-store.ts | 93 ++++--- src/lib/siwe.ts | 82 +++---- src/lib/sonar-client.ts | 37 ++- src/lib/token-store.ts | 61 +++-- src/types/next-auth.d.ts | 37 ++- tsconfig.json | 50 ++-- 22 files changed, 801 insertions(+), 826 deletions(-) diff --git a/src/app/Provider.tsx b/src/app/Provider.tsx index e39912e..23fd33b 100644 --- a/src/app/Provider.tsx +++ b/src/app/Provider.tsx @@ -18,8 +18,7 @@ const config = createConfig( // Required App Info appName: "Sonar Next.js example app", - appDescription: - "Next.js app showing how to integrate with the Sonar API via backend OAuth and NextAuth.", + appDescription: "Next.js app showing how to integrate with the Sonar API via backend OAuth and NextAuth.", }) ); diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts index c67c808..9b29e4b 100644 --- a/src/app/api/auth/[...nextauth]/route.ts +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -6,7 +6,7 @@ import { siwe } from "@/lib/siwe"; // Validate required environment variables if (process.env.NODE_ENV === "production" && !process.env.NEXTAUTH_SECRET) { - throw new Error("NEXTAUTH_SECRET is required in production"); + throw new Error("NEXTAUTH_SECRET is required in production"); } /** @@ -15,140 +15,137 @@ if (process.env.NODE_ENV === "production" && !process.env.NEXTAUTH_SECRET) { * We'll handle OAuth manually via API routes and use NextAuth for session management */ const sonarOAuthConfig = { - authorizationUrl: `${process.env.NEXT_PUBLIC_ECHO_FRONTEND_URL ?? "https://app.echo.xyz"}/oauth/authorize`, - tokenUrl: `${process.env.NEXT_PUBLIC_ECHO_API_URL ?? "https://api.echo.xyz"}/oauth/token`, - clientId: process.env.NEXT_PUBLIC_OAUTH_CLIENT_UUID ?? "", - clientSecret: process.env.OAUTH_CLIENT_SECRET ?? "", - redirectUri: process.env.NEXT_PUBLIC_OAUTH_CLIENT_REDIRECT_URI ?? "", + authorizationUrl: `${process.env.NEXT_PUBLIC_ECHO_FRONTEND_URL ?? "https://app.echo.xyz"}/oauth/authorize`, + tokenUrl: `${process.env.NEXT_PUBLIC_ECHO_API_URL ?? "https://api.echo.xyz"}/oauth/token`, + clientId: process.env.NEXT_PUBLIC_OAUTH_CLIENT_UUID ?? "", + clientSecret: process.env.OAUTH_CLIENT_SECRET ?? "", + redirectUri: process.env.NEXT_PUBLIC_OAUTH_CLIENT_REDIRECT_URI ?? "", }; /** * Refresh Sonar access token using refresh token */ export async function refreshSonarToken(refreshToken: string): Promise { - const tokenEndpoint = sonarOAuthConfig.tokenUrl; - const response = await fetch(tokenEndpoint, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body: new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: refreshToken, - client_id: sonarOAuthConfig.clientId, - client_secret: sonarOAuthConfig.clientSecret, - }), - }); + const tokenEndpoint = sonarOAuthConfig.tokenUrl; + const response = await fetch(tokenEndpoint, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: sonarOAuthConfig.clientId, + client_secret: sonarOAuthConfig.clientSecret, + }), + }); - if (!response.ok) { - throw new Error("Failed to refresh token"); - } + if (!response.ok) { + throw new Error("Failed to refresh token"); + } - const data = await response.json(); - const expiresAt = Math.floor(Date.now() / 1000) + (data.expires_in || 3600); + const data = await response.json(); + const expiresAt = Math.floor(Date.now() / 1000) + (data.expires_in || 3600); - return { - accessToken: data.access_token, - refreshToken: data.refresh_token || refreshToken, - expiresAt, - }; + return { + accessToken: data.access_token, + refreshToken: data.refresh_token || refreshToken, + expiresAt, + }; } const authOptions = { - providers: [ - // SIWE provider for wallet-based authentication - CredentialsProvider({ - id: "siwe", - name: "Sign in with Ethereum", - credentials: { - message: { label: "Message", type: "text" }, - signature: { label: "Signature", type: "text" }, - }, - async authorize(credentials) { - if (!credentials?.message || !credentials?.signature) { - return null; - } + providers: [ + // SIWE provider for wallet-based authentication + CredentialsProvider({ + id: "siwe", + name: "Sign in with Ethereum", + credentials: { + message: { label: "Message", type: "text" }, + signature: { label: "Signature", type: "text" }, + }, + async authorize(credentials) { + if (!credentials?.message || !credentials?.signature) { + return null; + } - try { - const address = await siwe.verify( - credentials.message as string, - credentials.signature as string - ); - if (!address) { - return null; - } + try { + const address = await siwe.verify(credentials.message as string, credentials.signature as string); + if (!address) { + return null; + } - return { - id: address.toLowerCase(), - name: address, - }; - } catch { - return null; - } - }, - }), - ], - session: { - strategy: "jwt" as const, - }, - callbacks: { - async jwt({ token, account, user }: { token: JWT; account?: Account | null; user?: User }) { - // Initial sign in - if (account && user) { - if (account.provider === "siwe") { - // SIWE sign in - set the user ID (wallet address) - token.sub = user.id; - } - } + return { + id: address.toLowerCase(), + name: address, + }; + } catch { + return null; + } + }, + }), + ], + session: { + strategy: "jwt" as const, + }, + callbacks: { + async jwt({ token, account, user }: { token: JWT; account?: Account | null; user?: User }) { + // Initial sign in + if (account && user) { + if (account.provider === "siwe") { + // SIWE sign in - set the user ID (wallet address) + token.sub = user.id; + } + } - // Check if Sonar token needs refresh (tokens are stored separately, but we check expiry here) - if (token.sub) { - const storedTokens = getTokenStore().getTokens(token.sub); - if (storedTokens) { - const now = Math.floor(Date.now() / 1000); - // Refresh if token expires in less than 5 minutes - if (storedTokens.expiresAt - now < 300) { - try { - const newTokens = await refreshSonarToken(storedTokens.refreshToken); - getTokenStore().setTokens(token.sub, newTokens); - } catch { - // Refresh failed - clear tokens and force re-auth - getTokenStore().clearTokens(token.sub); - } - } - } + // Check if Sonar token needs refresh (tokens are stored separately, but we check expiry here) + if (token.sub) { + const storedTokens = getTokenStore().getTokens(token.sub); + if (storedTokens) { + const now = Math.floor(Date.now() / 1000); + // Refresh if token expires in less than 5 minutes + if (storedTokens.expiresAt - now < 300) { + try { + const newTokens = await refreshSonarToken(storedTokens.refreshToken); + getTokenStore().setTokens(token.sub, newTokens); + } catch { + // Refresh failed - clear tokens and force re-auth + getTokenStore().clearTokens(token.sub); } + } + } + } - return token; - }, - async session({ session, token }: { session: Session; token: JWT }) { - // Add Sonar connection status to session - if (token.sub) { - const storedTokens = getTokenStore().getTokens(token.sub); - session.user = { - ...session.user, - id: token.sub, - sonarConnected: !!storedTokens, - }; - } - return session; - }, + return token; }, - pages: { - signIn: "/", + async session({ session, token }: { session: Session; token: JWT }) { + // Add Sonar connection status to session + if (token.sub) { + const storedTokens = getTokenStore().getTokens(token.sub); + session.user = { + ...session.user, + id: token.sub, + sonarConnected: !!storedTokens, + }; + } + return session; }, - cookies: { - sessionToken: { - name: `next-auth.session-token`, - options: { - httpOnly: true, - sameSite: "lax" as const, - path: "/", - secure: process.env.NODE_ENV === "production", - }, - }, + }, + pages: { + signIn: "/", + }, + cookies: { + sessionToken: { + name: `next-auth.session-token`, + options: { + httpOnly: true, + sameSite: "lax" as const, + path: "/", + secure: process.env.NODE_ENV === "production", + }, }, - secret: process.env.NEXTAUTH_SECRET, + }, + secret: process.env.NEXTAUTH_SECRET, }; // NextAuth v5 beta returns an object with handlers and auth function @@ -157,5 +154,3 @@ const auth = NextAuth(authOptions); export const { GET, POST } = auth.handlers; export const { auth: getAuth } = auth; export { authOptions }; - - diff --git a/src/app/api/auth/sonar/authorize/route.ts b/src/app/api/auth/sonar/authorize/route.ts index 147591e..a574090 100644 --- a/src/app/api/auth/sonar/authorize/route.ts +++ b/src/app/api/auth/sonar/authorize/route.ts @@ -8,27 +8,26 @@ import { setPKCEVerifier } from "@/lib/pkce-store"; * Generate Sonar OAuth authorization URL with PKCE and redirect user */ export async function GET() { - const session = await getAuth(); + const session = await getAuth(); - if (!session?.user?.id) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } - // Generate PKCE parameters (includes state token from sonar-core) - const { codeVerifier, codeChallenge, state } = await generatePKCEParams(); + // 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.user.id, codeVerifier); + // Store code verifier and user ID linked to state token (will be retrieved in callback) + await setPKCEVerifier(state, session.user.id, codeVerifier); - // Build authorization URL with PKCE - const authorizationUrl = buildAuthorizationUrl({ - clientUUID: process.env.NEXT_PUBLIC_OAUTH_CLIENT_UUID ?? "", - redirectURI: process.env.NEXT_PUBLIC_OAUTH_CLIENT_REDIRECT_URI ?? "", - state, - codeChallenge, - frontendURL: process.env.NEXT_PUBLIC_ECHO_FRONTEND_URL ?? "https://app.echo.xyz", - }); + // Build authorization URL with PKCE + const authorizationUrl = buildAuthorizationUrl({ + clientUUID: process.env.NEXT_PUBLIC_OAUTH_CLIENT_UUID ?? "", + redirectURI: process.env.NEXT_PUBLIC_OAUTH_CLIENT_REDIRECT_URI ?? "", + state, + codeChallenge, + frontendURL: process.env.NEXT_PUBLIC_ECHO_FRONTEND_URL ?? "https://app.echo.xyz", + }); - return NextResponse.redirect(authorizationUrl.toString()); + return NextResponse.redirect(authorizationUrl.toString()); } - diff --git a/src/app/api/auth/sonar/callback/route.ts b/src/app/api/auth/sonar/callback/route.ts index 4585955..bba6a8b 100644 --- a/src/app/api/auth/sonar/callback/route.ts +++ b/src/app/api/auth/sonar/callback/route.ts @@ -9,82 +9,81 @@ import { createSonarClient } from "@/lib/sonar-client"; * 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"); + 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 }); - } + // 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 } - ); - } + // 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 getAuth(); - if (!session?.user?.id) { - return NextResponse.json({ error: "Unauthorized", details: "No active session" }, { status: 401 }); - } + // Verify session exists + const session = await getAuth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized", details: "No active session" }, { status: 401 }); + } - try { - // Retrieve code verifier and user 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 } - ); - } + try { + // Retrieve code verifier and user 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.user.id) { - return NextResponse.json( - { error: "Invalid session", details: "State token does not match current session" }, - { status: 401 } - ); - } + // Verify the state token belongs to the current session + if (stateData.userId !== session.user.id) { + return NextResponse.json( + { error: "Invalid session", details: "State token does not match current session" }, + { status: 401 } + ); + } - const { codeVerifier } = stateData; + const { codeVerifier } = stateData; - // Create a temporary client to exchange the authorization code - const client = createSonarClient(session.user.id); - const tokenData = await client.exchangeAuthorizationCode({ - code, - codeVerifier, - redirectURI: process.env.NEXT_PUBLIC_OAUTH_CLIENT_REDIRECT_URI ?? "", - }); + // Create a temporary client to exchange the authorization code + const client = createSonarClient(session.user.id); + const tokenData = await client.exchangeAuthorizationCode({ + code, + codeVerifier, + redirectURI: process.env.NEXT_PUBLIC_OAUTH_CLIENT_REDIRECT_URI ?? "", + }); - const expiresAt = Math.floor(Date.now() / 1000) + (tokenData.expires_in || 3600); + const expiresAt = Math.floor(Date.now() / 1000) + (tokenData.expires_in || 3600); - const sonarTokens: SonarTokens = { - accessToken: tokenData.access_token, - refreshToken: tokenData.refresh_token, - expiresAt, - }; + const sonarTokens: SonarTokens = { + accessToken: tokenData.access_token, + refreshToken: tokenData.refresh_token, + expiresAt, + }; - // Store tokens in token store - getTokenStore().setTokens(session.user.id, sonarTokens); + // Store tokens in token store + getTokenStore().setTokens(session.user.id, sonarTokens); - // Clear the code verifier (no longer needed) - await clearPKCEVerifier(state); + // Clear the code verifier (no longer needed) + await clearPKCEVerifier(state); - // Return success - frontend will navigate to home - // The page load will naturally trigger session refresh via NextAuth - // The session callback checks the token store on every request - 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 }); + // Return success - frontend will navigate to home + // The page load will naturally trigger session refresh via NextAuth + // The session callback checks the token store on every request + 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 index 0f346fc..9bae04a 100644 --- a/src/app/api/auth/sonar/disconnect/route.ts +++ b/src/app/api/auth/sonar/disconnect/route.ts @@ -6,14 +6,13 @@ import { getTokenStore } from "@/lib/token-store"; * Disconnect Sonar account (remove stored tokens) */ export async function POST() { - const session = await getAuth(); + const session = await getAuth(); - if (!session?.user?.id) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } - getTokenStore().clearTokens(session.user.id); + getTokenStore().clearTokens(session.user.id); - return NextResponse.json({ success: true }); + return NextResponse.json({ success: true }); } - diff --git a/src/app/api/sonar/entities/route.ts b/src/app/api/sonar/entities/route.ts index 1f38112..39095a8 100644 --- a/src/app/api/sonar/entities/route.ts +++ b/src/app/api/sonar/entities/route.ts @@ -9,49 +9,48 @@ import { APIError } from "@echoxyz/sonar-core"; * Returns all entities for the authenticated user */ export async function POST(request: NextRequest) { - const session = await getAuth(); + const session = await getAuth(); - if (!session?.user?.id) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await request.json(); + const { saleUUID } = body; - const body = await request.json(); - const { saleUUID } = body; + if (!saleUUID) { + return NextResponse.json({ error: "Missing saleUUID" }, { status: 400 }); + } - if (!saleUUID) { - return NextResponse.json({ error: "Missing saleUUID" }, { status: 400 }); + try { + // Get tokens from store + let tokens = getTokenStore().getTokens(session.user.id); + if (!tokens) { + return NextResponse.json({ error: "Sonar account not connected" }, { status: 401 }); } - try { - // Get tokens from store - let tokens = getTokenStore().getTokens(session.user.id); - if (!tokens) { - return NextResponse.json({ error: "Sonar account not connected" }, { status: 401 }); - } - - // Check if token needs refresh - const now = Math.floor(Date.now() / 1000); - if (tokens.expiresAt - now < 300) { - try { - tokens = await refreshSonarToken(tokens.refreshToken); - getTokenStore().setTokens(session.user.id, tokens); - } catch { - return NextResponse.json({ error: "Failed to refresh token" }, { status: 401 }); - } - } - - // Create Sonar client and make the request - const client = createSonarClient(session.user.id); - const result = await client.listAvailableEntities({ saleUUID }); - - return NextResponse.json(result); - } catch (error) { - if (error instanceof APIError) { - return NextResponse.json({ error: error.message }, { status: error.status }); - } - - console.error("Error calling Sonar API"); - return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + // Check if token needs refresh + const now = Math.floor(Date.now() / 1000); + if (tokens.expiresAt - now < 300) { + try { + tokens = await refreshSonarToken(tokens.refreshToken); + getTokenStore().setTokens(session.user.id, tokens); + } catch { + return NextResponse.json({ error: "Failed to refresh token" }, { status: 401 }); + } } -} + // Create Sonar client and make the request + const client = createSonarClient(session.user.id); + const result = await client.listAvailableEntities({ saleUUID }); + + return NextResponse.json(result); + } catch (error) { + if (error instanceof APIError) { + return NextResponse.json({ error: error.message }, { status: error.status }); + } + + console.error("Error calling Sonar API"); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/app/api/sonar/entity/route.ts b/src/app/api/sonar/entity/route.ts index de293c0..bc0335e 100644 --- a/src/app/api/sonar/entity/route.ts +++ b/src/app/api/sonar/entity/route.ts @@ -8,54 +8,53 @@ import { APIError } from "@echoxyz/sonar-core"; * Proxy request to Sonar ReadEntity endpoint */ export async function POST(request: NextRequest) { - const session = await getAuth(); + const session = await getAuth(); - if (!session?.user?.id) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await request.json(); + const { saleUUID, walletAddress } = body; + + if (!saleUUID || !walletAddress) { + return NextResponse.json({ error: "Missing saleUUID or walletAddress" }, { status: 400 }); + } + + try { + // Get tokens from store + let tokens = getTokenStore().getTokens(session.user.id); + if (!tokens) { + return NextResponse.json({ error: "Sonar account not connected" }, { status: 401 }); } - const body = await request.json(); - const { saleUUID, walletAddress } = body; + // Check if token needs refresh + const now = Math.floor(Date.now() / 1000); + if (tokens.expiresAt - now < 300) { + try { + tokens = await refreshSonarToken(tokens.refreshToken); + getTokenStore().setTokens(session.user.id, tokens); + } catch { + return NextResponse.json({ error: "Failed to refresh token" }, { status: 401 }); + } + } - if (!saleUUID || !walletAddress) { - return NextResponse.json({ error: "Missing saleUUID or walletAddress" }, { status: 400 }); + // Create Sonar client and make the request + // The client will pick up the token from the store (including if it was just refreshed) + const client = createSonarClient(session.user.id); + const result = await client.readEntity({ saleUUID, walletAddress }); + + return NextResponse.json(result); + } catch (error) { + if (error instanceof APIError && error.status === 404) { + return NextResponse.json({ Entity: null }, { status: 200 }); } - try { - // Get tokens from store - let tokens = getTokenStore().getTokens(session.user.id); - if (!tokens) { - return NextResponse.json({ error: "Sonar account not connected" }, { status: 401 }); - } - - // Check if token needs refresh - const now = Math.floor(Date.now() / 1000); - if (tokens.expiresAt - now < 300) { - try { - tokens = await refreshSonarToken(tokens.refreshToken); - getTokenStore().setTokens(session.user.id, tokens); - } catch { - return NextResponse.json({ error: "Failed to refresh token" }, { status: 401 }); - } - } - - // Create Sonar client and make the request - // The client will pick up the token from the store (including if it was just refreshed) - const client = createSonarClient(session.user.id); - const result = await client.readEntity({ saleUUID, walletAddress }); - - return NextResponse.json(result); - } catch (error) { - if (error instanceof APIError && error.status === 404) { - return NextResponse.json({ Entity: null }, { status: 200 }); - } - - if (error instanceof APIError) { - return NextResponse.json({ error: error.message }, { status: error.status }); - } - - console.error("Error calling Sonar API"); - return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + if (error instanceof APIError) { + return NextResponse.json({ error: error.message }, { status: error.status }); } -} + console.error("Error calling Sonar API"); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/app/api/sonar/generate-purchase-permit/route.ts b/src/app/api/sonar/generate-purchase-permit/route.ts index bcf9029..8b4b2af 100644 --- a/src/app/api/sonar/generate-purchase-permit/route.ts +++ b/src/app/api/sonar/generate-purchase-permit/route.ts @@ -8,49 +8,48 @@ import { APIError } from "@echoxyz/sonar-core"; * Proxy request to Sonar GenerateSalePurchasePermit endpoint */ export async function POST(request: NextRequest) { - const session = await getAuth(); + const session = await getAuth(); - if (!session?.user?.id) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await request.json(); + const { saleUUID, entityID, walletAddress } = body; - const body = await request.json(); - const { saleUUID, entityID, walletAddress } = body; + if (!saleUUID || !entityID || !walletAddress) { + return NextResponse.json({ error: "Missing required parameters" }, { status: 400 }); + } - if (!saleUUID || !entityID || !walletAddress) { - return NextResponse.json({ error: "Missing required parameters" }, { status: 400 }); + try { + // Get tokens from store + let tokens = getTokenStore().getTokens(session.user.id); + if (!tokens) { + return NextResponse.json({ error: "Sonar account not connected" }, { status: 401 }); } - try { - // Get tokens from store - let tokens = getTokenStore().getTokens(session.user.id); - if (!tokens) { - return NextResponse.json({ error: "Sonar account not connected" }, { status: 401 }); - } - - // Check if token needs refresh - const now = Math.floor(Date.now() / 1000); - if (tokens.expiresAt - now < 300) { - try { - tokens = await refreshSonarToken(tokens.refreshToken); - getTokenStore().setTokens(session.user.id, tokens); - } catch { - return NextResponse.json({ error: "Failed to refresh token" }, { status: 401 }); - } - } - - // Create Sonar client and make the request - const client = createSonarClient(session.user.id); - const result = await client.generatePurchasePermit({ saleUUID, entityID, walletAddress }); - - return NextResponse.json(result); - } catch (error) { - if (error instanceof APIError) { - return NextResponse.json({ error: error.message }, { status: error.status }); - } - - console.error("Error calling Sonar API"); - return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + // Check if token needs refresh + const now = Math.floor(Date.now() / 1000); + if (tokens.expiresAt - now < 300) { + try { + tokens = await refreshSonarToken(tokens.refreshToken); + getTokenStore().setTokens(session.user.id, tokens); + } catch { + return NextResponse.json({ error: "Failed to refresh token" }, { status: 401 }); + } } -} + // Create Sonar client and make the request + const client = createSonarClient(session.user.id); + const result = await client.generatePurchasePermit({ saleUUID, entityID, walletAddress }); + + return NextResponse.json(result); + } catch (error) { + if (error instanceof APIError) { + return NextResponse.json({ error: error.message }, { status: error.status }); + } + + console.error("Error calling Sonar API"); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/app/api/sonar/pre-purchase-check/route.ts b/src/app/api/sonar/pre-purchase-check/route.ts index 66dc13a..8ed0c84 100644 --- a/src/app/api/sonar/pre-purchase-check/route.ts +++ b/src/app/api/sonar/pre-purchase-check/route.ts @@ -8,49 +8,48 @@ import { APIError } from "@echoxyz/sonar-core"; * Proxy request to Sonar PrePurchaseCheck endpoint */ export async function POST(request: NextRequest) { - const session = await getAuth(); + const session = await getAuth(); - if (!session?.user?.id) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await request.json(); + const { saleUUID, entityID, walletAddress } = body; - const body = await request.json(); - const { saleUUID, entityID, walletAddress } = body; + if (!saleUUID || !entityID || !walletAddress) { + return NextResponse.json({ error: "Missing required parameters" }, { status: 400 }); + } - if (!saleUUID || !entityID || !walletAddress) { - return NextResponse.json({ error: "Missing required parameters" }, { status: 400 }); + try { + // Get tokens from store + let tokens = getTokenStore().getTokens(session.user.id); + if (!tokens) { + return NextResponse.json({ error: "Sonar account not connected" }, { status: 401 }); } - try { - // Get tokens from store - let tokens = getTokenStore().getTokens(session.user.id); - if (!tokens) { - return NextResponse.json({ error: "Sonar account not connected" }, { status: 401 }); - } - - // Check if token needs refresh - const now = Math.floor(Date.now() / 1000); - if (tokens.expiresAt - now < 300) { - try { - tokens = await refreshSonarToken(tokens.refreshToken); - getTokenStore().setTokens(session.user.id, tokens); - } catch { - return NextResponse.json({ error: "Failed to refresh token" }, { status: 401 }); - } - } - - // Create Sonar client and make the request - const client = createSonarClient(session.user.id); - const result = await client.prePurchaseCheck({ saleUUID, entityID, walletAddress }); - - return NextResponse.json(result); - } catch (error) { - if (error instanceof APIError) { - return NextResponse.json({ error: error.message }, { status: error.status }); - } - - console.error("Error calling Sonar API"); - return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + // Check if token needs refresh + const now = Math.floor(Date.now() / 1000); + if (tokens.expiresAt - now < 300) { + try { + tokens = await refreshSonarToken(tokens.refreshToken); + getTokenStore().setTokens(session.user.id, tokens); + } catch { + return NextResponse.json({ error: "Failed to refresh token" }, { status: 401 }); + } } -} + // Create Sonar client and make the request + const client = createSonarClient(session.user.id); + const result = await client.prePurchaseCheck({ saleUUID, entityID, walletAddress }); + + return NextResponse.json(result); + } catch (error) { + if (error instanceof APIError) { + return NextResponse.json({ error: error.message }, { status: error.status }); + } + + console.error("Error calling Sonar API"); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/app/components/auth/AuthenticationSection.tsx b/src/app/components/auth/AuthenticationSection.tsx index c18ede4..1419784 100644 --- a/src/app/components/auth/AuthenticationSection.tsx +++ b/src/app/components/auth/AuthenticationSection.tsx @@ -51,7 +51,10 @@ export function AuthenticationSection() {

Sign in with your wallet to continue.

-
@@ -69,7 +72,10 @@ export function AuthenticationSection() { Sign out
- diff --git a/src/app/components/sale/PurchaseCard.tsx b/src/app/components/sale/PurchaseCard.tsx index b70bd75..f53f8cf 100644 --- a/src/app/components/sale/PurchaseCard.tsx +++ b/src/app/components/sale/PurchaseCard.tsx @@ -156,17 +156,16 @@ function PurchaseCard({ entityID, walletAddress }: { entityID: EntityID; walletA /> )} - {!purchaser.readyToPurchase && - purchaser.failureReason === PrePurchaseFailureReason.REQUIRES_LIVENESS && ( - - )} + {!purchaser.readyToPurchase && purchaser.failureReason === PrePurchaseFailureReason.REQUIRES_LIVENESS && ( + + )} ); } diff --git a/src/app/hooks/use-siwe.ts b/src/app/hooks/use-siwe.ts index c7f728b..532f9e6 100644 --- a/src/app/hooks/use-siwe.ts +++ b/src/app/hooks/use-siwe.ts @@ -8,23 +8,23 @@ import { useAccount } from "wagmi"; * Hook for Sign In With Ethereum */ export function useSIWE() { - const { address } = useAccount(); - const { signMessageAsync } = useSignMessage(); + const { address } = useAccount(); + const { signMessageAsync } = useSignMessage(); - const signInWithEthereum = async () => { - if (!address) { - throw new Error("Wallet not connected"); - } + const signInWithEthereum = async () => { + if (!address) { + throw new Error("Wallet not connected"); + } - // Create SIWE message - const domain = typeof window !== "undefined" ? window.location.host : "localhost"; - const origin = typeof window !== "undefined" ? window.location.origin : "http://localhost:3000"; - const statement = "Sign in with Ethereum to the app."; - // Generate alphanumeric nonce (siwe requires no hyphens) - const nonce = crypto.randomUUID().replace(/-/g, ""); - const expirationTime = new Date(Date.now() + 1000 * 60 * 60 * 24).toISOString(); // 24 hours + // Create SIWE message + const domain = typeof window !== "undefined" ? window.location.host : "localhost"; + const origin = typeof window !== "undefined" ? window.location.origin : "http://localhost:3000"; + const statement = "Sign in with Ethereum to the app."; + // Generate alphanumeric nonce (siwe requires no hyphens) + const nonce = crypto.randomUUID().replace(/-/g, ""); + const expirationTime = new Date(Date.now() + 1000 * 60 * 60 * 24).toISOString(); // 24 hours - const message = `${domain} wants you to sign in with your Ethereum account: + const message = `${domain} wants you to sign in with your Ethereum account: ${address} ${statement} @@ -36,29 +36,29 @@ Nonce: ${nonce} Issued At: ${new Date().toISOString()} Expiration Time: ${expirationTime}`; - try { - // Sign the message - const signature = await signMessageAsync({ message }); + try { + // Sign the message + const signature = await signMessageAsync({ message }); - // Send to NextAuth - const result = await signIn("siwe", { - message, - signature, - redirect: false, - }); + // Send to NextAuth + const result = await signIn("siwe", { + message, + signature, + redirect: false, + }); - if (result?.error) { - throw new Error(result.error); - } + if (result?.error) { + throw new Error(result.error); + } - return result; - } catch (error) { - console.error("SIWE error:", error); - throw error; - } - }; + return result; + } catch (error) { + console.error("SIWE error:", error); + throw error; + } + }; - return { - signInWithEthereum, - }; + return { + signInWithEthereum, + }; } diff --git a/src/app/hooks/use-sonar-entities.ts b/src/app/hooks/use-sonar-entities.ts index 51f22cf..c0ea2b8 100644 --- a/src/app/hooks/use-sonar-entities.ts +++ b/src/app/hooks/use-sonar-entities.ts @@ -10,58 +10,57 @@ import { saleUUID } from "../config"; * Replaces useSonarEntities from sonar-react */ export function useSonarEntities() { - const { data: session } = useSession(); - const [loading, setLoading] = useState(false); - const [entities, setEntities] = useState(undefined); - const [error, setError] = useState(undefined); + const { data: session } = useSession(); + const [loading, setLoading] = useState(false); + const [entities, setEntities] = useState(undefined); + const [error, setError] = useState(undefined); - const sonarConnected = session?.user?.sonarConnected ?? false; + const sonarConnected = session?.user?.sonarConnected ?? false; - useEffect(() => { - if (!session?.user?.id || !sonarConnected) { - setEntities(undefined); - setError(undefined); - setLoading(false); - return; - } + useEffect(() => { + if (!session?.user?.id || !sonarConnected) { + setEntities(undefined); + setError(undefined); + setLoading(false); + return; + } - const fetchEntities = async () => { - setLoading(true); - setError(undefined); + const fetchEntities = async () => { + setLoading(true); + setError(undefined); - try { - const response = await fetch("/api/sonar/entities", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - saleUUID, - }), - }); + try { + const response = await fetch("/api/sonar/entities", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + saleUUID, + }), + }); - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || "Failed to fetch entities"); - } + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || "Failed to fetch entities"); + } - const data = await response.json(); - setEntities(data.Entities || []); - } catch (err) { - setError(err instanceof Error ? err : new Error(String(err))); - setEntities(undefined); - } finally { - setLoading(false); - } - }; + const data = await response.json(); + setEntities(data.Entities || []); + } catch (err) { + setError(err instanceof Error ? err : new Error(String(err))); + setEntities(undefined); + } finally { + setLoading(false); + } + }; - fetchEntities(); - }, [session?.user?.id, sonarConnected]); + fetchEntities(); + }, [session?.user?.id, sonarConnected]); - return { - loading, - entities, - error, - }; + return { + loading, + entities, + error, + }; } - diff --git a/src/app/hooks/use-sonar-entity.ts b/src/app/hooks/use-sonar-entity.ts index 6f02818..15cd52a 100644 --- a/src/app/hooks/use-sonar-entity.ts +++ b/src/app/hooks/use-sonar-entity.ts @@ -10,65 +10,64 @@ import { saleUUID } from "../config"; * Replaces useSonarEntity from sonar-react */ export function useSonarEntity(walletAddress?: string) { - const { data: session } = useSession(); - const [loading, setLoading] = useState(false); - const [entity, setEntity] = useState(undefined); - const [error, setError] = useState(undefined); + const { data: session } = useSession(); + const [loading, setLoading] = useState(false); + const [entity, setEntity] = useState(undefined); + const [error, setError] = useState(undefined); - const sonarConnected = session?.user?.sonarConnected ?? false; + const sonarConnected = session?.user?.sonarConnected ?? false; - useEffect(() => { - // Only fetch if user is authenticated AND connected to Sonar - if (!session?.user?.id || !sonarConnected || !walletAddress) { + useEffect(() => { + // Only fetch if user is authenticated AND connected to Sonar + if (!session?.user?.id || !sonarConnected || !walletAddress) { + setEntity(undefined); + setError(undefined); + setLoading(false); + return; + } + + const fetchEntity = async () => { + setLoading(true); + setError(undefined); + + try { + const response = await fetch("/api/sonar/entity", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + saleUUID, + walletAddress, + }), + }); + + if (!response.ok) { + if (response.status === 404) { setEntity(undefined); - setError(undefined); setLoading(false); return; + } + const errorData = await response.json(); + throw new Error(errorData.error || "Failed to fetch entity"); } - const fetchEntity = async () => { - setLoading(true); - setError(undefined); - - try { - const response = await fetch("/api/sonar/entity", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - saleUUID, - walletAddress, - }), - }); - - if (!response.ok) { - if (response.status === 404) { - setEntity(undefined); - setLoading(false); - return; - } - const errorData = await response.json(); - throw new Error(errorData.error || "Failed to fetch entity"); - } - - const data = await response.json(); - setEntity(data.Entity); - } catch (err) { - setError(err instanceof Error ? err : new Error(String(err))); - setEntity(undefined); - } finally { - setLoading(false); - } - }; + const data = await response.json(); + setEntity(data.Entity); + } catch (err) { + setError(err instanceof Error ? err : new Error(String(err))); + setEntity(undefined); + } finally { + setLoading(false); + } + }; - fetchEntity(); - }, [session?.user?.id, sonarConnected, walletAddress]); + fetchEntity(); + }, [session?.user?.id, sonarConnected, walletAddress]); - return { - loading, - entity, - error, - }; + return { + loading, + entity, + error, + }; } - diff --git a/src/app/hooks/use-sonar-purchase.ts b/src/app/hooks/use-sonar-purchase.ts index 428ee23..379634a 100644 --- a/src/app/hooks/use-sonar-purchase.ts +++ b/src/app/hooks/use-sonar-purchase.ts @@ -1,152 +1,147 @@ "use client"; -import { - EntityID, - GeneratePurchasePermitResponse, - PrePurchaseFailureReason, -} from "@echoxyz/sonar-core"; +import { EntityID, GeneratePurchasePermitResponse, PrePurchaseFailureReason } from "@echoxyz/sonar-core"; import { useSession } from "next-auth/react"; import { useCallback, useEffect, useState } from "react"; export type UseSonarPurchaseResultLoading = { - loading: true; - readyToPurchase: false; + loading: true; + readyToPurchase: false; }; export type UseSonarPurchaseResultReadyToPurchase = { - loading: false; - readyToPurchase: true; - generatePurchasePermit: () => Promise; - livenessCheckURL?: string; + loading: false; + readyToPurchase: true; + generatePurchasePermit: () => Promise; + livenessCheckURL?: string; }; export type UseSonarPurchaseResultNotReadyToPurchase = { - loading: false; - readyToPurchase: false; - failureReason: PrePurchaseFailureReason; - livenessCheckURL?: string; + loading: false; + readyToPurchase: false; + failureReason: PrePurchaseFailureReason; + livenessCheckURL?: string; }; export type UseSonarPurchaseResultError = { - loading: false; - readyToPurchase: false; - error: Error; + loading: false; + readyToPurchase: false; + error: Error; }; export type UseSonarPurchaseResult = - | UseSonarPurchaseResultLoading - | UseSonarPurchaseResultReadyToPurchase - | UseSonarPurchaseResultNotReadyToPurchase - | UseSonarPurchaseResultError; + | UseSonarPurchaseResultLoading + | UseSonarPurchaseResultReadyToPurchase + | UseSonarPurchaseResultNotReadyToPurchase + | UseSonarPurchaseResultError; /** * Hook for Sonar purchase flow * Replaces useSonarPurchase from sonar-react */ export function useSonarPurchase(args: { - saleUUID: string; - entityID: EntityID; - walletAddress: string; + saleUUID: string; + entityID: EntityID; + walletAddress: string; }): UseSonarPurchaseResult { - const { data: session } = useSession(); - const [state, setState] = useState({ - loading: true, - readyToPurchase: false, + const { data: session } = useSession(); + const [state, setState] = useState({ + loading: true, + readyToPurchase: false, + }); + + const generatePurchasePermit = useCallback(async (): Promise => { + if (!session?.user?.id) { + 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 generatePurchasePermit = useCallback(async (): Promise => { - if (!session?.user?.id) { - throw new Error("Not authenticated"); - } + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || "Failed to generate purchase permit"); + } + + const data = await response.json(); + return data; + }, [session?.user?.id, args.saleUUID, args.entityID, args.walletAddress]); - 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 sonarConnected = session?.user?.sonarConnected ?? false; + + useEffect(() => { + // Only fetch if user is authenticated AND connected to Sonar + if (!session?.user?.id || !sonarConnected) { + setState({ + loading: false, + readyToPurchase: false, + error: new Error("Not authenticated"), + }); + return; + } + + const fetchPurchaseData = async () => { + setState({ + loading: true, + readyToPurchase: false, + }); + + try { + const response = await fetch("/api/sonar/pre-purchase-check", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + saleUUID: args.saleUUID, + entityID: args.entityID, + walletAddress: args.walletAddress, + }), }); if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || "Failed to generate purchase permit"); + const errorData = await response.json(); + throw new Error(errorData.error || "Failed to check purchase eligibility"); } const data = await response.json(); - return data; - }, [session?.user?.id, args.saleUUID, args.entityID, args.walletAddress]); - - const sonarConnected = session?.user?.sonarConnected ?? false; - - useEffect(() => { - // Only fetch if user is authenticated AND connected to Sonar - if (!session?.user?.id || !sonarConnected) { - setState({ - loading: false, - readyToPurchase: false, - error: new Error("Not authenticated"), - }); - return; + + if (data.ReadyToPurchase) { + setState({ + loading: false, + readyToPurchase: true, + generatePurchasePermit, + livenessCheckURL: data.LivenessCheckURL, + }); + return; } - const fetchPurchaseData = async () => { - setState({ - loading: true, - readyToPurchase: false, - }); - - try { - const response = await fetch("/api/sonar/pre-purchase-check", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - saleUUID: args.saleUUID, - entityID: args.entityID, - walletAddress: args.walletAddress, - }), - }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || "Failed to check purchase eligibility"); - } - - const data = await response.json(); - - if (data.ReadyToPurchase) { - setState({ - loading: false, - readyToPurchase: true, - generatePurchasePermit, - livenessCheckURL: data.LivenessCheckURL, - }); - return; - } - - setState({ - loading: false, - readyToPurchase: false, - failureReason: data.FailureReason as PrePurchaseFailureReason, - livenessCheckURL: data.LivenessCheckURL, - }); - } catch (err) { - setState({ - loading: false, - readyToPurchase: false, - error: err instanceof Error ? err : new Error(String(err)), - }); - } - }; - - fetchPurchaseData(); - }, [session?.user?.id, sonarConnected, args.saleUUID, args.entityID, args.walletAddress, generatePurchasePermit]); - - return state; -} + setState({ + loading: false, + readyToPurchase: false, + failureReason: data.FailureReason as PrePurchaseFailureReason, + livenessCheckURL: data.LivenessCheckURL, + }); + } catch (err) { + setState({ + loading: false, + readyToPurchase: false, + error: err instanceof Error ? err : new Error(String(err)), + }); + } + }; + + fetchPurchaseData(); + }, [session?.user?.id, sonarConnected, args.saleUUID, args.entityID, args.walletAddress, generatePurchasePermit]); + return state; +} diff --git a/src/app/page.tsx b/src/app/page.tsx index d0abe47..1cefd01 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -37,20 +37,12 @@ export default function Home() { }; // Registration data (all entities for the user) - const { - loading: entitiesLoading, - entities, - error: entitiesError, - } = useSonarEntities(); + const { loading: entitiesLoading, entities, error: entitiesError } = useSonarEntities(); const eligibleEntities = entities?.filter((entity) => entity.SaleEligibility === SaleEligibility.ELIGIBLE) || []; // Sale data (entity for current wallet) - const { - loading: entityLoading, - entity, - error: entityError, - } = useSonarEntity(address); + const { loading: entityLoading, entity, error: entityError } = useSonarEntity(address); const isEligible = entity && entity.SaleEligibility === SaleEligibility.ELIGIBLE; @@ -198,10 +190,12 @@ export default function Home() { {/* Entity Information */} - {address && sonarConnected && (
-

Your Entity Information

- -
)} + {address && sonarConnected && ( +
+

Your Entity Information

+ +
+ )} {/* Purchase Card */} {isEligible && address && ( diff --git a/src/lib/pkce-store.ts b/src/lib/pkce-store.ts index 7988c16..8740ba9 100644 --- a/src/lib/pkce-store.ts +++ b/src/lib/pkce-store.ts @@ -8,71 +8,70 @@ const PKCE_COOKIE_PREFIX = "sonar_pkce_"; const PKCE_COOKIE_TTL_SEC = 10 * 60; // 10 minutes interface StateEntry { - userId: string; - codeVerifier: string; - expiresAt: number; + userId: string; + codeVerifier: string; + expiresAt: number; } /** * Store PKCE verifier and user ID linked to a state token in a cookie */ export async function setPKCEVerifier(state: string, userId: string, codeVerifier: string): Promise { - const cookieStore = await cookies(); - const expiresAt = Date.now() + PKCE_COOKIE_TTL_SEC * 1000; - - const entry: StateEntry = { - userId, - codeVerifier, - expiresAt, - }; + const cookieStore = await cookies(); + const expiresAt = Date.now() + PKCE_COOKIE_TTL_SEC * 1000; + + const entry: StateEntry = { + userId, + codeVerifier, + expiresAt, + }; - cookieStore.set(`${PKCE_COOKIE_PREFIX}${state}`, JSON.stringify(entry), { - httpOnly: true, - secure: process.env.NODE_ENV === "production", - sameSite: "lax", - maxAge: PKCE_COOKIE_TTL_SEC, - path: "/", - }); + cookieStore.set(`${PKCE_COOKIE_PREFIX}${state}`, JSON.stringify(entry), { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + maxAge: PKCE_COOKIE_TTL_SEC, + path: "/", + }); } /** * Get PKCE verifier and user ID for a state token from cookie */ export async function getPKCEVerifier(state: string): Promise<{ userId: string; codeVerifier: string } | null> { - const cookieStore = await cookies(); - const cookieName = `${PKCE_COOKIE_PREFIX}${state}`; - const cookie = cookieStore.get(cookieName); - - if (!cookie?.value) { - return null; - } - - try { - const entry: StateEntry = JSON.parse(cookie.value); - - // Check if expired - if (entry.expiresAt < Date.now()) { - cookieStore.delete(cookieName); - return null; - } - - return { - userId: entry.userId, - codeVerifier: entry.codeVerifier, - }; - } catch (error) { - console.error("PKCEStore.getPKCEVerifier - parse error", { state, error }); - cookieStore.delete(cookieName); - return null; + const cookieStore = await cookies(); + const cookieName = `${PKCE_COOKIE_PREFIX}${state}`; + const cookie = cookieStore.get(cookieName); + + if (!cookie?.value) { + return null; + } + + try { + const entry: StateEntry = JSON.parse(cookie.value); + + // Check if expired + if (entry.expiresAt < Date.now()) { + cookieStore.delete(cookieName); + return null; } + + return { + userId: entry.userId, + codeVerifier: entry.codeVerifier, + }; + } catch (error) { + console.error("PKCEStore.getPKCEVerifier - parse error", { state, error }); + cookieStore.delete(cookieName); + return null; + } } /** * Clear state entry (called after successful token exchange) */ export async function clearPKCEVerifier(state: string): Promise { - const cookieStore = await cookies(); - const cookieName = `${PKCE_COOKIE_PREFIX}${state}`; - cookieStore.delete(cookieName); + const cookieStore = await cookies(); + const cookieName = `${PKCE_COOKIE_PREFIX}${state}`; + cookieStore.delete(cookieName); } - diff --git a/src/lib/siwe.ts b/src/lib/siwe.ts index 006b16e..d0f26d4 100644 --- a/src/lib/siwe.ts +++ b/src/lib/siwe.ts @@ -12,50 +12,50 @@ import { SiweMessage } from "siwe"; * @returns The verified Ethereum address if valid, null otherwise */ export const siwe = { - async verify(message: string, signature: string): Promise { - try { - // Parse the SIWE message - const siweMessage = new SiweMessage(message); + async verify(message: string, signature: string): Promise { + try { + // Parse the SIWE message + const siweMessage = new SiweMessage(message); - // Verify domain matches expected domain (if configured) - const expectedDomain = process.env.NEXT_PUBLIC_SIWE_DOMAIN; - if (expectedDomain) { - // Allow both with and without port for localhost - const normalizedExpected = expectedDomain.split(":")[0]; - const normalizedParsed = siweMessage.domain.split(":")[0]; - if (normalizedExpected !== normalizedParsed) { - console.error("SIWE: Domain mismatch"); - return null; - } - } + // Verify domain matches expected domain (if configured) + const expectedDomain = process.env.NEXT_PUBLIC_SIWE_DOMAIN; + if (expectedDomain) { + // Allow both with and without port for localhost + const normalizedExpected = expectedDomain.split(":")[0]; + const normalizedParsed = siweMessage.domain.split(":")[0]; + if (normalizedExpected !== normalizedParsed) { + console.error("SIWE: Domain mismatch"); + return null; + } + } - // Validate chain ID if present (optional - can be configured via env) - const expectedChainId = process.env.NEXT_PUBLIC_SIWE_CHAIN_ID; - if (expectedChainId && siweMessage.chainId) { - const expectedChainIdNum = parseInt(expectedChainId, 10); - if (siweMessage.chainId !== expectedChainIdNum) { - console.error("SIWE: Chain ID mismatch"); - return null; - } - } + // Validate chain ID if present (optional - can be configured via env) + const expectedChainId = process.env.NEXT_PUBLIC_SIWE_CHAIN_ID; + if (expectedChainId && siweMessage.chainId) { + const expectedChainIdNum = parseInt(expectedChainId, 10); + if (siweMessage.chainId !== expectedChainIdNum) { + console.error("SIWE: Chain ID mismatch"); + return null; + } + } - // Verify the signature using the siwe library - // This performs cryptographic signature verification and validates message fields - const result = await siweMessage.verify({ - signature, - }); + // Verify the signature using the siwe library + // This performs cryptographic signature verification and validates message fields + const result = await siweMessage.verify({ + signature, + }); - // Check if verification was successful - if (!result.success) { - console.error("SIWE: Verification failed", result.error); - return null; - } + // Check if verification was successful + if (!result.success) { + console.error("SIWE: Verification failed", result.error); + return null; + } - // Return the verified address from the message - return siweMessage.address.toLowerCase(); - } catch (error) { - console.error("SIWE verification error:", error); - return null; - } - }, + // Return the verified address from the message + return siweMessage.address.toLowerCase(); + } catch (error) { + console.error("SIWE verification error:", error); + return null; + } + }, }; diff --git a/src/lib/sonar-client.ts b/src/lib/sonar-client.ts index 5390f20..a6ecab7 100644 --- a/src/lib/sonar-client.ts +++ b/src/lib/sonar-client.ts @@ -6,25 +6,24 @@ import { getTokenStore } from "./token-store"; * Sets the access token from our server-side token store */ export function createSonarClient(userId: string): SonarClient { - const apiURL = process.env.NEXT_PUBLIC_ECHO_API_URL ?? "https://api.echo.xyz"; - - // Create a new client instance - const client = new SonarClient({ - apiURL, - opts: { - onUnauthorized: () => { - // Clear tokens on unauthorized - getTokenStore().clearTokens(userId); - }, - }, - }); + const apiURL = process.env.NEXT_PUBLIC_ECHO_API_URL ?? "https://api.echo.xyz"; - // Set the token from our server-side store - const tokens = getTokenStore().getTokens(userId); - if (tokens?.accessToken) { - client.setToken(tokens.accessToken); - } + // Create a new client instance + const client = new SonarClient({ + apiURL, + opts: { + onUnauthorized: () => { + // Clear tokens on unauthorized + getTokenStore().clearTokens(userId); + }, + }, + }); - return client; -} + // Set the token from our server-side store + const tokens = getTokenStore().getTokens(userId); + if (tokens?.accessToken) { + client.setToken(tokens.accessToken); + } + return client; +} diff --git a/src/lib/token-store.ts b/src/lib/token-store.ts index 2ad0437..9ec8ae8 100644 --- a/src/lib/token-store.ts +++ b/src/lib/token-store.ts @@ -3,26 +3,26 @@ * This interface allows swapping between in-memory and persistent storage implementations. */ export interface SonarTokens { - accessToken: string; - refreshToken: string; - expiresAt: number; // Unix timestamp in seconds + accessToken: string; + refreshToken: string; + expiresAt: number; // Unix timestamp in seconds } export interface TokenStore { - /** - * Store tokens for a user identifier - */ - setTokens(userId: string, tokens: SonarTokens): void; + /** + * Store tokens for a user identifier + */ + setTokens(userId: string, tokens: SonarTokens): void; - /** - * Retrieve tokens for a user identifier - */ - getTokens(userId: string): SonarTokens | null; + /** + * Retrieve tokens for a user identifier + */ + getTokens(userId: string): SonarTokens | null; - /** - * Remove tokens for a user identifier - */ - clearTokens(userId: string): void; + /** + * Remove tokens for a user identifier + */ + clearTokens(userId: string): void; } /** @@ -31,19 +31,19 @@ export interface TokenStore { * This can be easily swapped for a database-backed implementation. */ class InMemoryTokenStore implements TokenStore { - private tokens: Map = new Map(); + private tokens: Map = new Map(); - setTokens(userId: string, tokens: SonarTokens): void { - this.tokens.set(userId, tokens); - } + setTokens(userId: string, tokens: SonarTokens): void { + this.tokens.set(userId, tokens); + } - getTokens(userId: string): SonarTokens | null { - return this.tokens.get(userId) || null; - } + getTokens(userId: string): SonarTokens | null { + return this.tokens.get(userId) || null; + } - clearTokens(userId: string): void { - this.tokens.delete(userId); - } + clearTokens(userId: string): void { + this.tokens.delete(userId); + } } // Singleton instance - can be swapped for a different implementation @@ -54,10 +54,10 @@ let tokenStoreInstance: TokenStore | null = null; * This factory function allows swapping implementations without changing call sites. */ export function getTokenStore(): TokenStore { - if (!tokenStoreInstance) { - tokenStoreInstance = new InMemoryTokenStore(); - } - return tokenStoreInstance; + if (!tokenStoreInstance) { + tokenStoreInstance = new InMemoryTokenStore(); + } + return tokenStoreInstance; } /** @@ -65,6 +65,5 @@ export function getTokenStore(): TokenStore { * Useful for swapping to a database-backed store. */ export function setTokenStore(store: TokenStore): void { - tokenStoreInstance = store; + tokenStoreInstance = store; } - diff --git a/src/types/next-auth.d.ts b/src/types/next-auth.d.ts index 2bf0740..74c6ec2 100644 --- a/src/types/next-auth.d.ts +++ b/src/types/next-auth.d.ts @@ -2,27 +2,26 @@ import "next-auth"; import "next-auth/jwt"; declare module "next-auth" { - interface Session { - user: { - id: string; - name?: string | null; - email?: string | null; - image?: string | null; - sonarConnected?: boolean; - }; - } + interface Session { + user: { + id: string; + name?: string | null; + email?: string | null; + image?: string | null; + sonarConnected?: boolean; + }; + } - interface User { - id: string; - name?: string | null; - } + interface User { + id: string; + name?: string | null; + } } declare module "next-auth/jwt" { - interface JWT { - sub?: string; - sonarAccessToken?: string; - sonarTokenExpiresAt?: number; - } + interface JWT { + sub?: string; + sonarAccessToken?: string; + sonarTokenExpiresAt?: number; + } } - diff --git a/tsconfig.json b/tsconfig.json index 06ee0db..c133409 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,27 +1,27 @@ { - "compilerOptions": { - "target": "ES2017", - "lib": ["dom", "dom.iterable", "esnext"], - "allowJs": true, - "skipLibCheck": true, - "strict": true, - "noEmit": true, - "esModuleInterop": true, - "module": "esnext", - "moduleResolution": "bundler", - "resolveJsonModule": true, - "isolatedModules": true, - "jsx": "preserve", - "incremental": true, - "plugins": [ - { - "name": "next" - } - ], - "paths": { - "@/*": ["./src/*"] - } - }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules"] + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] } From efa571d92eed9ca2213b67288d6b1d32df425656 Mon Sep 17 00:00:00 2001 From: Will Sewell Date: Tue, 6 Jan 2026 17:17:32 +0000 Subject: [PATCH 05/20] Remove duplication in route handlers --- src/app/api/sonar/entities/route.ts | 57 +++----------- src/app/api/sonar/entity/route.ts | 75 ++++++------------ .../sonar/generate-purchase-permit/route.ts | 70 ++++++----------- src/app/api/sonar/pre-purchase-check/route.ts | 70 ++++++----------- src/lib/sonar-route-handler.ts | 77 +++++++++++++++++++ 5 files changed, 160 insertions(+), 189 deletions(-) create mode 100644 src/lib/sonar-route-handler.ts diff --git a/src/app/api/sonar/entities/route.ts b/src/app/api/sonar/entities/route.ts index 39095a8..66f7b23 100644 --- a/src/app/api/sonar/entities/route.ts +++ b/src/app/api/sonar/entities/route.ts @@ -1,56 +1,23 @@ -import { NextRequest, NextResponse } from "next/server"; -import { getAuth, refreshSonarToken } from "../../auth/[...nextauth]/route"; -import { getTokenStore } from "@/lib/token-store"; -import { createSonarClient } from "@/lib/sonar-client"; -import { APIError } from "@echoxyz/sonar-core"; +import { NextResponse } from "next/server"; +import { createSonarRouteHandler } from "@/lib/sonar-route-handler"; + +type EntitiesRequest = { + saleUUID: string; +}; /** * Proxy request to Sonar ReadEntities endpoint * Returns all entities for the authenticated user */ -export async function POST(request: NextRequest) { - const session = await getAuth(); - - if (!session?.user?.id) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const body = await request.json(); - const { saleUUID } = body; - - if (!saleUUID) { - return NextResponse.json({ error: "Missing saleUUID" }, { status: 400 }); - } +export const POST = createSonarRouteHandler( + async ({ client }, body) => { + const { saleUUID } = body; - try { - // Get tokens from store - let tokens = getTokenStore().getTokens(session.user.id); - if (!tokens) { - return NextResponse.json({ error: "Sonar account not connected" }, { status: 401 }); + if (!saleUUID) { + return NextResponse.json({ error: "Missing saleUUID" }, { status: 400 }); } - // Check if token needs refresh - const now = Math.floor(Date.now() / 1000); - if (tokens.expiresAt - now < 300) { - try { - tokens = await refreshSonarToken(tokens.refreshToken); - getTokenStore().setTokens(session.user.id, tokens); - } catch { - return NextResponse.json({ error: "Failed to refresh token" }, { status: 401 }); - } - } - - // Create Sonar client and make the request - const client = createSonarClient(session.user.id); const result = await client.listAvailableEntities({ saleUUID }); - return NextResponse.json(result); - } catch (error) { - if (error instanceof APIError) { - return NextResponse.json({ error: error.message }, { status: error.status }); - } - - console.error("Error calling Sonar API"); - return NextResponse.json({ error: "Internal server error" }, { status: 500 }); } -} +); diff --git a/src/app/api/sonar/entity/route.ts b/src/app/api/sonar/entity/route.ts index bc0335e..469bdc3 100644 --- a/src/app/api/sonar/entity/route.ts +++ b/src/app/api/sonar/entity/route.ts @@ -1,60 +1,35 @@ -import { NextRequest, NextResponse } from "next/server"; -import { getAuth, refreshSonarToken } from "../../auth/[...nextauth]/route"; -import { getTokenStore } from "@/lib/token-store"; -import { createSonarClient } from "@/lib/sonar-client"; +import { NextResponse } from "next/server"; +import { createSonarRouteHandler } from "@/lib/sonar-route-handler"; import { APIError } from "@echoxyz/sonar-core"; +type EntityRequest = { + saleUUID: string; + walletAddress: string; +}; + /** * Proxy request to Sonar ReadEntity endpoint */ -export async function POST(request: NextRequest) { - const session = await getAuth(); - - if (!session?.user?.id) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const body = await request.json(); - const { saleUUID, walletAddress } = body; - - if (!saleUUID || !walletAddress) { - return NextResponse.json({ error: "Missing saleUUID or walletAddress" }, { status: 400 }); - } - - try { - // Get tokens from store - let tokens = getTokenStore().getTokens(session.user.id); - if (!tokens) { - return NextResponse.json({ error: "Sonar account not connected" }, { status: 401 }); +export const POST = createSonarRouteHandler( + async ({ client }, body) => { + const { saleUUID, walletAddress } = body; + + if (!saleUUID || !walletAddress) { + return NextResponse.json( + { error: "Missing saleUUID or walletAddress" }, + { status: 400 } + ); } - // Check if token needs refresh - const now = Math.floor(Date.now() / 1000); - if (tokens.expiresAt - now < 300) { - try { - tokens = await refreshSonarToken(tokens.refreshToken); - getTokenStore().setTokens(session.user.id, tokens); - } catch { - return NextResponse.json({ error: "Failed to refresh token" }, { status: 401 }); + 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; } - - // Create Sonar client and make the request - // The client will pick up the token from the store (including if it was just refreshed) - const client = createSonarClient(session.user.id); - const result = await client.readEntity({ saleUUID, walletAddress }); - - return NextResponse.json(result); - } catch (error) { - if (error instanceof APIError && error.status === 404) { - return NextResponse.json({ Entity: null }, { status: 200 }); - } - - if (error instanceof APIError) { - return NextResponse.json({ error: error.message }, { status: error.status }); - } - - console.error("Error calling Sonar API"); - return NextResponse.json({ error: "Internal server error" }, { status: 500 }); } -} +); diff --git a/src/app/api/sonar/generate-purchase-permit/route.ts b/src/app/api/sonar/generate-purchase-permit/route.ts index 8b4b2af..f239169 100644 --- a/src/app/api/sonar/generate-purchase-permit/route.ts +++ b/src/app/api/sonar/generate-purchase-permit/route.ts @@ -1,55 +1,31 @@ -import { NextRequest, NextResponse } from "next/server"; -import { getAuth, refreshSonarToken } from "../../auth/[...nextauth]/route"; -import { getTokenStore } from "@/lib/token-store"; -import { createSonarClient } from "@/lib/sonar-client"; -import { APIError } from "@echoxyz/sonar-core"; +import { NextResponse } from "next/server"; +import { createSonarRouteHandler } from "@/lib/sonar-route-handler"; + +type GeneratePurchasePermitRequest = { + saleUUID: string; + entityID: string; + walletAddress: string; +}; /** * Proxy request to Sonar GenerateSalePurchasePermit endpoint */ -export async function POST(request: NextRequest) { - const session = await getAuth(); - - if (!session?.user?.id) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const body = await request.json(); - const { saleUUID, entityID, walletAddress } = body; - - if (!saleUUID || !entityID || !walletAddress) { - return NextResponse.json({ error: "Missing required parameters" }, { status: 400 }); - } - - try { - // Get tokens from store - let tokens = getTokenStore().getTokens(session.user.id); - if (!tokens) { - return NextResponse.json({ error: "Sonar account not connected" }, { status: 401 }); - } - - // Check if token needs refresh - const now = Math.floor(Date.now() / 1000); - if (tokens.expiresAt - now < 300) { - try { - tokens = await refreshSonarToken(tokens.refreshToken); - getTokenStore().setTokens(session.user.id, tokens); - } catch { - return NextResponse.json({ error: "Failed to refresh token" }, { status: 401 }); - } +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 } + ); } - // Create Sonar client and make the request - const client = createSonarClient(session.user.id); - const result = await client.generatePurchasePermit({ saleUUID, entityID, walletAddress }); - + const result = await client.generatePurchasePermit({ + saleUUID, + entityID, + walletAddress, + }); return NextResponse.json(result); - } catch (error) { - if (error instanceof APIError) { - return NextResponse.json({ error: error.message }, { status: error.status }); - } - - console.error("Error calling Sonar API"); - return NextResponse.json({ error: "Internal server error" }, { status: 500 }); } -} +); diff --git a/src/app/api/sonar/pre-purchase-check/route.ts b/src/app/api/sonar/pre-purchase-check/route.ts index 8ed0c84..2b74d9d 100644 --- a/src/app/api/sonar/pre-purchase-check/route.ts +++ b/src/app/api/sonar/pre-purchase-check/route.ts @@ -1,55 +1,31 @@ -import { NextRequest, NextResponse } from "next/server"; -import { getAuth, refreshSonarToken } from "../../auth/[...nextauth]/route"; -import { getTokenStore } from "@/lib/token-store"; -import { createSonarClient } from "@/lib/sonar-client"; -import { APIError } from "@echoxyz/sonar-core"; +import { NextResponse } from "next/server"; +import { createSonarRouteHandler } from "@/lib/sonar-route-handler"; + +type PrePurchaseCheckRequest = { + saleUUID: string; + entityID: string; + walletAddress: string; +}; /** * Proxy request to Sonar PrePurchaseCheck endpoint */ -export async function POST(request: NextRequest) { - const session = await getAuth(); - - if (!session?.user?.id) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const body = await request.json(); - const { saleUUID, entityID, walletAddress } = body; - - if (!saleUUID || !entityID || !walletAddress) { - return NextResponse.json({ error: "Missing required parameters" }, { status: 400 }); - } - - try { - // Get tokens from store - let tokens = getTokenStore().getTokens(session.user.id); - if (!tokens) { - return NextResponse.json({ error: "Sonar account not connected" }, { status: 401 }); - } - - // Check if token needs refresh - const now = Math.floor(Date.now() / 1000); - if (tokens.expiresAt - now < 300) { - try { - tokens = await refreshSonarToken(tokens.refreshToken); - getTokenStore().setTokens(session.user.id, tokens); - } catch { - return NextResponse.json({ error: "Failed to refresh token" }, { status: 401 }); - } +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 } + ); } - // Create Sonar client and make the request - const client = createSonarClient(session.user.id); - const result = await client.prePurchaseCheck({ saleUUID, entityID, walletAddress }); - + const result = await client.prePurchaseCheck({ + saleUUID, + entityID, + walletAddress, + }); return NextResponse.json(result); - } catch (error) { - if (error instanceof APIError) { - return NextResponse.json({ error: error.message }, { status: error.status }); - } - - console.error("Error calling Sonar API"); - return NextResponse.json({ error: "Internal server error" }, { status: 500 }); } -} +); diff --git a/src/lib/sonar-route-handler.ts b/src/lib/sonar-route-handler.ts new file mode 100644 index 0000000..1d90332 --- /dev/null +++ b/src/lib/sonar-route-handler.ts @@ -0,0 +1,77 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getAuth, refreshSonarToken } from "@/app/api/auth/[...nextauth]/route"; +import { getTokenStore, SonarTokens } from "@/lib/token-store"; +import { createSonarClient } from "@/lib/sonar-client"; +import { APIError, SonarClient } from "@echoxyz/sonar-core"; + +export type AuthenticatedContext = { + userId: string; + tokens: SonarTokens; + client: SonarClient; +}; + +type RouteHandler = ( + context: AuthenticatedContext, + body: T +) => Promise; + +/** + * Creates a Sonar API route handler with authentication, token refresh, and error handling. + */ +export function createSonarRouteHandler( + handler: RouteHandler +): (request: NextRequest) => Promise { + return async (request: NextRequest) => { + // Check session authentication + const session = await getAuth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const userId = session.user.id; + + // Get tokens from store + let tokens = getTokenStore().getTokens(userId); + if (!tokens) { + return NextResponse.json( + { error: "Sonar account not connected" }, + { status: 401 } + ); + } + + // Check if token needs refresh + const now = Math.floor(Date.now() / 1000); + if (tokens.expiresAt - now < 300) { + try { + tokens = await refreshSonarToken(tokens.refreshToken); + getTokenStore().setTokens(userId, tokens); + } catch { + return NextResponse.json( + { error: "Failed to refresh token" }, + { status: 401 } + ); + } + } + + try { + const body = (await request.json()) as T; + const client = createSonarClient(userId); + + return await handler({ userId, tokens, client }, body); + } catch (error) { + if (error instanceof APIError) { + return NextResponse.json( + { error: error.message }, + { status: error.status } + ); + } + + console.error("Error calling Sonar API"); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } + }; +} + From 7b4f106adeef8bf7fc73d28382baff71997be840 Mon Sep 17 00:00:00 2001 From: Will Sewell Date: Tue, 6 Jan 2026 17:52:08 +0000 Subject: [PATCH 06/20] Provide architectural overview in readme --- README.md | 139 +++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 116 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 8ea6fc0..c9b7183 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,18 @@ # sonar-example-nextjs -Example Next.js app showing how to inetgrate with the Sonar API via the `sonar-react` and `sonar-core` libraries. - -There is an integration guide for these libraries [here](https://docs.echo.xyz/sonar/integration-guides/react). - -The example app demonstrates how to: - -- Setup providers in `src/app/Provider.tsx` -- Authenticate with Sonar via the oauth flow - - See `src/app/components/auth/AuthenticationSection.tsx` on how to create the login/logout buttons - - See `src/app/oauth/callback/page.tsx` for an example of the oauth callback handler -- Prior to a sale going live, a way to list the state of all of a user's entities - - See `src/app/page.tsx` while in the `!saleIsLive` state -- When sale is live, display setup/eligibily state of the entity on Sonar that is linked to the currently connected wallet - - See `src/app/page.tsx` while in the `saleIsLive` state -- Surface the user's entity setup/eligibility state - - See components in `src/app/components/entity` -- Run prepurchase checks - - See `src/app/components/sale/PurchaseCard.tsx` for an example of how to run these checks and interpret the result -- Submit a purchase transaction to an example sale contract - - See the `ReadyToPurchaseSection` in `src/app/components/sale/PurchaseCard.tsx` for an example of how to generate a purchase permit and pass this to the contract, - using the `useSaleContract` hook in `src/app/hooks.ts` - -## Running the app locally +A **backend-focused** example Next.js app showing how to integrate with the Sonar API. + +There is an integration guide for the Sonar libraries [here](https://docs.echo.xyz/sonar/integration-guides/react). + +This example implements a backend OAuth flow where tokens are stored server-side and all Sonar API requests are proxied through the backend. For a simpler frontend-only approach where tokens are managed client-side, see [sonar-example-react](https://github.com/sunrisedotdev/sonar-example-react). + +## Why Use the Backend Approach? + +This approach is more secure than a frontend-only approach since the access tokens stay on the server and do not need to be sent to the client at all. + +However it does increase the complexity, which might not be worth it if you already have a frontend-only single page app. + +## Running the App Locally Set the required env vars listed in `src/app/config.ts` (or update that file). You can find the values for your sale on the [Echo founder dashboard](https://app.echo.xyz/founder). @@ -31,3 +21,106 @@ You can find the values for your sale on the [Echo founder dashboard](https://ap pnpm i pnpm dev ``` + +## What This Example Demonstrates + +- **OAuth authentication with Sonar** via a secure backend flow with PKCE +- **Token management** — server-side storage with automatic refresh +- **Entity state display** — prior to sale, list all user entities; during sale, show linked entity status +- **Pre-purchase checks** — validate eligibility before transactions +- **Purchase transactions** — generate permits and submit to the sale contract + +## Architecture Overview + +### OAuth Flow + +The backend handles the complete OAuth flow, storing tokens securely server-side: + +``` +┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ +│ Browser │ │ Next.js │ │ Echo │ │ Sonar │ +│ │ │ Backend │ │ OAuth │ │ API │ +└────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ + │ │ │ │ + │ 1. Click "Connect" │ │ │ + ├───────────────────>│ │ │ + │ │ │ │ + │ │ 2. Generate PKCE │ │ + │ │ params & store │ │ + │ │ verifier │ │ + │ │ │ │ + │ 3. Redirect to Echo OAuth │ │ + │<───────────────────┼───────────────────>│ │ + │ │ │ │ + │ 4. User authenticates │ │ + │<─────────────────────────────────-─────>│ │ + │ │ │ │ + │ 5. Redirect with auth code │ │ + │────────────────────┼───────────────────>│ │ + │ │ │ │ + │ │ 6. Exchange code │ │ + │ │ for tokens │ │ + │ │<──────────────────>│ │ + │ │ │ │ + │ │ 7. Store tokens │ │ + │ │ server-side │ │ + │ │ │ │ + │ 8. Success response│ │ │ + │<───────────────────│ │ │ + │ │ │ │ +``` + +### Proxied API Requests + +Once authenticated, all Sonar API calls go through the backend, which handles token refresh automatically: + +``` +┌─────────┐ ┌─────────┐ ┌─────────┐ +│ Browser │ │ Next.js │ │ Sonar │ +│ │ │ Backend │ │ API │ +└────┬────┘ └────┬────┘ └────┬────┘ + │ │ │ + │ 1. POST /api/sonar/entities │ │ + │ { saleUUID: "..." } │ │ + ├────────────────────────────>│ │ + │ │ │ + │ │ 2. Verify session │ + │ │ & get tokens │ + │ │ │ + │ │ 3. Refresh token │ + │ │ if expiring │ + │ │ │ + │ │ 4. GET /entities │ + │ │ Authorization: Bearer... │ + │ ├────────────────────────────>│ + │ │ │ + │ │ 5. Response │ + │ │<────────────────────────────│ + │ │ │ + │ 6. Forward response │ │ + │<────────────────────────────│ │ + │ │ │ +``` + +## Project Structure + +``` +src/ +├── app/ +│ ├── api/ +│ │ ├── auth/ +│ │ │ ├── [...nextauth]/ # NextAuth config & session management +│ │ │ └── sonar/ # Sonar OAuth routes (authorize, callback, disconnect) +│ │ └── sonar/ # Proxied Sonar API routes (entities, pre-purchase, etc.) +│ ├── components/ +│ │ ├── auth/ # Login/logout UI +│ │ ├── entity/ # Entity display components +│ │ ├── registration/ # Pre-sale entity list & eligibility +│ │ └── sale/ # Purchase flow UI +│ ├── hooks/ # React hooks for Sonar API calls +│ ├── oauth/callback/ # OAuth callback page (frontend) +│ ├── config.ts # Environment configuration +│ ├── page.tsx # Main page +│ └── Provider.tsx # App providers setup +└── lib/ # Server-side utilities (token storage, PKCE, Sonar client) +``` From 52e5a50d7dbbfdb7ac67ef5e19d0fe04facaf98a Mon Sep 17 00:00:00 2001 From: Will Sewell Date: Tue, 6 Jan 2026 17:55:26 +0000 Subject: [PATCH 07/20] Move all hooks into hooks dir --- src/app/components/sale/PurchaseCard.tsx | 2 +- src/app/{hooks.ts => hooks/use-sale-contract.ts} | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) rename src/app/{hooks.ts => hooks/use-sale-contract.ts} (96%) diff --git a/src/app/components/sale/PurchaseCard.tsx b/src/app/components/sale/PurchaseCard.tsx index f53f8cf..821a87b 100644 --- a/src/app/components/sale/PurchaseCard.tsx +++ b/src/app/components/sale/PurchaseCard.tsx @@ -8,7 +8,7 @@ import { UseSonarPurchaseResultNotReadyToPurchase, UseSonarPurchaseResultReadyToPurchase, } from "../../hooks/use-sonar-purchase"; -import { useSaleContract } from "../../hooks"; +import { useSaleContract } from "../../hooks/use-sale-contract"; function readinessConfig( sonarPurchaser: UseSonarPurchaseResultReadyToPurchase | UseSonarPurchaseResultNotReadyToPurchase diff --git a/src/app/hooks.ts b/src/app/hooks/use-sale-contract.ts similarity index 96% rename from src/app/hooks.ts rename to src/app/hooks/use-sale-contract.ts index d3900bc..393e558 100644 --- a/src/app/hooks.ts +++ b/src/app/hooks/use-sale-contract.ts @@ -1,8 +1,8 @@ import { BasicPermitV2, GeneratePurchasePermitResponse } from "@echoxyz/sonar-core"; import { useCallback, useEffect, useState } from "react"; import { useReadContract, useWriteContract, useWaitForTransactionReceipt } from "wagmi"; -import { saleContract } from "./config"; -import { examplSaleABI } from "./ExampleSaleABI"; +import { saleContract } from "../config"; +import { examplSaleABI } from "../ExampleSaleABI"; import { useConfig } from "wagmi"; import { simulateContract } from "wagmi/actions"; @@ -85,3 +85,4 @@ export const useSaleContract = (walletAddress: `0x${string}`) => { awaitingTxReceiptError, }; }; + From 1e6adf62ad06a1751f6c8e7a15843eeb1edf191b Mon Sep 17 00:00:00 2001 From: Will Sewell Date: Tue, 6 Jan 2026 20:08:53 +0000 Subject: [PATCH 08/20] Fix issue where after connecting to sonar, the frontend would still not think it was connected --- src/app/oauth/callback/page.tsx | 25 +++++++++---------------- 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/src/app/oauth/callback/page.tsx b/src/app/oauth/callback/page.tsx index dda592a..b137436 100644 --- a/src/app/oauth/callback/page.tsx +++ b/src/app/oauth/callback/page.tsx @@ -1,16 +1,13 @@ "use client"; import { Suspense, useEffect, useState } from "react"; -import { useSearchParams, useRouter } from "next/navigation"; -import { useSession } from "next-auth/react"; +import { useSearchParams } from "next/navigation"; /** * OAuth callback content - uses useSearchParams which requires Suspense */ function OAuthCallbackContent() { const searchParams = useSearchParams(); - const router = useRouter(); - const { update } = useSession(); const [error, setError] = useState(null); useEffect(() => { @@ -22,13 +19,13 @@ function OAuthCallbackContent() { if (oauthError) { setError(`OAuth error: ${oauthError}`); - setTimeout(() => router.push("/"), 3000); + setTimeout(() => (window.location.href = "/"), 3000); return; } if (!code || !state) { setError("Missing authorization code or state"); - setTimeout(() => router.push("/"), 3000); + setTimeout(() => (window.location.href = "/"), 3000); return; } @@ -39,25 +36,21 @@ function OAuthCallbackContent() { if (!response.ok) { const errorData = await response.json().catch(() => ({ error: "Unknown error" })); setError(errorData.error || "Failed to complete OAuth flow"); - setTimeout(() => router.push("/"), 3000); + setTimeout(() => (window.location.href = "/"), 3000); return; } - // Success - refresh NextAuth session to pick up new tokens - // The update() call triggers a new session fetch, which runs the session callback - // The session callback checks the token store and updates sonarConnected - await update(); // Force NextAuth to refresh the session (triggers session callback server-side) - - // Navigate to home - the session should now be updated - router.push("/"); + // 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"); - setTimeout(() => router.push("/"), 3000); + setTimeout(() => (window.location.href = "/"), 3000); } }; handleCallback(); - }, [searchParams, router, update]); + }, [searchParams]); return (
From af74ff9302e4873013bc57937285db927f0b2d9a Mon Sep 17 00:00:00 2001 From: Will Sewell Date: Tue, 6 Jan 2026 20:12:44 +0000 Subject: [PATCH 09/20] Revert apostrophe change --- src/app/components/sale/PurchaseCard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/components/sale/PurchaseCard.tsx b/src/app/components/sale/PurchaseCard.tsx index 821a87b..84b9edf 100644 --- a/src/app/components/sale/PurchaseCard.tsx +++ b/src/app/components/sale/PurchaseCard.tsx @@ -42,7 +42,7 @@ function readinessConfig( return warningConfig("The connected wallet is not eligible for this sale. Connect a different wallet."); case PrePurchaseFailureReason.MAX_WALLETS_USED: return warningConfig( - "Maximum number of wallets reached — This entity can't use the connected wallet. Use a previous wallet." + "Maximum number of wallets reached — This entity can’t use the connected wallet. Use a previous wallet." ); case PrePurchaseFailureReason.NO_RESERVED_ALLOCATION: return warningConfig( From 17e8cc40266baa00d2b317171b1054ee5026b7d8 Mon Sep 17 00:00:00 2001 From: Will Sewell Date: Thu, 8 Jan 2026 17:44:10 +0000 Subject: [PATCH 10/20] Strip SIWE and NextAuth in favour is custom minimal/dummy sessions --- README.md | 49 +++-- package.json | 2 - pnpm-lock.yaml | 181 +----------------- src/app/Provider.tsx | 4 +- src/app/api/auth/[...nextauth]/route.ts | 156 --------------- 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 | 13 +- src/app/api/auth/sonar/callback/route.ts | 16 +- src/app/api/auth/sonar/disconnect/route.ts | 8 +- .../components/auth/AuthenticationSection.tsx | 44 ++--- src/app/hooks/use-session.tsx | 77 ++++++++ src/app/hooks/use-siwe.ts | 64 ------- src/app/hooks/use-sonar-entities.ts | 16 +- src/app/hooks/use-sonar-entity.ts | 18 +- src/app/hooks/use-sonar-purchase.ts | 20 +- src/app/page.tsx | 103 +++++----- src/lib/pkce-store.ts | 17 +- src/lib/session.ts | 52 +++++ src/lib/siwe.ts | 61 ------ src/lib/sonar-route-handler.ts | 62 ++++-- src/types/next-auth.d.ts | 27 --- 23 files changed, 386 insertions(+), 656 deletions(-) delete mode 100644 src/app/api/auth/[...nextauth]/route.ts create mode 100644 src/app/api/auth/login/route.ts create mode 100644 src/app/api/auth/logout/route.ts create mode 100644 src/app/api/auth/session/route.ts create mode 100644 src/app/hooks/use-session.tsx delete mode 100644 src/app/hooks/use-siwe.ts create mode 100644 src/lib/session.ts delete mode 100644 src/lib/siwe.ts delete mode 100644 src/types/next-auth.d.ts diff --git a/README.md b/README.md index c9b7183..3225488 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,12 @@ pnpm dev - **Pre-purchase checks** — validate eligibility before transactions - **Purchase transactions** — generate permits and submit to the sale contract -## Architecture Overview +## Authentication Architecture + +For demonstration purposes, this example uses a minimal session system: + +- **Login** backend creates a random session ID stored in an HTTP-only cookie (no authentication required) +- **Logout** clears the session and any associated Sonar tokens ### OAuth Flow @@ -70,6 +75,15 @@ The backend handles the complete OAuth flow, storing tokens securely server-side │ │ │ │ ``` +### Token Refresh + +Access tokens expire after a set time. The backend automatically refreshes them: + +- Before each Sonar API call, the backend checks if the token expires within 5 minutes +- If so, it uses `SonarClient.refreshToken()` to get new tokens +- Refreshed tokens are stored back in the token store +- **Concurrent request handling**: If multiple requests need to refresh simultaneously, promise coalescing ensures only one refresh API call is made + ### Proxied API Requests Once authenticated, all Sonar API calls go through the backend, which handles token refresh automatically: @@ -108,19 +122,24 @@ Once authenticated, all Sonar API calls go through the backend, which handles to src/ ├── app/ │ ├── api/ -│ │ ├── auth/ -│ │ │ ├── [...nextauth]/ # NextAuth config & session management -│ │ │ └── sonar/ # Sonar OAuth routes (authorize, callback, disconnect) -│ │ └── sonar/ # Proxied Sonar API routes (entities, pre-purchase, etc.) +│ │ ├── auth/ # Minimal session management +│ │ └── sonar/ # Proxied Sonar API routes (entities, pre-purchase, etc.) │ ├── components/ -│ │ ├── auth/ # Login/logout UI -│ │ ├── entity/ # Entity display components -│ │ ├── registration/ # Pre-sale entity list & eligibility -│ │ └── sale/ # Purchase flow UI -│ ├── hooks/ # React hooks for Sonar API calls -│ ├── oauth/callback/ # OAuth callback page (frontend) -│ ├── config.ts # Environment configuration -│ ├── page.tsx # Main page -│ └── Provider.tsx # App providers setup -└── lib/ # Server-side utilities (token storage, PKCE, Sonar client) +│ │ ├── auth/ # Login/logout UI +│ │ ├── entity/ # Entity display components +│ │ ├── registration/ # Pre-sale entity list & eligibility +│ │ └── sale/ # Purchase flow UI +│ ├── hooks/ +│ │ ├── use-session.tsx # Session state context & hook +│ │ └── use-sonar-*.ts # React hooks for Sonar API calls +│ ├── oauth/callback/ # OAuth callback page (frontend) +│ ├── config.ts # Environment configuration +│ ├── page.tsx # Main page +│ └── Provider.tsx # App providers setup +└── lib/ + ├── session.ts # Cookie-based session management + ├── token-store.ts # In-memory token storage (swap for DB in production) + ├── pkce-store.ts # PKCE verifier storage for OAuth + ├── sonar-client.ts # SonarClient factory + └── sonar-route-handler.ts # Authenticated route handler with token refresh ``` diff --git a/package.json b/package.json index 2c67392..c3a0e45 100644 --- a/package.json +++ b/package.json @@ -15,10 +15,8 @@ "@tanstack/react-query": "^5.90.12", "connectkit": "^1.9.1", "next": "^15.5.7", - "next-auth": "5.0.0-beta.30", "react": "^18.3.1", "react-dom": "^18.3.1", - "siwe": "^2.3.2", "wagmi": "^2.19.5" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b1267c9..fac8360 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,18 +26,12 @@ importers: next: specifier: ^15.5.7 version: 15.5.7(@babel/core@7.28.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - next-auth: - specifier: 5.0.0-beta.30 - version: 5.0.0-beta.30(next@15.5.7(@babel/core@7.28.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) react: specifier: ^18.3.1 version: 18.3.1 react-dom: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) - siwe: - specifier: ^2.3.2 - version: 2.3.2(ethers@6.16.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) wagmi: specifier: ^2.19.5 version: 2.19.5(@tanstack/query-core@5.90.16)(@tanstack/react-query@5.90.16(react@18.3.1))(@types/react@18.3.27)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react@18.3.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.39.0(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76))(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76) @@ -115,9 +109,6 @@ packages: '@acemir/cssom@0.9.23': resolution: {integrity: sha512-2kJ1HxBKzPLbmhZpxBiTZggjtgCwKg1ma5RHShxvd6zgqhDEdEkzpiwe7jLkI2p2BrZvFCXIihdoMkl1H39VnA==} - '@adraffy/ens-normalize@1.10.1': - resolution: {integrity: sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw==} - '@adraffy/ens-normalize@1.11.1': resolution: {integrity: sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==} @@ -134,20 +125,6 @@ packages: '@asamuzakjp/nwsapi@2.3.9': resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} - '@auth/core@0.41.0': - resolution: {integrity: sha512-Wd7mHPQ/8zy6Qj7f4T46vg3aoor8fskJm6g2Zyj064oQ3+p0xNZXAV60ww0hY+MbTesfu29kK14Zk5d5JTazXQ==} - peerDependencies: - '@simplewebauthn/browser': ^9.0.1 - '@simplewebauthn/server': ^9.0.2 - nodemailer: ^6.8.0 - peerDependenciesMeta: - '@simplewebauthn/browser': - optional: true - '@simplewebauthn/server': - optional: true - nodemailer: - optional: true - '@babel/code-frame@7.27.1': resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} @@ -1013,9 +990,6 @@ packages: resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==} engines: {node: ^14.21.3 || >=16} - '@noble/curves@1.2.0': - resolution: {integrity: sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==} - '@noble/curves@1.4.2': resolution: {integrity: sha512-TavHr8qycMChk8UwMld0ZDRvatedkzWfH8IiaeGCfymOP5i0hSCozz9vHOL0nkwk7HRMlFnAiKpS2jrUmSybcw==} @@ -1035,10 +1009,6 @@ packages: resolution: {integrity: sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==} engines: {node: ^14.21.3 || >=16} - '@noble/hashes@1.3.2': - resolution: {integrity: sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==} - engines: {node: '>= 16'} - '@noble/hashes@1.4.0': resolution: {integrity: sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==} engines: {node: '>= 16'} @@ -1071,9 +1041,6 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} - '@panva/hkdf@1.2.1': - resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==} - '@paulmillr/qr@0.2.1': resolution: {integrity: sha512-IHnV6A+zxU7XwmKFinmYjUcwlyK9+xkG3/s9KcQhI9BjQKycrJ1JRO+FbNYPwZiPKW3je/DR0k7w8/gLa5eaxQ==} deprecated: 'The package is now available as "qr": npm install qr' @@ -1525,21 +1492,6 @@ packages: '@solana/web3.js@1.98.4': resolution: {integrity: sha512-vv9lfnvjUsRiq//+j5pBdXig0IQdtzA0BRZ3bXEP4KaIyF1CcaydWqgyzQgfZMNIsWNWmG+AUHwPy4AHOD6gpw==} - '@spruceid/siwe-parser@2.1.2': - resolution: {integrity: sha512-d/r3S1LwJyMaRAKQ0awmo9whfXeE88Qt00vRj91q5uv5ATtWIQEGJ67Yr5eSZw5zp1/fZCXZYuEckt8lSkereQ==} - - '@stablelib/binary@1.0.1': - resolution: {integrity: sha512-ClJWvmL6UBM/wjkvv/7m5VP3GMr9t0osr4yVgLZsLCOz4hGN9gIAFEqnJ0TsSMAN+n840nf2cHZnA5/KFqHC7Q==} - - '@stablelib/int@1.0.1': - resolution: {integrity: sha512-byr69X/sDtDiIjIV6m4roLVWnNNlRGzsvxw+agj8CIEazqWGOQp2dTYgQhtyVXV9wpO6WyXRQUzLV/JRNumT2w==} - - '@stablelib/random@1.0.2': - resolution: {integrity: sha512-rIsE83Xpb7clHPVRlBj8qNe5L8ISQOzjghYQm/dZ7VaM2KHYwMW5adjQjrzTZCchFnNCNhkwtnOBa9HTMJCI8w==} - - '@stablelib/wipe@1.0.1': - resolution: {integrity: sha512-WfqfX/eXGiAd3RJe4VU2snh/ZPwtSjLG4ynQ/vYzvghTh7dHFcI1wl+nrkWG6lGhukOxOsUHfv8dUXr58D0ayg==} - '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} @@ -2075,9 +2027,6 @@ packages: engines: {node: '>=0.4.0'} hasBin: true - aes-js@4.0.0-beta.5: - resolution: {integrity: sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==} - agent-base@7.1.4: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} @@ -2116,9 +2065,6 @@ packages: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} - apg-js@4.4.0: - resolution: {integrity: sha512-fefmXFknJmtgtNEXfPwZKYkMFX4Fyeyz+fNF6JWp87biGOPslJbCBVU158zvKRZfHBKnJDy8CMM40oLFGkXT8Q==} - argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -2792,10 +2738,6 @@ packages: ethereum-cryptography@2.2.1: resolution: {integrity: sha512-r/W8lkHSiTLxUxW8Rf3u4HGB0xQweG2RyETjywylKZSzLWoWAijRz8WCuOtJ6wah+avllXBqZuk29HCCvhEIRg==} - ethers@6.16.0: - resolution: {integrity: sha512-U1wulmetNymijEhpSEQ7Ct/P/Jw9/e7R1j5XIbPRydgV2DjLVMsULDlNksq3RQnFgKoLlZf88ijYtWEXcPa07A==} - engines: {node: '>=14.0.0'} - eventemitter2@6.4.9: resolution: {integrity: sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==} @@ -3560,22 +3502,6 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - next-auth@5.0.0-beta.30: - resolution: {integrity: sha512-+c51gquM3F6nMVmoAusRJ7RIoY0K4Ts9HCCwyy/BRoe4mp3msZpOzYMyb5LAYc1wSo74PMQkGDcaghIO7W6Xjg==} - peerDependencies: - '@simplewebauthn/browser': ^9.0.1 - '@simplewebauthn/server': ^9.0.2 - next: ^14.0.0-0 || ^15.0.0 || ^16.0.0 - nodemailer: ^7.0.7 - react: ^18.2.0 || ^19.0.0 - peerDependenciesMeta: - '@simplewebauthn/browser': - optional: true - '@simplewebauthn/server': - optional: true - nodemailer: - optional: true - next@15.5.7: resolution: {integrity: sha512-+t2/0jIJ48kUpGKkdlhgkv+zPTEOoXyr60qXe68eB/pl3CMJaLeIGjzp5D6Oqt25hCBiBTt8wEeeAzfJvUKnPQ==} engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} @@ -3626,9 +3552,6 @@ packages: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} - oauth4webapi@3.8.3: - resolution: {integrity: sha512-pQ5BsX3QRTgnt5HxgHwgunIRaDXBdkT23tf8dfzmtTIL2LTpdmxgbpbBm0VgFWAIDlezQvQCTgnVIUmHupXHxw==} - obj-multiplex@1.0.0: resolution: {integrity: sha512-0GNJAOsHoBHeNTvl5Vt6IWnpUEcc3uSRxzBri7EDyIcMgYvnY2JL2qdeV5zTMjWQX5OHcD5amcW2HFfDh0gjIA==} @@ -3886,17 +3809,9 @@ packages: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} - preact-render-to-string@6.5.11: - resolution: {integrity: sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==} - peerDependencies: - preact: '>=10' - preact@10.24.2: resolution: {integrity: sha512-1cSoF0aCC8uaARATfrlz4VCBqE8LwZwRfLgkxJOQwAlQt6ayTmi0D9OF7nXid1POI5SZidFuG9CnlXbDfLqY/Q==} - preact@10.24.3: - resolution: {integrity: sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==} - preact@10.28.0: resolution: {integrity: sha512-rytDAoiXr3+t6OIP3WGlDd0ouCUG1iCWzkcY3++Nreuoi17y6T5i/zRhe6uYfoVcxq6YU+sBtJouuRDsq8vvqA==} @@ -4154,11 +4069,6 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} - siwe@2.3.2: - resolution: {integrity: sha512-aSf+6+Latyttbj5nMu6GF3doMfv2UYj83hhwZgUF20ky6fTS83uVhkQABdIVnEuS8y1bBdk7p6ltb9SmlhTTlA==} - peerDependencies: - ethers: ^5.6.8 || ^6.0.8 - socket.io-client@4.8.1: resolution: {integrity: sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==} engines: {node: '>=10.0.0'} @@ -4404,9 +4314,6 @@ packages: tslib@1.14.1: resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} - tslib@2.7.0: - resolution: {integrity: sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==} - tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -4578,9 +4485,6 @@ packages: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} hasBin: true - valid-url@1.0.9: - resolution: {integrity: sha512-QQDsV8OnSf5Uc30CKSwG9lnhMPe6exHtTXLRYX8uMwKENy640pU+2BgBL0LRbDh/eYRahNCS7aewCx0wf3NYVA==} - valtio@1.13.2: resolution: {integrity: sha512-Qik0o+DSy741TmkqmRfjq+0xpZBXi/Y6+fXZLn0xNF1z/waFMbE3rkivv5Zcf9RrMUp6zswf2J7sbh2KBlba5A==} engines: {node: '>=12.20.0'} @@ -4900,8 +4804,6 @@ snapshots: '@acemir/cssom@0.9.23': {} - '@adraffy/ens-normalize@1.10.1': {} - '@adraffy/ens-normalize@1.11.1': {} '@alloc/quick-lru@5.2.0': {} @@ -4924,14 +4826,6 @@ snapshots: '@asamuzakjp/nwsapi@2.3.9': {} - '@auth/core@0.41.0': - dependencies: - '@panva/hkdf': 1.2.1 - jose: 6.1.1 - oauth4webapi: 3.8.3 - preact: 10.24.3 - preact-render-to-string: 6.5.11(preact@10.24.3) - '@babel/code-frame@7.27.1': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -5818,10 +5712,6 @@ snapshots: '@noble/ciphers@1.3.0': {} - '@noble/curves@1.2.0': - dependencies: - '@noble/hashes': 1.3.2 - '@noble/curves@1.4.2': dependencies: '@noble/hashes': 1.4.0 @@ -5842,8 +5732,6 @@ snapshots: dependencies: '@noble/hashes': 1.8.0 - '@noble/hashes@1.3.2': {} - '@noble/hashes@1.4.0': {} '@noble/hashes@1.7.0': {} @@ -5866,8 +5754,6 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} - '@panva/hkdf@1.2.1': {} - '@paulmillr/qr@0.2.1': {} '@pkgjs/parseargs@0.11.0': @@ -6670,26 +6556,6 @@ snapshots: - typescript - utf-8-validate - '@spruceid/siwe-parser@2.1.2': - dependencies: - '@noble/hashes': 1.8.0 - apg-js: 4.4.0 - uri-js: 4.4.1 - valid-url: 1.0.9 - - '@stablelib/binary@1.0.1': - dependencies: - '@stablelib/int': 1.0.1 - - '@stablelib/int@1.0.1': {} - - '@stablelib/random@1.0.2': - dependencies: - '@stablelib/binary': 1.0.1 - '@stablelib/wipe': 1.0.1 - - '@stablelib/wipe@1.0.1': {} - '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 @@ -6829,6 +6695,7 @@ snapshots: '@types/node@22.7.5': dependencies: undici-types: 6.19.8 + optional: true '@types/prop-types@15.7.15': {} @@ -7686,8 +7553,6 @@ snapshots: acorn@8.15.0: {} - aes-js@4.0.0-beta.5: {} - agent-base@7.1.4: {} agentkeepalive@4.6.0: @@ -7720,8 +7585,6 @@ snapshots: normalize-path: 3.0.0 picomatch: 2.3.1 - apg-js@4.4.0: {} - argparse@2.0.1: {} aria-query@5.3.0: @@ -8626,19 +8489,6 @@ snapshots: '@scure/bip32': 1.4.0 '@scure/bip39': 1.3.0 - ethers@6.16.0(bufferutil@4.0.9)(utf-8-validate@5.0.10): - dependencies: - '@adraffy/ens-normalize': 1.10.1 - '@noble/curves': 1.2.0 - '@noble/hashes': 1.3.2 - '@types/node': 22.7.5 - aes-js: 4.0.0-beta.5 - tslib: 2.7.0 - ws: 8.17.1(bufferutil@4.0.9)(utf-8-validate@5.0.10) - transitivePeerDependencies: - - bufferutil - - utf-8-validate - eventemitter2@6.4.9: {} eventemitter3@5.0.1: {} @@ -9384,12 +9234,6 @@ snapshots: natural-compare@1.4.0: {} - next-auth@5.0.0-beta.30(next@15.5.7(@babel/core@7.28.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1): - dependencies: - '@auth/core': 0.41.0 - next: 15.5.7(@babel/core@7.28.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react: 18.3.1 - next@15.5.7(@babel/core@7.28.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@next/env': 15.5.7 @@ -9429,8 +9273,6 @@ snapshots: normalize-path@3.0.0: {} - oauth4webapi@3.8.3: {} - obj-multiplex@1.0.0: dependencies: end-of-stream: 1.4.5 @@ -9708,14 +9550,8 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 - preact-render-to-string@6.5.11(preact@10.24.3): - dependencies: - preact: 10.24.3 - preact@10.24.2: {} - preact@10.24.3: {} - preact@10.28.0: {} prelude-ls@1.2.1: {} @@ -10052,14 +9888,6 @@ snapshots: signal-exit@4.1.0: {} - siwe@2.3.2(ethers@6.16.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)): - dependencies: - '@spruceid/siwe-parser': 2.1.2 - '@stablelib/random': 1.0.2 - ethers: 6.16.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) - uri-js: 4.4.1 - valid-url: 1.0.9 - socket.io-client@4.8.1(bufferutil@4.0.9)(utf-8-validate@5.0.10): dependencies: '@socket.io/component-emitter': 3.1.2 @@ -10325,8 +10153,6 @@ snapshots: tslib@1.14.1: {} - tslib@2.7.0: {} - tslib@2.8.1: {} tsup@8.5.1(jiti@2.6.1)(postcss@8.5.6)(typescript@5.9.3): @@ -10411,7 +10237,8 @@ snapshots: uncrypto@0.1.3: {} - undici-types@6.19.8: {} + undici-types@6.19.8: + optional: true undici-types@6.21.0: {} @@ -10490,8 +10317,6 @@ snapshots: uuid@9.0.1: {} - valid-url@1.0.9: {} - valtio@1.13.2(@types/react@18.3.27)(react@18.3.1): dependencies: derive-valtio: 0.1.0(valtio@1.13.2(@types/react@18.3.27)(react@18.3.1)) diff --git a/src/app/Provider.tsx b/src/app/Provider.tsx index 23fd33b..5e566cf 100644 --- a/src/app/Provider.tsx +++ b/src/app/Provider.tsx @@ -3,8 +3,8 @@ import { WagmiProvider, createConfig, http } from "wagmi"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { ConnectKitProvider, getDefaultConfig } from "connectkit"; -import { SessionProvider } from "next-auth/react"; import { sepolia } from "wagmi/chains"; +import { SessionProvider } from "./hooks/use-session"; const config = createConfig( getDefaultConfig({ @@ -18,7 +18,7 @@ const config = createConfig( // Required App Info appName: "Sonar Next.js example app", - appDescription: "Next.js app showing how to integrate with the Sonar API via backend OAuth and NextAuth.", + appDescription: "Next.js app showing how to integrate with the Sonar API via backend OAuth.", }) ); diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts deleted file mode 100644 index 9b29e4b..0000000 --- a/src/app/api/auth/[...nextauth]/route.ts +++ /dev/null @@ -1,156 +0,0 @@ -import NextAuth, { Account, User, Session } from "next-auth"; -import { JWT } from "next-auth/jwt"; -import CredentialsProvider from "next-auth/providers/credentials"; -import { getTokenStore, SonarTokens } from "@/lib/token-store"; -import { siwe } from "@/lib/siwe"; - -// Validate required environment variables -if (process.env.NODE_ENV === "production" && !process.env.NEXTAUTH_SECRET) { - throw new Error("NEXTAUTH_SECRET is required in production"); -} - -/** - * Custom Sonar OAuth provider configuration - * Note: NextAuth v5 uses a different provider structure - * We'll handle OAuth manually via API routes and use NextAuth for session management - */ -const sonarOAuthConfig = { - authorizationUrl: `${process.env.NEXT_PUBLIC_ECHO_FRONTEND_URL ?? "https://app.echo.xyz"}/oauth/authorize`, - tokenUrl: `${process.env.NEXT_PUBLIC_ECHO_API_URL ?? "https://api.echo.xyz"}/oauth/token`, - clientId: process.env.NEXT_PUBLIC_OAUTH_CLIENT_UUID ?? "", - clientSecret: process.env.OAUTH_CLIENT_SECRET ?? "", - redirectUri: process.env.NEXT_PUBLIC_OAUTH_CLIENT_REDIRECT_URI ?? "", -}; - -/** - * Refresh Sonar access token using refresh token - */ -export async function refreshSonarToken(refreshToken: string): Promise { - const tokenEndpoint = sonarOAuthConfig.tokenUrl; - const response = await fetch(tokenEndpoint, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body: new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: refreshToken, - client_id: sonarOAuthConfig.clientId, - client_secret: sonarOAuthConfig.clientSecret, - }), - }); - - if (!response.ok) { - throw new Error("Failed to refresh token"); - } - - const data = await response.json(); - const expiresAt = Math.floor(Date.now() / 1000) + (data.expires_in || 3600); - - return { - accessToken: data.access_token, - refreshToken: data.refresh_token || refreshToken, - expiresAt, - }; -} - -const authOptions = { - providers: [ - // SIWE provider for wallet-based authentication - CredentialsProvider({ - id: "siwe", - name: "Sign in with Ethereum", - credentials: { - message: { label: "Message", type: "text" }, - signature: { label: "Signature", type: "text" }, - }, - async authorize(credentials) { - if (!credentials?.message || !credentials?.signature) { - return null; - } - - try { - const address = await siwe.verify(credentials.message as string, credentials.signature as string); - if (!address) { - return null; - } - - return { - id: address.toLowerCase(), - name: address, - }; - } catch { - return null; - } - }, - }), - ], - session: { - strategy: "jwt" as const, - }, - callbacks: { - async jwt({ token, account, user }: { token: JWT; account?: Account | null; user?: User }) { - // Initial sign in - if (account && user) { - if (account.provider === "siwe") { - // SIWE sign in - set the user ID (wallet address) - token.sub = user.id; - } - } - - // Check if Sonar token needs refresh (tokens are stored separately, but we check expiry here) - if (token.sub) { - const storedTokens = getTokenStore().getTokens(token.sub); - if (storedTokens) { - const now = Math.floor(Date.now() / 1000); - // Refresh if token expires in less than 5 minutes - if (storedTokens.expiresAt - now < 300) { - try { - const newTokens = await refreshSonarToken(storedTokens.refreshToken); - getTokenStore().setTokens(token.sub, newTokens); - } catch { - // Refresh failed - clear tokens and force re-auth - getTokenStore().clearTokens(token.sub); - } - } - } - } - - return token; - }, - async session({ session, token }: { session: Session; token: JWT }) { - // Add Sonar connection status to session - if (token.sub) { - const storedTokens = getTokenStore().getTokens(token.sub); - session.user = { - ...session.user, - id: token.sub, - sonarConnected: !!storedTokens, - }; - } - return session; - }, - }, - pages: { - signIn: "/", - }, - cookies: { - sessionToken: { - name: `next-auth.session-token`, - options: { - httpOnly: true, - sameSite: "lax" as const, - path: "/", - secure: process.env.NODE_ENV === "production", - }, - }, - }, - secret: process.env.NEXTAUTH_SECRET, -}; - -// NextAuth v5 beta returns an object with handlers and auth function -const auth = NextAuth(authOptions); - -export const { GET, POST } = auth.handlers; -export const { auth: getAuth } = auth; -export { authOptions }; diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts new file mode 100644 index 0000000..4d20d91 --- /dev/null +++ b/src/app/api/auth/login/route.ts @@ -0,0 +1,15 @@ +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, + sessionId: session.id, + }); +} diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts new file mode 100644 index 0000000..7c5d8b6 --- /dev/null +++ b/src/app/api/auth/logout/route.ts @@ -0,0 +1,12 @@ +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 new file mode 100644 index 0000000..41e05d5 --- /dev/null +++ b/src/app/api/auth/session/route.ts @@ -0,0 +1,25 @@ +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.id); + + 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 index a574090..7a7a09a 100644 --- a/src/app/api/auth/sonar/authorize/route.ts +++ b/src/app/api/auth/sonar/authorize/route.ts @@ -1,24 +1,23 @@ import { NextResponse } from "next/server"; -import { getAuth } from "../../[...nextauth]/route"; -import { generatePKCEParams } from "@echoxyz/sonar-core"; -import { buildAuthorizationUrl } from "@echoxyz/sonar-core"; +import { getSession } from "@/lib/session"; +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 getAuth(); + const session = await getSession(); - if (!session?.user?.id) { + 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.user.id, codeVerifier); + // Store code verifier and session ID linked to state token (will be retrieved in callback) + await setPKCEVerifier(state, session.id, codeVerifier); // Build authorization URL with PKCE const authorizationUrl = buildAuthorizationUrl({ diff --git a/src/app/api/auth/sonar/callback/route.ts b/src/app/api/auth/sonar/callback/route.ts index bba6a8b..d316b05 100644 --- a/src/app/api/auth/sonar/callback/route.ts +++ b/src/app/api/auth/sonar/callback/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { getAuth } from "../../[...nextauth]/route"; +import { getSession } from "@/lib/session"; import { getTokenStore, SonarTokens } from "@/lib/token-store"; import { getPKCEVerifier, clearPKCEVerifier } from "@/lib/pkce-store"; import { createSonarClient } from "@/lib/sonar-client"; @@ -28,13 +28,13 @@ export async function GET(request: NextRequest) { } // Verify session exists - const session = await getAuth(); - if (!session?.user?.id) { + const session = await getSession(); + if (!session) { return NextResponse.json({ error: "Unauthorized", details: "No active session" }, { status: 401 }); } try { - // Retrieve code verifier and user ID from cookie store using state token + // Retrieve code verifier and session ID from cookie store using state token const stateData = await getPKCEVerifier(state); if (!stateData) { return NextResponse.json( @@ -44,7 +44,7 @@ export async function GET(request: NextRequest) { } // Verify the state token belongs to the current session - if (stateData.userId !== session.user.id) { + if (stateData.userId !== session.id) { return NextResponse.json( { error: "Invalid session", details: "State token does not match current session" }, { status: 401 } @@ -54,7 +54,7 @@ export async function GET(request: NextRequest) { const { codeVerifier } = stateData; // Create a temporary client to exchange the authorization code - const client = createSonarClient(session.user.id); + const client = createSonarClient(session.id); const tokenData = await client.exchangeAuthorizationCode({ code, codeVerifier, @@ -70,14 +70,12 @@ export async function GET(request: NextRequest) { }; // Store tokens in token store - getTokenStore().setTokens(session.user.id, sonarTokens); + getTokenStore().setTokens(session.id, sonarTokens); // Clear the code verifier (no longer needed) await clearPKCEVerifier(state); // Return success - frontend will navigate to home - // The page load will naturally trigger session refresh via NextAuth - // The session callback checks the token store on every request return NextResponse.json({ success: true }); } catch (error) { // Return proper error response with status code diff --git a/src/app/api/auth/sonar/disconnect/route.ts b/src/app/api/auth/sonar/disconnect/route.ts index 9bae04a..f149fed 100644 --- a/src/app/api/auth/sonar/disconnect/route.ts +++ b/src/app/api/auth/sonar/disconnect/route.ts @@ -1,18 +1,18 @@ import { NextResponse } from "next/server"; -import { getAuth } from "../../[...nextauth]/route"; +import { getSession } from "@/lib/session"; import { getTokenStore } from "@/lib/token-store"; /** * Disconnect Sonar account (remove stored tokens) */ export async function POST() { - const session = await getAuth(); + const session = await getSession(); - if (!session?.user?.id) { + if (!session) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - getTokenStore().clearTokens(session.user.id); + getTokenStore().clearTokens(session.id); return NextResponse.json({ success: true }); } diff --git a/src/app/components/auth/AuthenticationSection.tsx b/src/app/components/auth/AuthenticationSection.tsx index 1419784..a36b19a 100644 --- a/src/app/components/auth/AuthenticationSection.tsx +++ b/src/app/components/auth/AuthenticationSection.tsx @@ -1,26 +1,9 @@ "use client"; -import { ConnectKitButton } from "connectkit"; -import { signOut, useSession } from "next-auth/react"; -import { useAccount } from "wagmi"; -import { useSIWE } from "../../hooks/use-siwe"; +import { useSession } from "@/app/hooks/use-session"; export function AuthenticationSection() { - const { data: session } = useSession(); - const { address } = useAccount(); - const { signInWithEthereum } = useSIWE(); - - const sonarConnected = session?.user?.sonarConnected ?? false; - const walletConnected = !!address; - const appAuthenticated = !!session; - - const handleSignIn = async () => { - try { - await signInWithEthereum(); - } catch (error) { - console.error("Failed to sign in:", error); - } - }; + const { authenticated, sonarConnected, loading, login, logout } = useSession(); const handleConnectSonar = () => { window.location.href = "/api/auth/sonar/authorize"; @@ -35,27 +18,24 @@ export function AuthenticationSection() { } }; - if (!walletConnected) { + if (loading) { return (
-
-

Connect your wallet to continue.

- -
+

Loading...

); } - if (!appAuthenticated) { + if (!authenticated) { return (
-

Sign in with your wallet to continue.

+

Login to continue.

@@ -68,8 +48,8 @@ export function AuthenticationSection() {

Connect your Sonar account to check your eligibility status.

-
-
diff --git a/src/app/hooks/use-session.tsx b/src/app/hooks/use-session.tsx new file mode 100644 index 0000000..15190e4 --- /dev/null +++ b/src/app/hooks/use-session.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from "react"; + +interface SessionState { + authenticated: boolean; + sonarConnected: boolean; + loading: boolean; +} + +interface SessionContextValue extends SessionState { + login: () => Promise; + logout: () => Promise; + refreshSession: () => Promise; +} + +const SessionContext = createContext(null); + +export function SessionProvider({ children }: { children: ReactNode }) { + const [state, setState] = useState({ + authenticated: false, + sonarConnected: false, + loading: true, + }); + + const refreshSession = useCallback(async () => { + try { + const response = await fetch("/api/auth/session"); + const data = await response.json(); + setState({ + authenticated: data.authenticated, + sonarConnected: data.sonarConnected, + loading: false, + }); + } catch { + setState({ + authenticated: false, + sonarConnected: false, + loading: false, + }); + } + }, []); + + useEffect(() => { + refreshSession(); + }, [refreshSession]); + + const login = useCallback(async () => { + try { + await fetch("/api/auth/login", { method: "POST" }); + await refreshSession(); + } catch (error) { + console.error("Login failed:", error); + } + }, [refreshSession]); + + const logout = useCallback(async () => { + try { + await fetch("/api/auth/logout", { method: "POST" }); + await refreshSession(); + } catch (error) { + console.error("Logout failed:", error); + } + }, [refreshSession]); + + return ( + {children} + ); +} + +export function useSession() { + const context = useContext(SessionContext); + if (!context) { + throw new Error("useSession must be used within a SessionProvider"); + } + return context; +} diff --git a/src/app/hooks/use-siwe.ts b/src/app/hooks/use-siwe.ts deleted file mode 100644 index 532f9e6..0000000 --- a/src/app/hooks/use-siwe.ts +++ /dev/null @@ -1,64 +0,0 @@ -"use client"; - -import { useSignMessage } from "wagmi"; -import { signIn } from "next-auth/react"; -import { useAccount } from "wagmi"; - -/** - * Hook for Sign In With Ethereum - */ -export function useSIWE() { - const { address } = useAccount(); - const { signMessageAsync } = useSignMessage(); - - const signInWithEthereum = async () => { - if (!address) { - throw new Error("Wallet not connected"); - } - - // Create SIWE message - const domain = typeof window !== "undefined" ? window.location.host : "localhost"; - const origin = typeof window !== "undefined" ? window.location.origin : "http://localhost:3000"; - const statement = "Sign in with Ethereum to the app."; - // Generate alphanumeric nonce (siwe requires no hyphens) - const nonce = crypto.randomUUID().replace(/-/g, ""); - const expirationTime = new Date(Date.now() + 1000 * 60 * 60 * 24).toISOString(); // 24 hours - - const message = `${domain} wants you to sign in with your Ethereum account: -${address} - -${statement} - -URI: ${origin} -Version: 1 -Chain ID: 11155111 -Nonce: ${nonce} -Issued At: ${new Date().toISOString()} -Expiration Time: ${expirationTime}`; - - try { - // Sign the message - const signature = await signMessageAsync({ message }); - - // Send to NextAuth - const result = await signIn("siwe", { - message, - signature, - redirect: false, - }); - - if (result?.error) { - throw new Error(result.error); - } - - return result; - } catch (error) { - console.error("SIWE error:", error); - throw error; - } - }; - - return { - signInWithEthereum, - }; -} diff --git a/src/app/hooks/use-sonar-entities.ts b/src/app/hooks/use-sonar-entities.ts index c0ea2b8..f77a369 100644 --- a/src/app/hooks/use-sonar-entities.ts +++ b/src/app/hooks/use-sonar-entities.ts @@ -1,24 +1,21 @@ "use client"; import { useState, useEffect } from "react"; -import { useSession } from "next-auth/react"; +import { useSession } from "./use-session"; import { EntityDetails } from "@echoxyz/sonar-core"; import { saleUUID } from "../config"; /** * Hook to fetch all Sonar entities for the authenticated user - * Replaces useSonarEntities from sonar-react */ export function useSonarEntities() { - const { data: session } = useSession(); + const { authenticated, sonarConnected, refreshSession } = useSession(); const [loading, setLoading] = useState(false); const [entities, setEntities] = useState(undefined); const [error, setError] = useState(undefined); - const sonarConnected = session?.user?.sonarConnected ?? false; - useEffect(() => { - if (!session?.user?.id || !sonarConnected) { + if (!authenticated || !sonarConnected) { setEntities(undefined); setError(undefined); setLoading(false); @@ -41,6 +38,11 @@ export function useSonarEntities() { }); if (!response.ok) { + // If server says not connected (e.g., tokens lost after hot reload), refresh session state + if (response.status === 401) { + await refreshSession(); + return; + } const errorData = await response.json(); throw new Error(errorData.error || "Failed to fetch entities"); } @@ -56,7 +58,7 @@ export function useSonarEntities() { }; fetchEntities(); - }, [session?.user?.id, sonarConnected]); + }, [authenticated, sonarConnected, refreshSession]); return { loading, diff --git a/src/app/hooks/use-sonar-entity.ts b/src/app/hooks/use-sonar-entity.ts index 15cd52a..ac95201 100644 --- a/src/app/hooks/use-sonar-entity.ts +++ b/src/app/hooks/use-sonar-entity.ts @@ -1,25 +1,22 @@ "use client"; import { useState, useEffect } from "react"; -import { useSession } from "next-auth/react"; +import { useSession } from "./use-session"; import { EntityDetails } from "@echoxyz/sonar-core"; import { saleUUID } from "../config"; /** - * Hook to fetch Sonar entity details - * Replaces useSonarEntity from sonar-react + * Hook to fetch Sonar entity details for a specific wallet */ export function useSonarEntity(walletAddress?: string) { - const { data: session } = useSession(); + const { authenticated, sonarConnected, refreshSession } = useSession(); const [loading, setLoading] = useState(false); const [entity, setEntity] = useState(undefined); const [error, setError] = useState(undefined); - const sonarConnected = session?.user?.sonarConnected ?? false; - useEffect(() => { // Only fetch if user is authenticated AND connected to Sonar - if (!session?.user?.id || !sonarConnected || !walletAddress) { + if (!authenticated || !sonarConnected || !walletAddress) { setEntity(undefined); setError(undefined); setLoading(false); @@ -48,6 +45,11 @@ export function useSonarEntity(walletAddress?: string) { setLoading(false); return; } + // If server says not connected (e.g., tokens lost after hot reload), refresh session state + if (response.status === 401) { + await refreshSession(); + return; + } const errorData = await response.json(); throw new Error(errorData.error || "Failed to fetch entity"); } @@ -63,7 +65,7 @@ export function useSonarEntity(walletAddress?: string) { }; fetchEntity(); - }, [session?.user?.id, sonarConnected, walletAddress]); + }, [authenticated, sonarConnected, walletAddress, refreshSession]); return { loading, diff --git a/src/app/hooks/use-sonar-purchase.ts b/src/app/hooks/use-sonar-purchase.ts index 379634a..01e9fc8 100644 --- a/src/app/hooks/use-sonar-purchase.ts +++ b/src/app/hooks/use-sonar-purchase.ts @@ -1,7 +1,7 @@ "use client"; import { EntityID, GeneratePurchasePermitResponse, PrePurchaseFailureReason } from "@echoxyz/sonar-core"; -import { useSession } from "next-auth/react"; +import { useSession } from "./use-session"; import { useCallback, useEffect, useState } from "react"; export type UseSonarPurchaseResultLoading = { @@ -37,21 +37,20 @@ export type UseSonarPurchaseResult = /** * Hook for Sonar purchase flow - * Replaces useSonarPurchase from sonar-react */ export function useSonarPurchase(args: { saleUUID: string; entityID: EntityID; walletAddress: string; }): UseSonarPurchaseResult { - const { data: session } = useSession(); + const { authenticated, sonarConnected, refreshSession } = useSession(); const [state, setState] = useState({ loading: true, readyToPurchase: false, }); const generatePurchasePermit = useCallback(async (): Promise => { - if (!session?.user?.id) { + if (!authenticated) { throw new Error("Not authenticated"); } @@ -74,13 +73,11 @@ export function useSonarPurchase(args: { const data = await response.json(); return data; - }, [session?.user?.id, args.saleUUID, args.entityID, args.walletAddress]); - - const sonarConnected = session?.user?.sonarConnected ?? false; + }, [authenticated, args.saleUUID, args.entityID, args.walletAddress]); useEffect(() => { // Only fetch if user is authenticated AND connected to Sonar - if (!session?.user?.id || !sonarConnected) { + if (!authenticated || !sonarConnected) { setState({ loading: false, readyToPurchase: false, @@ -109,6 +106,11 @@ export function useSonarPurchase(args: { }); if (!response.ok) { + // If server says not connected (e.g., tokens lost after hot reload), refresh session state + if (response.status === 401) { + await refreshSession(); + return; + } const errorData = await response.json(); throw new Error(errorData.error || "Failed to check purchase eligibility"); } @@ -141,7 +143,7 @@ export function useSonarPurchase(args: { }; fetchPurchaseData(); - }, [session?.user?.id, sonarConnected, args.saleUUID, args.entityID, args.walletAddress, generatePurchasePermit]); + }, [authenticated, sonarConnected, args.saleUUID, args.entityID, args.walletAddress, generatePurchasePermit, refreshSession]); return state; } diff --git a/src/app/page.tsx b/src/app/page.tsx index 1cefd01..406989e 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,7 +1,6 @@ "use client"; import { useState, useEffect } from "react"; -import { useSession } from "next-auth/react"; import { saleUUID, sonarHomeURL } from "./config"; import { useAccount } from "wagmi"; import { SaleEligibility } from "@echoxyz/sonar-core"; @@ -10,17 +9,17 @@ import { NotEligibleMessage } from "./components/sale/NotEligibleMessage"; import { AuthenticationSection } from "./components/auth/AuthenticationSection"; import { useSonarEntity } from "./hooks/use-sonar-entity"; import { useSonarEntities } from "./hooks/use-sonar-entities"; +import { useSession } from "./hooks/use-session"; import PurchaseCard from "./components/sale/PurchaseCard"; import { EntitiesList } from "./components/registration/EntitiesList"; import { EligibilityResults } from "./components/registration/EligibilityResults"; +import { ConnectKitButton } from "connectkit"; export default function Home() { const [saleIsLive, setSaleIsLive] = useState(false); - const { data: session } = useSession(); + const { sonarConnected } = useSession(); const { address } = useAccount(); - const sonarConnected = session?.user?.sonarConnected ?? false; - // Load sale state from localStorage useEffect(() => { const stored = localStorage.getItem("sale_is_live"); @@ -47,6 +46,15 @@ export default function Home() { const isEligible = entity && entity.SaleEligibility === SaleEligibility.ELIGIBLE; const EntitySection = () => { + if (!address) { + return ( +
+

Connection Required

+

Connect your wallet to continue with your purchase.

+
+ ); + } + if (entityLoading) { return (
@@ -153,62 +161,51 @@ export default function Home() { )}
- {/* Registration Phase */} - {!saleIsLive && ( -
- +
+ + + {/* Registration Phase */} + {sonarConnected && !saleIsLive && ( +
+

Check Your Eligibility

- {sonarConnected && ( -
-

Check Your Eligibility

+ - + )} +
+ )} - {!entitiesLoading && !entitiesError && entities && ( - - )} -
- )} -
- )} - - {/* Sale Phase */} - {saleIsLive && ( -
- {/* Connection Buttons */} - - - {/* Entity Information */} - {address && sonarConnected && ( -
-

Your Entity Information

- -
- )} - - {/* Purchase Card */} - {isEligible && address && ( -
-

Make a Purchase

- -
- )} - - {/* Not Eligible Message */} - {entity && !isEligible && } -
- )} + {/* Sale Phase */} + {sonarConnected && saleIsLive && ( +
+

Your Entity Information

+ + + + {isEligible && address && ( +
+

Make a Purchase

+ +
+ )} + + {entity && !isEligible && } +
+ )} +
diff --git a/src/lib/pkce-store.ts b/src/lib/pkce-store.ts index 8740ba9..98d9bfa 100644 --- a/src/lib/pkce-store.ts +++ b/src/lib/pkce-store.ts @@ -1,6 +1,6 @@ /** * Temporary store for PKCE code verifiers and OAuth state tokens - * Uses encrypted cookies to persist across serverless function invocations + * Uses cookies to persist across serverless function invocations */ import { cookies } from "next/headers"; @@ -8,20 +8,20 @@ const PKCE_COOKIE_PREFIX = "sonar_pkce_"; const PKCE_COOKIE_TTL_SEC = 10 * 60; // 10 minutes interface StateEntry { - userId: string; + sessionId: string; codeVerifier: string; expiresAt: number; } /** - * Store PKCE verifier and user ID linked to a state token in a cookie + * Store PKCE verifier and session ID linked to a state token in a cookie */ -export async function setPKCEVerifier(state: string, userId: string, codeVerifier: string): Promise { +export async function setPKCEVerifier(state: string, sessionId: string, codeVerifier: string): Promise { const cookieStore = await cookies(); const expiresAt = Date.now() + PKCE_COOKIE_TTL_SEC * 1000; const entry: StateEntry = { - userId, + sessionId, codeVerifier, expiresAt, }; @@ -36,7 +36,7 @@ export async function setPKCEVerifier(state: string, userId: string, codeVerifie } /** - * Get PKCE verifier and user ID for a state token from cookie + * Get PKCE verifier and session ID for a state token from cookie */ export async function getPKCEVerifier(state: string): Promise<{ userId: string; codeVerifier: string } | null> { const cookieStore = await cookies(); @@ -57,11 +57,10 @@ export async function getPKCEVerifier(state: string): Promise<{ userId: string; } return { - userId: entry.userId, + userId: entry.sessionId, codeVerifier: entry.codeVerifier, }; - } catch (error) { - console.error("PKCEStore.getPKCEVerifier - parse error", { state, error }); + } catch { cookieStore.delete(cookieName); return null; } diff --git a/src/lib/session.ts b/src/lib/session.ts new file mode 100644 index 0000000..be1cbd3 --- /dev/null +++ b/src/lib/session.ts @@ -0,0 +1,52 @@ +/** + * Simple session management using cookies. + * Sessions are just random UUIDs - no authentication required. + */ +import { cookies } from "next/headers"; +import { getTokenStore } from "./token-store"; + +const SESSION_COOKIE_NAME = "session_id"; +const SESSION_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; // 7 days + +export interface Session { + id: string; +} + +export async function getSession(): Promise { + const cookieStore = await cookies(); + const sessionId = cookieStore.get(SESSION_COOKIE_NAME)?.value; + + if (!sessionId) { + return null; + } + + return { id: sessionId }; +} + +export async function createSession(): Promise { + const cookieStore = await cookies(); + const sessionId = crypto.randomUUID(); + + cookieStore.set(SESSION_COOKIE_NAME, sessionId, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + maxAge: SESSION_COOKIE_MAX_AGE, + path: "/", + }); + + return { id: sessionId }; +} + +export async function destroySession(): Promise { + const cookieStore = await cookies(); + const sessionId = cookieStore.get(SESSION_COOKIE_NAME)?.value; + + // Clear Sonar tokens if session exists + if (sessionId) { + getTokenStore().clearTokens(sessionId); + } + + // Delete the session cookie + cookieStore.delete(SESSION_COOKIE_NAME); +} diff --git a/src/lib/siwe.ts b/src/lib/siwe.ts deleted file mode 100644 index d0f26d4..0000000 --- a/src/lib/siwe.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** - * SIWE (Sign In With Ethereum) verification utilities - * Uses the siwe library for message parsing and signature verification - */ - -import { SiweMessage } from "siwe"; - -/** - * Verify SIWE message signature - * @param message - The SIWE message string - * @param signature - The signature string (hex format with 0x prefix) - * @returns The verified Ethereum address if valid, null otherwise - */ -export const siwe = { - async verify(message: string, signature: string): Promise { - try { - // Parse the SIWE message - const siweMessage = new SiweMessage(message); - - // Verify domain matches expected domain (if configured) - const expectedDomain = process.env.NEXT_PUBLIC_SIWE_DOMAIN; - if (expectedDomain) { - // Allow both with and without port for localhost - const normalizedExpected = expectedDomain.split(":")[0]; - const normalizedParsed = siweMessage.domain.split(":")[0]; - if (normalizedExpected !== normalizedParsed) { - console.error("SIWE: Domain mismatch"); - return null; - } - } - - // Validate chain ID if present (optional - can be configured via env) - const expectedChainId = process.env.NEXT_PUBLIC_SIWE_CHAIN_ID; - if (expectedChainId && siweMessage.chainId) { - const expectedChainIdNum = parseInt(expectedChainId, 10); - if (siweMessage.chainId !== expectedChainIdNum) { - console.error("SIWE: Chain ID mismatch"); - return null; - } - } - - // Verify the signature using the siwe library - // This performs cryptographic signature verification and validates message fields - const result = await siweMessage.verify({ - signature, - }); - - // Check if verification was successful - if (!result.success) { - console.error("SIWE: Verification failed", result.error); - return null; - } - - // Return the verified address from the message - return siweMessage.address.toLowerCase(); - } catch (error) { - console.error("SIWE verification error:", error); - return null; - } - }, -}; diff --git a/src/lib/sonar-route-handler.ts b/src/lib/sonar-route-handler.ts index 1d90332..6e9ff48 100644 --- a/src/lib/sonar-route-handler.ts +++ b/src/lib/sonar-route-handler.ts @@ -1,11 +1,50 @@ import { NextRequest, NextResponse } from "next/server"; -import { getAuth, refreshSonarToken } from "@/app/api/auth/[...nextauth]/route"; +import { getSession } from "@/lib/session"; import { getTokenStore, SonarTokens } from "@/lib/token-store"; import { createSonarClient } from "@/lib/sonar-client"; import { APIError, SonarClient } from "@echoxyz/sonar-core"; +// In-flight refresh promises by session ID - prevents concurrent refresh attempts +const refreshPromises = new Map>(); + +/** + * Refresh Sonar access token using refresh token. + * Uses promise coalescing to prevent concurrent refresh attempts for the same session. + */ +async function refreshSonarToken(sessionId: string, refreshToken: string): Promise { + // If a refresh is already in-flight for this session, reuse it + const existing = refreshPromises.get(sessionId); + if (existing) { + return existing; + } + + const doRefresh = async (): Promise => { + const apiURL = process.env.NEXT_PUBLIC_ECHO_API_URL ?? "https://api.echo.xyz"; + const client = new SonarClient({ apiURL }); + + const tokenData = await client.refreshToken({ refreshToken }); + const expiresAt = Math.floor(Date.now() / 1000) + (tokenData.expires_in || 3600); + + return { + accessToken: tokenData.access_token, + refreshToken: tokenData.refresh_token || refreshToken, + expiresAt, + }; + }; + + const promise = doRefresh(); + refreshPromises.set(sessionId, promise); + + try { + const result = await promise; + return result; + } finally { + refreshPromises.delete(sessionId); + } +} + export type AuthenticatedContext = { - userId: string; + sessionId: string; tokens: SonarTokens; client: SonarClient; }; @@ -23,15 +62,13 @@ export function createSonarRouteHandler( ): (request: NextRequest) => Promise { return async (request: NextRequest) => { // Check session authentication - const session = await getAuth(); - if (!session?.user?.id) { + const session = await getSession(); + if (!session) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - const userId = session.user.id; - // Get tokens from store - let tokens = getTokenStore().getTokens(userId); + let tokens = getTokenStore().getTokens(session.id); if (!tokens) { return NextResponse.json( { error: "Sonar account not connected" }, @@ -39,12 +76,12 @@ export function createSonarRouteHandler( ); } - // Check if token needs refresh + // Check if token needs refresh (within 5 minutes of expiry) const now = Math.floor(Date.now() / 1000); if (tokens.expiresAt - now < 300) { try { - tokens = await refreshSonarToken(tokens.refreshToken); - getTokenStore().setTokens(userId, tokens); + tokens = await refreshSonarToken(session.id, tokens.refreshToken); + getTokenStore().setTokens(session.id, tokens); } catch { return NextResponse.json( { error: "Failed to refresh token" }, @@ -55,9 +92,9 @@ export function createSonarRouteHandler( try { const body = (await request.json()) as T; - const client = createSonarClient(userId); + const client = createSonarClient(session.id); - return await handler({ userId, tokens, client }, body); + return await handler({ sessionId: session.id, tokens, client }, body); } catch (error) { if (error instanceof APIError) { return NextResponse.json( @@ -74,4 +111,3 @@ export function createSonarRouteHandler( } }; } - diff --git a/src/types/next-auth.d.ts b/src/types/next-auth.d.ts deleted file mode 100644 index 74c6ec2..0000000 --- a/src/types/next-auth.d.ts +++ /dev/null @@ -1,27 +0,0 @@ -import "next-auth"; -import "next-auth/jwt"; - -declare module "next-auth" { - interface Session { - user: { - id: string; - name?: string | null; - email?: string | null; - image?: string | null; - sonarConnected?: boolean; - }; - } - - interface User { - id: string; - name?: string | null; - } -} - -declare module "next-auth/jwt" { - interface JWT { - sub?: string; - sonarAccessToken?: string; - sonarTokenExpiresAt?: number; - } -} From c8c9afa65e8e409b8755572287fc9b2d0438f404 Mon Sep 17 00:00:00 2001 From: Will Sewell Date: Fri, 9 Jan 2026 11:09:25 +0000 Subject: [PATCH 11/20] Remove duplication in hooks and simplify --- src/app/components/sale/PurchaseCard.tsx | 7 +- src/app/hooks/use-sonar-entities.ts | 58 +-------- src/app/hooks/use-sonar-entity.ts | 76 +++--------- src/app/hooks/use-sonar-purchase.ts | 145 ++++++----------------- src/app/hooks/use-sonar-query.ts | 66 +++++++++++ 5 files changed, 121 insertions(+), 231 deletions(-) create mode 100644 src/app/hooks/use-sonar-query.ts diff --git a/src/app/components/sale/PurchaseCard.tsx b/src/app/components/sale/PurchaseCard.tsx index 84b9edf..eb62dd4 100644 --- a/src/app/components/sale/PurchaseCard.tsx +++ b/src/app/components/sale/PurchaseCard.tsx @@ -1,13 +1,10 @@ "use client"; import { PrePurchaseFailureReason, GeneratePurchasePermitResponse, EntityID } from "@echoxyz/sonar-core"; +import { UseSonarPurchaseResultNotReadyToPurchase, UseSonarPurchaseResultReadyToPurchase } from "@echoxyz/sonar-react"; import { useState } from "react"; import { saleUUID } from "../../config"; -import { - useSonarPurchase, - UseSonarPurchaseResultNotReadyToPurchase, - UseSonarPurchaseResultReadyToPurchase, -} from "../../hooks/use-sonar-purchase"; +import { useSonarPurchase } from "../../hooks/use-sonar-purchase"; import { useSaleContract } from "../../hooks/use-sale-contract"; function readinessConfig( diff --git a/src/app/hooks/use-sonar-entities.ts b/src/app/hooks/use-sonar-entities.ts index f77a369..930ce12 100644 --- a/src/app/hooks/use-sonar-entities.ts +++ b/src/app/hooks/use-sonar-entities.ts @@ -1,68 +1,18 @@ "use client"; -import { useState, useEffect } from "react"; -import { useSession } from "./use-session"; -import { EntityDetails } from "@echoxyz/sonar-core"; +import { ListAvailableEntitiesResponse } from "@echoxyz/sonar-core"; import { saleUUID } from "../config"; +import { useSonarQuery } from "./use-sonar-query"; /** * Hook to fetch all Sonar entities for the authenticated user */ export function useSonarEntities() { - const { authenticated, sonarConnected, refreshSession } = useSession(); - const [loading, setLoading] = useState(false); - const [entities, setEntities] = useState(undefined); - const [error, setError] = useState(undefined); - - useEffect(() => { - if (!authenticated || !sonarConnected) { - setEntities(undefined); - setError(undefined); - setLoading(false); - return; - } - - const fetchEntities = async () => { - setLoading(true); - setError(undefined); - - try { - const response = await fetch("/api/sonar/entities", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - saleUUID, - }), - }); - - if (!response.ok) { - // If server says not connected (e.g., tokens lost after hot reload), refresh session state - if (response.status === 401) { - await refreshSession(); - return; - } - const errorData = await response.json(); - throw new Error(errorData.error || "Failed to fetch entities"); - } - - const data = await response.json(); - setEntities(data.Entities || []); - } catch (err) { - setError(err instanceof Error ? err : new Error(String(err))); - setEntities(undefined); - } finally { - setLoading(false); - } - }; - - fetchEntities(); - }, [authenticated, sonarConnected, refreshSession]); + const { loading, data, error } = useSonarQuery("/api/sonar/entities", { saleUUID }); return { loading, - entities, + entities: data?.Entities, error, }; } diff --git a/src/app/hooks/use-sonar-entity.ts b/src/app/hooks/use-sonar-entity.ts index ac95201..8abe762 100644 --- a/src/app/hooks/use-sonar-entity.ts +++ b/src/app/hooks/use-sonar-entity.ts @@ -1,75 +1,29 @@ "use client"; -import { useState, useEffect } from "react"; -import { useSession } from "./use-session"; -import { EntityDetails } from "@echoxyz/sonar-core"; +import { ReadEntityResponse } from "@echoxyz/sonar-core"; import { saleUUID } from "../config"; +import { useSonarQuery } from "./use-sonar-query"; /** * Hook to fetch Sonar entity details for a specific wallet */ export function useSonarEntity(walletAddress?: string) { - const { authenticated, sonarConnected, refreshSession } = useSession(); - const [loading, setLoading] = useState(false); - const [entity, setEntity] = useState(undefined); - const [error, setError] = useState(undefined); + const query = useSonarQuery("/api/sonar/entity", { + saleUUID, + walletAddress: walletAddress ?? "", + }); - useEffect(() => { - // Only fetch if user is authenticated AND connected to Sonar - if (!authenticated || !sonarConnected || !walletAddress) { - setEntity(undefined); - setError(undefined); - setLoading(false); - return; - } + // No wallet address - return idle state + if (!walletAddress) { + return { loading: false, entity: undefined, error: undefined }; + } - const fetchEntity = async () => { - setLoading(true); - setError(undefined); - - try { - const response = await fetch("/api/sonar/entity", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - saleUUID, - walletAddress, - }), - }); - - if (!response.ok) { - if (response.status === 404) { - setEntity(undefined); - setLoading(false); - return; - } - // If server says not connected (e.g., tokens lost after hot reload), refresh session state - if (response.status === 401) { - await refreshSession(); - return; - } - const errorData = await response.json(); - throw new Error(errorData.error || "Failed to fetch entity"); - } - - const data = await response.json(); - setEntity(data.Entity); - } catch (err) { - setError(err instanceof Error ? err : new Error(String(err))); - setEntity(undefined); - } finally { - setLoading(false); - } - }; - - fetchEntity(); - }, [authenticated, sonarConnected, walletAddress, refreshSession]); + // Treat 404 as "no entity" rather than an error + const is404 = query.error?.message.includes("404"); return { - loading, - entity, - error, + loading: query.loading, + entity: query.data?.Entity, + error: is404 ? undefined : query.error, }; } diff --git a/src/app/hooks/use-sonar-purchase.ts b/src/app/hooks/use-sonar-purchase.ts index 01e9fc8..e6e816a 100644 --- a/src/app/hooks/use-sonar-purchase.ts +++ b/src/app/hooks/use-sonar-purchase.ts @@ -1,39 +1,15 @@ "use client"; -import { EntityID, GeneratePurchasePermitResponse, PrePurchaseFailureReason } from "@echoxyz/sonar-core"; +import { + EntityID, + GeneratePurchasePermitResponse, + PrePurchaseCheckResponse, + PrePurchaseFailureReason, +} from "@echoxyz/sonar-core"; +import { UseSonarPurchaseResult } from "@echoxyz/sonar-react"; import { useSession } from "./use-session"; -import { useCallback, useEffect, useState } from "react"; - -export type UseSonarPurchaseResultLoading = { - loading: true; - readyToPurchase: false; -}; - -export type UseSonarPurchaseResultReadyToPurchase = { - loading: false; - readyToPurchase: true; - generatePurchasePermit: () => Promise; - livenessCheckURL?: string; -}; - -export type UseSonarPurchaseResultNotReadyToPurchase = { - loading: false; - readyToPurchase: false; - failureReason: PrePurchaseFailureReason; - livenessCheckURL?: string; -}; - -export type UseSonarPurchaseResultError = { - loading: false; - readyToPurchase: false; - error: Error; -}; - -export type UseSonarPurchaseResult = - | UseSonarPurchaseResultLoading - | UseSonarPurchaseResultReadyToPurchase - | UseSonarPurchaseResultNotReadyToPurchase - | UseSonarPurchaseResultError; +import { useCallback } from "react"; +import { useSonarQuery } from "./use-sonar-query"; /** * Hook for Sonar purchase flow @@ -43,10 +19,12 @@ export function useSonarPurchase(args: { entityID: EntityID; walletAddress: string; }): UseSonarPurchaseResult { - const { authenticated, sonarConnected, refreshSession } = useSession(); - const [state, setState] = useState({ - loading: true, - readyToPurchase: false, + const { authenticated } = useSession(); + + const { loading, data, error } = useSonarQuery("/api/sonar/pre-purchase-check", { + saleUUID: args.saleUUID, + entityID: args.entityID, + walletAddress: args.walletAddress, }); const generatePurchasePermit = useCallback(async (): Promise => { @@ -56,9 +34,7 @@ export function useSonarPurchase(args: { const response = await fetch("/api/sonar/generate-purchase-permit", { method: "POST", - headers: { - "Content-Type": "application/json", - }, + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ saleUUID: args.saleUUID, entityID: args.entityID, @@ -68,82 +44,29 @@ export function useSonarPurchase(args: { if (!response.ok) { const errorData = await response.json(); - throw new Error(errorData.error || "Failed to generate purchase permit"); + throw new Error(errorData.error || `Request failed: ${response.status}`); } - const data = await response.json(); - return data; + return response.json(); }, [authenticated, args.saleUUID, args.entityID, args.walletAddress]); - useEffect(() => { - // Only fetch if user is authenticated AND connected to Sonar - if (!authenticated || !sonarConnected) { - setState({ - loading: false, - readyToPurchase: false, - error: new Error("Not authenticated"), - }); - return; - } - - const fetchPurchaseData = async () => { - setState({ - loading: true, - readyToPurchase: false, - }); - - try { - const response = await fetch("/api/sonar/pre-purchase-check", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - saleUUID: args.saleUUID, - entityID: args.entityID, - walletAddress: args.walletAddress, - }), - }); - - if (!response.ok) { - // If server says not connected (e.g., tokens lost after hot reload), refresh session state - if (response.status === 401) { - await refreshSession(); - return; - } - const errorData = await response.json(); - throw new Error(errorData.error || "Failed to check purchase eligibility"); - } + if (loading) { + return { loading: true, readyToPurchase: false, error: undefined }; + } - const data = await response.json(); + if (error || !data) { + return { loading: false, readyToPurchase: false, error: error ?? new Error("No data") }; + } - if (data.ReadyToPurchase) { - setState({ - loading: false, - readyToPurchase: true, - generatePurchasePermit, - livenessCheckURL: data.LivenessCheckURL, - }); - return; - } + if (data.ReadyToPurchase) { + return { loading: false, readyToPurchase: true, error: undefined, generatePurchasePermit }; + } - setState({ - loading: false, - readyToPurchase: false, - failureReason: data.FailureReason as PrePurchaseFailureReason, - livenessCheckURL: data.LivenessCheckURL, - }); - } catch (err) { - setState({ - loading: false, - readyToPurchase: false, - error: err instanceof Error ? err : new Error(String(err)), - }); - } - }; - - fetchPurchaseData(); - }, [authenticated, sonarConnected, args.saleUUID, args.entityID, args.walletAddress, generatePurchasePermit, refreshSession]); - - return state; + return { + loading: false, + readyToPurchase: false, + error: undefined, + failureReason: data.FailureReason as PrePurchaseFailureReason, + livenessCheckURL: data.LivenessCheckURL, + }; } diff --git a/src/app/hooks/use-sonar-query.ts b/src/app/hooks/use-sonar-query.ts new file mode 100644 index 0000000..e30a72c --- /dev/null +++ b/src/app/hooks/use-sonar-query.ts @@ -0,0 +1,66 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useSession } from "./use-session"; + +export type SonarQueryState = + | { loading: true; data: undefined; error: undefined } + | { loading: false; data: T; error: undefined } + | { loading: false; data: undefined; error: Error } + | { loading: false; data: undefined; error: undefined }; // idle/skipped state + +/** + * Generic hook for Sonar API queries with shared session/auth handling + */ +export function useSonarQuery(endpoint: string, body: Record): SonarQueryState { + const { authenticated, sonarConnected, refreshSession } = useSession(); + const [state, setState] = useState>({ + loading: true, + data: undefined, + error: undefined, + }); + + // Serialize body for stable dependency comparison + const serializedBody = JSON.stringify(body); + + useEffect(() => { + if (!authenticated || !sonarConnected) { + setState({ loading: false, data: undefined, error: undefined }); + return; + } + + const fetchData = async () => { + setState({ loading: true, data: undefined, error: undefined }); + + try { + const response = await fetch(endpoint, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: serializedBody, + }); + + if (!response.ok) { + if (response.status === 401) { + await refreshSession(); + return; + } + const errorData = await response.json(); + throw new Error(errorData.error || `Request failed: ${response.status}`); + } + + 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)), + }); + } + }; + + fetchData(); + }, [authenticated, sonarConnected, endpoint, serializedBody, refreshSession]); + + return state; +} From 672420717669ae87b3e75e1a9682b4320afa143f Mon Sep 17 00:00:00 2001 From: Will Sewell Date: Fri, 9 Jan 2026 11:13:29 +0000 Subject: [PATCH 12/20] Don't default the expire_in --- src/app/api/auth/sonar/callback/route.ts | 2 +- src/lib/sonar-route-handler.ts | 31 ++++++------------------ 2 files changed, 8 insertions(+), 25 deletions(-) diff --git a/src/app/api/auth/sonar/callback/route.ts b/src/app/api/auth/sonar/callback/route.ts index d316b05..9990fad 100644 --- a/src/app/api/auth/sonar/callback/route.ts +++ b/src/app/api/auth/sonar/callback/route.ts @@ -61,7 +61,7 @@ export async function GET(request: NextRequest) { redirectURI: process.env.NEXT_PUBLIC_OAUTH_CLIENT_REDIRECT_URI ?? "", }); - const expiresAt = Math.floor(Date.now() / 1000) + (tokenData.expires_in || 3600); + const expiresAt = Math.floor(Date.now() / 1000) + tokenData.expires_in; const sonarTokens: SonarTokens = { accessToken: tokenData.access_token, diff --git a/src/lib/sonar-route-handler.ts b/src/lib/sonar-route-handler.ts index 6e9ff48..799f39e 100644 --- a/src/lib/sonar-route-handler.ts +++ b/src/lib/sonar-route-handler.ts @@ -23,7 +23,7 @@ async function refreshSonarToken(sessionId: string, refreshToken: string): Promi const client = new SonarClient({ apiURL }); const tokenData = await client.refreshToken({ refreshToken }); - const expiresAt = Math.floor(Date.now() / 1000) + (tokenData.expires_in || 3600); + const expiresAt = Math.floor(Date.now() / 1000) + tokenData.expires_in; return { accessToken: tokenData.access_token, @@ -49,17 +49,12 @@ export type AuthenticatedContext = { client: SonarClient; }; -type RouteHandler = ( - context: AuthenticatedContext, - body: T -) => Promise; +type RouteHandler = (context: AuthenticatedContext, body: T) => Promise; /** * Creates a Sonar API route handler with authentication, token refresh, and error handling. */ -export function createSonarRouteHandler( - handler: RouteHandler -): (request: NextRequest) => Promise { +export function createSonarRouteHandler(handler: RouteHandler): (request: NextRequest) => Promise { return async (request: NextRequest) => { // Check session authentication const session = await getSession(); @@ -70,10 +65,7 @@ export function createSonarRouteHandler( // Get tokens from store let tokens = getTokenStore().getTokens(session.id); if (!tokens) { - return NextResponse.json( - { error: "Sonar account not connected" }, - { status: 401 } - ); + return NextResponse.json({ error: "Sonar account not connected" }, { status: 401 }); } // Check if token needs refresh (within 5 minutes of expiry) @@ -83,10 +75,7 @@ export function createSonarRouteHandler( tokens = await refreshSonarToken(session.id, tokens.refreshToken); getTokenStore().setTokens(session.id, tokens); } catch { - return NextResponse.json( - { error: "Failed to refresh token" }, - { status: 401 } - ); + return NextResponse.json({ error: "Failed to refresh token" }, { status: 401 }); } } @@ -97,17 +86,11 @@ export function createSonarRouteHandler( return await handler({ sessionId: session.id, tokens, client }, body); } catch (error) { if (error instanceof APIError) { - return NextResponse.json( - { error: error.message }, - { status: error.status } - ); + return NextResponse.json({ error: error.message }, { status: error.status }); } console.error("Error calling Sonar API"); - return NextResponse.json( - { error: "Internal server error" }, - { status: 500 } - ); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); } }; } From 84525cd523d0cf879a230a0af70316d3e554a8d8 Mon Sep 17 00:00:00 2001 From: Will Sewell Date: Fri, 9 Jan 2026 12:30:02 +0000 Subject: [PATCH 13/20] Remove redirects in case of error --- src/app/oauth/callback/page.tsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/app/oauth/callback/page.tsx b/src/app/oauth/callback/page.tsx index b137436..2e2e2cf 100644 --- a/src/app/oauth/callback/page.tsx +++ b/src/app/oauth/callback/page.tsx @@ -19,13 +19,11 @@ function OAuthCallbackContent() { if (oauthError) { setError(`OAuth error: ${oauthError}`); - setTimeout(() => (window.location.href = "/"), 3000); return; } if (!code || !state) { setError("Missing authorization code or state"); - setTimeout(() => (window.location.href = "/"), 3000); return; } @@ -36,7 +34,6 @@ function OAuthCallbackContent() { if (!response.ok) { const errorData = await response.json().catch(() => ({ error: "Unknown error" })); setError(errorData.error || "Failed to complete OAuth flow"); - setTimeout(() => (window.location.href = "/"), 3000); return; } @@ -45,7 +42,6 @@ function OAuthCallbackContent() { window.location.href = "/"; } catch { setError("Failed to process OAuth callback"); - setTimeout(() => (window.location.href = "/"), 3000); } }; From 3c84538e42ba007b8ef56e2ff90c97f0aa58c6d4 Mon Sep 17 00:00:00 2001 From: Will Sewell Date: Fri, 9 Jan 2026 12:40:14 +0000 Subject: [PATCH 14/20] In memory store for pcke code verifiers --- src/lib/pkce-store.ts | 131 ++++++++++++++++++++++++++---------------- 1 file changed, 83 insertions(+), 48 deletions(-) diff --git a/src/lib/pkce-store.ts b/src/lib/pkce-store.ts index 98d9bfa..06e7312 100644 --- a/src/lib/pkce-store.ts +++ b/src/lib/pkce-store.ts @@ -1,76 +1,111 @@ /** - * Temporary store for PKCE code verifiers and OAuth state tokens - * Uses cookies to persist across serverless function invocations + * PKCE store interface for managing OAuth PKCE code verifiers. + * This interface allows swapping between in-memory and persistent storage implementations. */ -import { cookies } from "next/headers"; - -const PKCE_COOKIE_PREFIX = "sonar_pkce_"; -const PKCE_COOKIE_TTL_SEC = 10 * 60; // 10 minutes - -interface StateEntry { +export interface PKCEEntry { sessionId: string; codeVerifier: string; - expiresAt: number; + expiresAt: number; // Unix timestamp in milliseconds +} + +export interface PKCEStore { + /** + * Store PKCE verifier and session ID for a state token + */ + setEntry(state: string, entry: PKCEEntry): void; + + /** + * Retrieve PKCE entry for a state token + */ + getEntry(state: string): PKCEEntry | null; + + /** + * Remove PKCE entry for a state token + */ + clearEntry(state: string): void; +} + +/** + * In-memory PKCE store implementation. + * Entries are stored in a Map and will be lost on server restart. + * This can be easily swapped for a database-backed implementation. + */ +class InMemoryPKCEStore implements PKCEStore { + private entries: Map = new Map(); + + setEntry(state: string, entry: PKCEEntry): void { + this.entries.set(state, entry); + } + + getEntry(state: string): PKCEEntry | null { + return this.entries.get(state) || null; + } + + clearEntry(state: string): void { + this.entries.delete(state); + } +} + +// Singleton instance - can be swapped for a different implementation +let pkceStoreInstance: PKCEStore | null = null; + +/** + * Get the PKCE store instance. + * This factory function allows swapping implementations without changing call sites. + */ +export function getPKCEStore(): PKCEStore { + if (!pkceStoreInstance) { + pkceStoreInstance = new InMemoryPKCEStore(); + } + return pkceStoreInstance; } /** - * Store PKCE verifier and session ID linked to a state token in a cookie + * Set a custom PKCE store implementation. + * Useful for swapping to a database-backed store. */ -export async function setPKCEVerifier(state: string, sessionId: string, codeVerifier: string): Promise { - const cookieStore = await cookies(); - const expiresAt = Date.now() + PKCE_COOKIE_TTL_SEC * 1000; +export function setPKCEStore(store: PKCEStore): void { + pkceStoreInstance = store; +} - const entry: StateEntry = { +const PKCE_TTL_MS = 10 * 60 * 1000; // 10 minutes + +/** + * Store PKCE verifier and session ID linked to a state token + */ +export function setPKCEVerifier(state: string, sessionId: string, codeVerifier: string): void { + getPKCEStore().setEntry(state, { sessionId, codeVerifier, - expiresAt, - }; - - cookieStore.set(`${PKCE_COOKIE_PREFIX}${state}`, JSON.stringify(entry), { - httpOnly: true, - secure: process.env.NODE_ENV === "production", - sameSite: "lax", - maxAge: PKCE_COOKIE_TTL_SEC, - path: "/", + expiresAt: Date.now() + PKCE_TTL_MS, }); } /** - * Get PKCE verifier and session ID for a state token from cookie + * Get PKCE verifier and session ID for a state token */ -export async function getPKCEVerifier(state: string): Promise<{ userId: string; codeVerifier: string } | null> { - const cookieStore = await cookies(); - const cookieName = `${PKCE_COOKIE_PREFIX}${state}`; - const cookie = cookieStore.get(cookieName); +export function getPKCEVerifier(state: string): { userId: string; codeVerifier: string } | null { + const entry = getPKCEStore().getEntry(state); - if (!cookie?.value) { + if (!entry) { return null; } - try { - const entry: StateEntry = JSON.parse(cookie.value); - - // Check if expired - if (entry.expiresAt < Date.now()) { - cookieStore.delete(cookieName); - return null; - } - - return { - userId: entry.sessionId, - codeVerifier: entry.codeVerifier, - }; - } catch { - cookieStore.delete(cookieName); + // Check if expired + if (entry.expiresAt < Date.now()) { + getPKCEStore().clearEntry(state); return null; } + + return { + userId: entry.sessionId, + codeVerifier: entry.codeVerifier, + }; } /** * Clear state entry (called after successful token exchange) */ -export async function clearPKCEVerifier(state: string): Promise { - const cookieStore = await cookies(); - const cookieName = `${PKCE_COOKIE_PREFIX}${state}`; - cookieStore.delete(cookieName); +export function clearPKCEVerifier(state: string): void { + getPKCEStore().clearEntry(state); } From 821faf5feb2603e05381398839b24a69bf1838ff Mon Sep 17 00:00:00 2001 From: Will Sewell Date: Fri, 9 Jan 2026 13:42:58 +0000 Subject: [PATCH 15/20] Use strict cookies --- src/lib/session.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/session.ts b/src/lib/session.ts index be1cbd3..76d2edd 100644 --- a/src/lib/session.ts +++ b/src/lib/session.ts @@ -30,7 +30,7 @@ export async function createSession(): Promise { cookieStore.set(SESSION_COOKIE_NAME, sessionId, { httpOnly: true, secure: process.env.NODE_ENV === "production", - sameSite: "lax", + sameSite: "strict", maxAge: SESSION_COOKIE_MAX_AGE, path: "/", }); From 9b41be8f18e4fb838fe74cd1568b29385e2be24b Mon Sep 17 00:00:00 2001 From: Will Sewell Date: Fri, 9 Jan 2026 13:48:24 +0000 Subject: [PATCH 16/20] Separate userId field --- src/app/api/auth/login/route.ts | 2 +- src/app/api/auth/session/route.ts | 2 +- src/app/api/auth/sonar/authorize/route.ts | 4 ++-- src/app/api/auth/sonar/callback/route.ts | 6 +++--- src/app/api/auth/sonar/disconnect/route.ts | 2 +- src/lib/session.ts | 8 +++++--- src/lib/sonar-route-handler.ts | 10 +++++----- 7 files changed, 18 insertions(+), 16 deletions(-) diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 4d20d91..dd46a7e 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -10,6 +10,6 @@ export async function POST() { return NextResponse.json({ success: true, - sessionId: session.id, + userId: session.userId, }); } diff --git a/src/app/api/auth/session/route.ts b/src/app/api/auth/session/route.ts index 41e05d5..018335c 100644 --- a/src/app/api/auth/session/route.ts +++ b/src/app/api/auth/session/route.ts @@ -16,7 +16,7 @@ export async function GET() { }); } - const tokens = getTokenStore().getTokens(session.id); + const tokens = getTokenStore().getTokens(session.userId); return NextResponse.json({ authenticated: true, diff --git a/src/app/api/auth/sonar/authorize/route.ts b/src/app/api/auth/sonar/authorize/route.ts index 7a7a09a..013a746 100644 --- a/src/app/api/auth/sonar/authorize/route.ts +++ b/src/app/api/auth/sonar/authorize/route.ts @@ -16,8 +16,8 @@ export async function GET() { // Generate PKCE parameters (includes state token from sonar-core) const { codeVerifier, codeChallenge, state } = await generatePKCEParams(); - // Store code verifier and session ID linked to state token (will be retrieved in callback) - await setPKCEVerifier(state, session.id, codeVerifier); + // 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({ diff --git a/src/app/api/auth/sonar/callback/route.ts b/src/app/api/auth/sonar/callback/route.ts index 9990fad..697577c 100644 --- a/src/app/api/auth/sonar/callback/route.ts +++ b/src/app/api/auth/sonar/callback/route.ts @@ -44,7 +44,7 @@ export async function GET(request: NextRequest) { } // Verify the state token belongs to the current session - if (stateData.userId !== session.id) { + if (stateData.userId !== session.userId) { return NextResponse.json( { error: "Invalid session", details: "State token does not match current session" }, { status: 401 } @@ -54,7 +54,7 @@ export async function GET(request: NextRequest) { const { codeVerifier } = stateData; // Create a temporary client to exchange the authorization code - const client = createSonarClient(session.id); + const client = createSonarClient(session.userId); const tokenData = await client.exchangeAuthorizationCode({ code, codeVerifier, @@ -70,7 +70,7 @@ export async function GET(request: NextRequest) { }; // Store tokens in token store - getTokenStore().setTokens(session.id, sonarTokens); + getTokenStore().setTokens(session.userId, sonarTokens); // Clear the code verifier (no longer needed) await clearPKCEVerifier(state); diff --git a/src/app/api/auth/sonar/disconnect/route.ts b/src/app/api/auth/sonar/disconnect/route.ts index f149fed..bbbb795 100644 --- a/src/app/api/auth/sonar/disconnect/route.ts +++ b/src/app/api/auth/sonar/disconnect/route.ts @@ -12,7 +12,7 @@ export async function POST() { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - getTokenStore().clearTokens(session.id); + getTokenStore().clearTokens(session.userId); return NextResponse.json({ success: true }); } diff --git a/src/lib/session.ts b/src/lib/session.ts index 76d2edd..e7b4d8e 100644 --- a/src/lib/session.ts +++ b/src/lib/session.ts @@ -5,11 +5,13 @@ import { cookies } from "next/headers"; import { getTokenStore } from "./token-store"; -const SESSION_COOKIE_NAME = "session_id"; +const SESSION_COOKIE_NAME = "sonar_example_session_id"; const SESSION_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; // 7 days export interface Session { id: string; + /** User identifier - in this example app, the same as the session ID */ + userId: string; } export async function getSession(): Promise { @@ -20,7 +22,7 @@ export async function getSession(): Promise { return null; } - return { id: sessionId }; + return { id: sessionId, userId: sessionId }; } export async function createSession(): Promise { @@ -35,7 +37,7 @@ export async function createSession(): Promise { path: "/", }); - return { id: sessionId }; + return { id: sessionId, userId: sessionId }; } export async function destroySession(): Promise { diff --git a/src/lib/sonar-route-handler.ts b/src/lib/sonar-route-handler.ts index 799f39e..dbb1ca8 100644 --- a/src/lib/sonar-route-handler.ts +++ b/src/lib/sonar-route-handler.ts @@ -63,7 +63,7 @@ export function createSonarRouteHandler(handler: RouteHandler): (request: } // Get tokens from store - let tokens = getTokenStore().getTokens(session.id); + let tokens = getTokenStore().getTokens(session.userId); if (!tokens) { return NextResponse.json({ error: "Sonar account not connected" }, { status: 401 }); } @@ -72,8 +72,8 @@ export function createSonarRouteHandler(handler: RouteHandler): (request: const now = Math.floor(Date.now() / 1000); if (tokens.expiresAt - now < 300) { try { - tokens = await refreshSonarToken(session.id, tokens.refreshToken); - getTokenStore().setTokens(session.id, tokens); + tokens = await refreshSonarToken(session.userId, tokens.refreshToken); + getTokenStore().setTokens(session.userId, tokens); } catch { return NextResponse.json({ error: "Failed to refresh token" }, { status: 401 }); } @@ -81,9 +81,9 @@ export function createSonarRouteHandler(handler: RouteHandler): (request: try { const body = (await request.json()) as T; - const client = createSonarClient(session.id); + const client = createSonarClient(session.userId); - return await handler({ sessionId: session.id, tokens, client }, body); + return await handler({ sessionId: session.userId, tokens, client }, body); } catch (error) { if (error instanceof APIError) { return NextResponse.json({ error: error.message }, { status: error.status }); From f69fbc5584e46c1c978f35ad226419666f1c125f Mon Sep 17 00:00:00 2001 From: Will Sewell Date: Fri, 9 Jan 2026 13:51:43 +0000 Subject: [PATCH 17/20] Merge sonar libs --- src/app/api/auth/sonar/callback/route.ts | 2 +- src/app/api/sonar/entities/route.ts | 2 +- src/app/api/sonar/entity/route.ts | 2 +- .../sonar/generate-purchase-permit/route.ts | 2 +- src/app/api/sonar/pre-purchase-check/route.ts | 2 +- src/lib/sonar-client.ts | 29 ------------------- src/lib/{sonar-route-handler.ts => sonar.ts} | 29 ++++++++++++++++++- 7 files changed, 33 insertions(+), 35 deletions(-) delete mode 100644 src/lib/sonar-client.ts rename src/lib/{sonar-route-handler.ts => sonar.ts} (82%) diff --git a/src/app/api/auth/sonar/callback/route.ts b/src/app/api/auth/sonar/callback/route.ts index 697577c..2d220f0 100644 --- a/src/app/api/auth/sonar/callback/route.ts +++ b/src/app/api/auth/sonar/callback/route.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getSession } from "@/lib/session"; import { getTokenStore, SonarTokens } from "@/lib/token-store"; import { getPKCEVerifier, clearPKCEVerifier } from "@/lib/pkce-store"; -import { createSonarClient } from "@/lib/sonar-client"; +import { createSonarClient } from "@/lib/sonar"; /** * Handle OAuth callback from Sonar diff --git a/src/app/api/sonar/entities/route.ts b/src/app/api/sonar/entities/route.ts index 66f7b23..f1a59df 100644 --- a/src/app/api/sonar/entities/route.ts +++ b/src/app/api/sonar/entities/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from "next/server"; -import { createSonarRouteHandler } from "@/lib/sonar-route-handler"; +import { createSonarRouteHandler } from "@/lib/sonar"; type EntitiesRequest = { saleUUID: string; diff --git a/src/app/api/sonar/entity/route.ts b/src/app/api/sonar/entity/route.ts index 469bdc3..9e3f9c2 100644 --- a/src/app/api/sonar/entity/route.ts +++ b/src/app/api/sonar/entity/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from "next/server"; -import { createSonarRouteHandler } from "@/lib/sonar-route-handler"; +import { createSonarRouteHandler } from "@/lib/sonar"; import { APIError } from "@echoxyz/sonar-core"; type EntityRequest = { diff --git a/src/app/api/sonar/generate-purchase-permit/route.ts b/src/app/api/sonar/generate-purchase-permit/route.ts index f239169..14fd6dd 100644 --- a/src/app/api/sonar/generate-purchase-permit/route.ts +++ b/src/app/api/sonar/generate-purchase-permit/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from "next/server"; -import { createSonarRouteHandler } from "@/lib/sonar-route-handler"; +import { createSonarRouteHandler } from "@/lib/sonar"; type GeneratePurchasePermitRequest = { saleUUID: string; diff --git a/src/app/api/sonar/pre-purchase-check/route.ts b/src/app/api/sonar/pre-purchase-check/route.ts index 2b74d9d..739cfe9 100644 --- a/src/app/api/sonar/pre-purchase-check/route.ts +++ b/src/app/api/sonar/pre-purchase-check/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from "next/server"; -import { createSonarRouteHandler } from "@/lib/sonar-route-handler"; +import { createSonarRouteHandler } from "@/lib/sonar"; type PrePurchaseCheckRequest = { saleUUID: string; diff --git a/src/lib/sonar-client.ts b/src/lib/sonar-client.ts deleted file mode 100644 index a6ecab7..0000000 --- a/src/lib/sonar-client.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { SonarClient } from "@echoxyz/sonar-core"; -import { getTokenStore } from "./token-store"; - -/** - * Create a SonarClient instance for a specific user - * Sets the access token from our server-side token store - */ -export function createSonarClient(userId: string): SonarClient { - const apiURL = process.env.NEXT_PUBLIC_ECHO_API_URL ?? "https://api.echo.xyz"; - - // Create a new client instance - const client = new SonarClient({ - apiURL, - opts: { - onUnauthorized: () => { - // Clear tokens on unauthorized - getTokenStore().clearTokens(userId); - }, - }, - }); - - // Set the token from our server-side store - const tokens = getTokenStore().getTokens(userId); - if (tokens?.accessToken) { - client.setToken(tokens.accessToken); - } - - return client; -} diff --git a/src/lib/sonar-route-handler.ts b/src/lib/sonar.ts similarity index 82% rename from src/lib/sonar-route-handler.ts rename to src/lib/sonar.ts index dbb1ca8..5750339 100644 --- a/src/lib/sonar-route-handler.ts +++ b/src/lib/sonar.ts @@ -1,9 +1,36 @@ import { NextRequest, NextResponse } from "next/server"; import { getSession } from "@/lib/session"; import { getTokenStore, SonarTokens } from "@/lib/token-store"; -import { createSonarClient } from "@/lib/sonar-client"; import { APIError, SonarClient } from "@echoxyz/sonar-core"; +/** + * Create a SonarClient instance for a specific user + * Sets the access token from our server-side token store + */ +export function createSonarClient(userId: string): SonarClient { + const apiURL = process.env.NEXT_PUBLIC_ECHO_API_URL ?? "https://api.echo.xyz"; + + // Create a new client instance + const client = new SonarClient({ + apiURL, + opts: { + onUnauthorized: () => { + // Clear tokens on unauthorized + getTokenStore().clearTokens(userId); + }, + }, + }); + + // Set the token from our server-side store + const tokens = getTokenStore().getTokens(userId); + if (tokens?.accessToken) { + client.setToken(tokens.accessToken); + } + + return client; +} + + // In-flight refresh promises by session ID - prevents concurrent refresh attempts const refreshPromises = new Map>(); From 2fc0fe44ab92bd1c682beedf06e1f90fffae9915 Mon Sep 17 00:00:00 2001 From: Will Sewell Date: Fri, 9 Jan 2026 14:00:44 +0000 Subject: [PATCH 18/20] Centralise config --- src/app/api/auth/sonar/authorize/route.ts | 7 ++++--- src/app/api/auth/sonar/callback/route.ts | 3 ++- src/app/components/registration/EntitiesList.tsx | 2 +- src/app/components/sale/PurchaseCard.tsx | 2 +- src/app/hooks/use-sale-contract.ts | 2 +- src/app/hooks/use-sonar-entities.ts | 2 +- src/app/hooks/use-sonar-entity.ts | 2 +- src/app/page.tsx | 2 +- src/{app => lib}/config.ts | 4 ++-- src/lib/sonar.ts | 8 +++----- 10 files changed, 17 insertions(+), 17 deletions(-) rename src/{app => lib}/config.ts (90%) diff --git a/src/app/api/auth/sonar/authorize/route.ts b/src/app/api/auth/sonar/authorize/route.ts index 013a746..d80e1e3 100644 --- a/src/app/api/auth/sonar/authorize/route.ts +++ b/src/app/api/auth/sonar/authorize/route.ts @@ -1,5 +1,6 @@ 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"; @@ -21,11 +22,11 @@ export async function GET() { // Build authorization URL with PKCE const authorizationUrl = buildAuthorizationUrl({ - clientUUID: process.env.NEXT_PUBLIC_OAUTH_CLIENT_UUID ?? "", - redirectURI: process.env.NEXT_PUBLIC_OAUTH_CLIENT_REDIRECT_URI ?? "", + clientUUID: sonarConfig.clientUUID, + redirectURI: sonarConfig.redirectURI, state, codeChallenge, - frontendURL: process.env.NEXT_PUBLIC_ECHO_FRONTEND_URL ?? "https://app.echo.xyz", + frontendURL: sonarConfig.frontendURL, }); return NextResponse.redirect(authorizationUrl.toString()); diff --git a/src/app/api/auth/sonar/callback/route.ts b/src/app/api/auth/sonar/callback/route.ts index 2d220f0..8dd6553 100644 --- a/src/app/api/auth/sonar/callback/route.ts +++ b/src/app/api/auth/sonar/callback/route.ts @@ -1,5 +1,6 @@ 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"; @@ -58,7 +59,7 @@ export async function GET(request: NextRequest) { const tokenData = await client.exchangeAuthorizationCode({ code, codeVerifier, - redirectURI: process.env.NEXT_PUBLIC_OAUTH_CLIENT_REDIRECT_URI ?? "", + redirectURI: sonarConfig.redirectURI, }); const expiresAt = Math.floor(Date.now() / 1000) + tokenData.expires_in; diff --git a/src/app/components/registration/EntitiesList.tsx b/src/app/components/registration/EntitiesList.tsx index 17b01f3..67c1a2f 100644 --- a/src/app/components/registration/EntitiesList.tsx +++ b/src/app/components/registration/EntitiesList.tsx @@ -1,6 +1,6 @@ import { EntityDetails } from "@echoxyz/sonar-core"; import { EntityCard } from "../entity/EntityCard"; -import { sonarConfig } from "@/app/config"; +import { sonarConfig } from "@/lib/config"; interface EntitiesListProps { loading: boolean; diff --git a/src/app/components/sale/PurchaseCard.tsx b/src/app/components/sale/PurchaseCard.tsx index eb62dd4..ce3001d 100644 --- a/src/app/components/sale/PurchaseCard.tsx +++ b/src/app/components/sale/PurchaseCard.tsx @@ -3,7 +3,7 @@ import { PrePurchaseFailureReason, GeneratePurchasePermitResponse, EntityID } from "@echoxyz/sonar-core"; import { UseSonarPurchaseResultNotReadyToPurchase, UseSonarPurchaseResultReadyToPurchase } from "@echoxyz/sonar-react"; import { useState } from "react"; -import { saleUUID } from "../../config"; +import { saleUUID } from "@/lib/config"; import { useSonarPurchase } from "../../hooks/use-sonar-purchase"; import { useSaleContract } from "../../hooks/use-sale-contract"; diff --git a/src/app/hooks/use-sale-contract.ts b/src/app/hooks/use-sale-contract.ts index 393e558..a106146 100644 --- a/src/app/hooks/use-sale-contract.ts +++ b/src/app/hooks/use-sale-contract.ts @@ -1,7 +1,7 @@ import { BasicPermitV2, GeneratePurchasePermitResponse } from "@echoxyz/sonar-core"; import { useCallback, useEffect, useState } from "react"; import { useReadContract, useWriteContract, useWaitForTransactionReceipt } from "wagmi"; -import { saleContract } from "../config"; +import { saleContract } from "@/lib/config"; import { examplSaleABI } from "../ExampleSaleABI"; import { useConfig } from "wagmi"; import { simulateContract } from "wagmi/actions"; diff --git a/src/app/hooks/use-sonar-entities.ts b/src/app/hooks/use-sonar-entities.ts index 930ce12..6a6b14e 100644 --- a/src/app/hooks/use-sonar-entities.ts +++ b/src/app/hooks/use-sonar-entities.ts @@ -1,7 +1,7 @@ "use client"; import { ListAvailableEntitiesResponse } from "@echoxyz/sonar-core"; -import { saleUUID } from "../config"; +import { saleUUID } from "@/lib/config"; import { useSonarQuery } from "./use-sonar-query"; /** diff --git a/src/app/hooks/use-sonar-entity.ts b/src/app/hooks/use-sonar-entity.ts index 8abe762..a1b6699 100644 --- a/src/app/hooks/use-sonar-entity.ts +++ b/src/app/hooks/use-sonar-entity.ts @@ -1,7 +1,7 @@ "use client"; import { ReadEntityResponse } from "@echoxyz/sonar-core"; -import { saleUUID } from "../config"; +import { saleUUID } from "@/lib/config"; import { useSonarQuery } from "./use-sonar-query"; /** diff --git a/src/app/page.tsx b/src/app/page.tsx index 406989e..d946680 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useEffect } from "react"; -import { saleUUID, sonarHomeURL } from "./config"; +import { saleUUID, sonarHomeURL } from "@/lib/config"; import { useAccount } from "wagmi"; import { SaleEligibility } from "@echoxyz/sonar-core"; import { EntityCard } from "./components/entity/EntityCard"; diff --git a/src/app/config.ts b/src/lib/config.ts similarity index 90% rename from src/app/config.ts rename to src/lib/config.ts index 2862377..7dd1ab2 100644 --- a/src/app/config.ts +++ b/src/lib/config.ts @@ -1,12 +1,12 @@ import { Hex } from "@echoxyz/sonar-core"; import { SonarProviderConfig } from "@echoxyz/sonar-react"; -export const sonarConfig = { +export const sonarConfig: SonarProviderConfig & { apiURL: string } = { clientUUID: process.env.NEXT_PUBLIC_OAUTH_CLIENT_UUID ?? "", redirectURI: process.env.NEXT_PUBLIC_OAUTH_CLIENT_REDIRECT_URI ?? "", frontendURL: process.env.NEXT_PUBLIC_ECHO_FRONTEND_URL ?? "https://app.echo.xyz", apiURL: process.env.NEXT_PUBLIC_ECHO_API_URL ?? "https://api.echo.xyz", -} as SonarProviderConfig; +}; export const saleUUID = process.env.NEXT_PUBLIC_SALE_UUID ?? ""; export const saleContract = diff --git a/src/lib/sonar.ts b/src/lib/sonar.ts index 5750339..e89bb1f 100644 --- a/src/lib/sonar.ts +++ b/src/lib/sonar.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getSession } from "@/lib/session"; import { getTokenStore, SonarTokens } from "@/lib/token-store"; +import { sonarConfig } from "@/lib/config"; import { APIError, SonarClient } from "@echoxyz/sonar-core"; /** @@ -8,11 +9,9 @@ import { APIError, SonarClient } from "@echoxyz/sonar-core"; * Sets the access token from our server-side token store */ export function createSonarClient(userId: string): SonarClient { - const apiURL = process.env.NEXT_PUBLIC_ECHO_API_URL ?? "https://api.echo.xyz"; - // Create a new client instance const client = new SonarClient({ - apiURL, + apiURL: sonarConfig.apiURL, opts: { onUnauthorized: () => { // Clear tokens on unauthorized @@ -46,8 +45,7 @@ async function refreshSonarToken(sessionId: string, refreshToken: string): Promi } const doRefresh = async (): Promise => { - const apiURL = process.env.NEXT_PUBLIC_ECHO_API_URL ?? "https://api.echo.xyz"; - const client = new SonarClient({ apiURL }); + const client = new SonarClient({ apiURL: sonarConfig.apiURL }); const tokenData = await client.refreshToken({ refreshToken }); const expiresAt = Math.floor(Date.now() / 1000) + tokenData.expires_in; From 080b6f9a1b94ac465a53a4809ae1f1aba125d209 Mon Sep 17 00:00:00 2001 From: Will Sewell Date: Fri, 9 Jan 2026 15:39:55 +0000 Subject: [PATCH 19/20] Do oauth redirection on client --- src/app/api/auth/sonar/authorize/route.ts | 2 +- src/app/components/auth/AuthenticationSection.tsx | 12 ++++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/app/api/auth/sonar/authorize/route.ts b/src/app/api/auth/sonar/authorize/route.ts index d80e1e3..6ad1ed8 100644 --- a/src/app/api/auth/sonar/authorize/route.ts +++ b/src/app/api/auth/sonar/authorize/route.ts @@ -29,5 +29,5 @@ export async function GET() { frontendURL: sonarConfig.frontendURL, }); - return NextResponse.redirect(authorizationUrl.toString()); + return NextResponse.json({ url: authorizationUrl.toString() }); } diff --git a/src/app/components/auth/AuthenticationSection.tsx b/src/app/components/auth/AuthenticationSection.tsx index a36b19a..6ce44c7 100644 --- a/src/app/components/auth/AuthenticationSection.tsx +++ b/src/app/components/auth/AuthenticationSection.tsx @@ -5,8 +5,16 @@ import { useSession } from "@/app/hooks/use-session"; export function AuthenticationSection() { const { authenticated, sonarConnected, loading, login, logout } = useSession(); - const handleConnectSonar = () => { - window.location.href = "/api/auth/sonar/authorize"; + 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; + } + } catch (error) { + console.error("Failed to get Sonar authorization URL:", error); + } }; const handleDisconnectSonar = async () => { From 4701d5ee15c71fc0f5bc5f7d90b1f2a3101436d4 Mon Sep 17 00:00:00 2001 From: Will Sewell Date: Fri, 9 Jan 2026 17:08:09 +0000 Subject: [PATCH 20/20] Review feedback --- .env.local | 3 - README.md | 74 +++++++++++-------- src/app/api/sonar/entities/route.ts | 2 +- src/app/api/sonar/entity/route.ts | 2 +- .../sonar/generate-purchase-permit/route.ts | 2 +- src/app/api/sonar/pre-purchase-check/route.ts | 2 +- src/lib/sonar.ts | 10 +-- 7 files changed, 49 insertions(+), 46 deletions(-) diff --git a/.env.local b/.env.local index 8af9336..0768e79 100644 --- a/.env.local +++ b/.env.local @@ -8,6 +8,3 @@ NEXT_PUBLIC_OAUTH_CLIENT_REDIRECT_URI=http://localhost:3000/oauth/callback # Your sale contract address NEXT_PUBLIC_SALE_CONTRACT_ADDRESS=0x897Cf7E2cAD4f716172CF82edE45eF7956B33Af0 - -NEXTAUTH_SECRET="TODO" # Generate something secure for this -NEXTAUTH_URL=http://localhost:3000 diff --git a/README.md b/README.md index 3225488..d8c40de 100644 --- a/README.md +++ b/README.md @@ -42,37 +42,49 @@ For demonstration purposes, this example uses a minimal session system: The backend handles the complete OAuth flow, storing tokens securely server-side: ``` -┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ -│ Browser │ │ Next.js │ │ Echo │ │ Sonar │ -│ │ │ Backend │ │ OAuth │ │ API │ -└────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ - │ │ │ │ - │ 1. Click "Connect" │ │ │ - ├───────────────────>│ │ │ - │ │ │ │ - │ │ 2. Generate PKCE │ │ - │ │ params & store │ │ - │ │ verifier │ │ - │ │ │ │ - │ 3. Redirect to Echo OAuth │ │ - │<───────────────────┼───────────────────>│ │ - │ │ │ │ - │ 4. User authenticates │ │ - │<─────────────────────────────────-─────>│ │ - │ │ │ │ - │ 5. Redirect with auth code │ │ - │────────────────────┼───────────────────>│ │ - │ │ │ │ - │ │ 6. Exchange code │ │ - │ │ for tokens │ │ - │ │<──────────────────>│ │ - │ │ │ │ - │ │ 7. Store tokens │ │ - │ │ server-side │ │ - │ │ │ │ - │ 8. Success response│ │ │ - │<───────────────────│ │ │ - │ │ │ │ +┌─────────┐ ┌─────────┐ ┌─────────┐ +│ Browser │ │ Next.js │ │ Echo │ +│ │ │ Backend │ │ OAuth │ +└────┬────┘ └────┬────┘ └────┬────┘ + │ │ │ + │ 1. Click "Connect" │ │ + ├────────────────────────────>│ │ + │ │ │ + │ │ 2. Generate PKCE │ + │ │ params & store │ + │ │ verifier │ + │ │ │ + │ 3. Return redirect │ │ + │ URL │ │ + │<────────────────────────────│ │ + │ │ │ + │ 4. Navigate to Echo OAuth │ │ + ├──────────────────────────────────────────────────────────>│ + │ │ │ + │ 5. User authenticates & authorizes │ + │ (interactive session) │ │ + │ │ │ + │ 6. Redirect to callback with auth code │ + │<──────────────────────────────────────────────────────────│ + │ │ │ + │ 7. Send auth code │ │ + │ to backend │ │ + ├────────────────────────────>│ │ + │ │ │ + │ │ 8. Exchange code │ + │ │ for tokens │ + │ ├────────────────────────────>│ + │ │ │ + │ │ 9. Return tokens │ + │ │<────────────────────────────│ + │ │ │ + │ │ 10. Store tokens │ + │ │ server-side │ + │ │ │ + │ 11. Success │ │ + │ response │ │ + │<────────────────────────────│ │ + │ │ │ ``` ### Token Refresh diff --git a/src/app/api/sonar/entities/route.ts b/src/app/api/sonar/entities/route.ts index f1a59df..4dcca54 100644 --- a/src/app/api/sonar/entities/route.ts +++ b/src/app/api/sonar/entities/route.ts @@ -10,7 +10,7 @@ type EntitiesRequest = { * Returns all entities for the authenticated user */ export const POST = createSonarRouteHandler( - async ({ client }, body) => { + async (client, body) => { const { saleUUID } = body; if (!saleUUID) { diff --git a/src/app/api/sonar/entity/route.ts b/src/app/api/sonar/entity/route.ts index 9e3f9c2..f74f955 100644 --- a/src/app/api/sonar/entity/route.ts +++ b/src/app/api/sonar/entity/route.ts @@ -11,7 +11,7 @@ type EntityRequest = { * Proxy request to Sonar ReadEntity endpoint */ export const POST = createSonarRouteHandler( - async ({ client }, body) => { + async (client, body) => { const { saleUUID, walletAddress } = body; if (!saleUUID || !walletAddress) { diff --git a/src/app/api/sonar/generate-purchase-permit/route.ts b/src/app/api/sonar/generate-purchase-permit/route.ts index 14fd6dd..0514413 100644 --- a/src/app/api/sonar/generate-purchase-permit/route.ts +++ b/src/app/api/sonar/generate-purchase-permit/route.ts @@ -11,7 +11,7 @@ type GeneratePurchasePermitRequest = { * Proxy request to Sonar GenerateSalePurchasePermit endpoint */ export const POST = createSonarRouteHandler( - async ({ client }, body) => { + async (client, body) => { const { saleUUID, entityID, walletAddress } = body; if (!saleUUID || !entityID || !walletAddress) { diff --git a/src/app/api/sonar/pre-purchase-check/route.ts b/src/app/api/sonar/pre-purchase-check/route.ts index 739cfe9..7a6cb98 100644 --- a/src/app/api/sonar/pre-purchase-check/route.ts +++ b/src/app/api/sonar/pre-purchase-check/route.ts @@ -11,7 +11,7 @@ type PrePurchaseCheckRequest = { * Proxy request to Sonar PrePurchaseCheck endpoint */ export const POST = createSonarRouteHandler( - async ({ client }, body) => { + async (client, body) => { const { saleUUID, entityID, walletAddress } = body; if (!saleUUID || !entityID || !walletAddress) { diff --git a/src/lib/sonar.ts b/src/lib/sonar.ts index e89bb1f..2e50bee 100644 --- a/src/lib/sonar.ts +++ b/src/lib/sonar.ts @@ -68,13 +68,7 @@ async function refreshSonarToken(sessionId: string, refreshToken: string): Promi } } -export type AuthenticatedContext = { - sessionId: string; - tokens: SonarTokens; - client: SonarClient; -}; - -type RouteHandler = (context: AuthenticatedContext, body: T) => Promise; +type RouteHandler = (client: SonarClient, body: T) => Promise; /** * Creates a Sonar API route handler with authentication, token refresh, and error handling. @@ -108,7 +102,7 @@ export function createSonarRouteHandler(handler: RouteHandler): (request: const body = (await request.json()) as T; const client = createSonarClient(session.userId); - return await handler({ sessionId: session.userId, tokens, client }, body); + return await handler(client, body); } catch (error) { if (error instanceof APIError) { return NextResponse.json({ error: error.message }, { status: error.status });