From 15581d28f7095198916186cc4cd8c6a9015a483d Mon Sep 17 00:00:00 2001 From: khalifa Date: Tue, 23 Jun 2026 03:49:15 -0700 Subject: [PATCH 1/2] closes #21 (Integrate the dashboard with `@guildpass/integration-client`) --- .env.example | 5 ++ README.md | 13 +++- app/api/integration/membership/route.ts | 31 +++++++++ app/api/integration/verify/route.ts | 31 +++++++++ app/dashboard/page.tsx | 58 ++++++++++++++-- lib/api/live.ts | 65 ++++++++++++++++-- lib/api/mock.ts | 9 +++ lib/api/types.ts | 9 +++ lib/integration-client.ts | 91 +++++++++++++++++++++++++ tsconfig.json | 1 + types/integration-client.d.ts | 45 ++++++++++++ 11 files changed, 347 insertions(+), 11 deletions(-) create mode 100644 app/api/integration/membership/route.ts create mode 100644 app/api/integration/verify/route.ts create mode 100644 lib/integration-client.ts create mode 100644 types/integration-client.d.ts diff --git a/.env.example b/.env.example index 0b0be34..0cd77bc 100644 --- a/.env.example +++ b/.env.example @@ -15,6 +15,11 @@ NEXT_PUBLIC_MOCK_MODE=true # Only required when NEXT_PUBLIC_MOCK_MODE is not "true". NEXT_PUBLIC_CORE_API_URL=http://localhost:4000 +# API key for the server-side integration gateway. +# This value is used by the backend-facing route handlers and is never bundled +# into client-side code. +INTEGRATION_API_KEY= + # ── SIWE Authentication (Sign-In with Ethereum) ─────────────────────────────── # The domain field included in the EIP-4361 message. Should match the origin # that the frontend is served from (used by the backend to prevent phishing). diff --git a/README.md b/README.md index 7df2db7..aa4557d 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,7 @@ By default, live mode assumes the backend is running at `http://localhost:4000`. ```bash # Set NEXT_PUBLIC_CORE_API_URL in .env.local if your backend runs on a different port +# Also provide INTEGRATION_API_KEY for the server-side integration gateway npm run dev ``` @@ -97,6 +98,7 @@ Admin actions are protected by [Sign-In with Ethereum (EIP-4361)](https://eips.e | `NEXT_PUBLIC_MOCK_MODE` | No | Set `true` for in-memory mock API; SIWE fully simulated | | `NEXT_PUBLIC_DEMO_MODE` | No | Alias for `NEXT_PUBLIC_MOCK_MODE` | | `NEXT_PUBLIC_CORE_API_URL` | Live mode only | Base URL of the `guildpass-core` access-api | +| `INTEGRATION_API_KEY` | Live mode only | Server-side API key used by `@guildpass/integration-client` through the dashboard gateway | | `NEXT_PUBLIC_SIWE_DOMAIN` | No | Domain field in the EIP-4361 message (defaults to `window.location.host`) | | `NEXT_PUBLIC_SIWE_STATEMENT` | No | Human-readable statement shown in the signed message | @@ -194,7 +196,16 @@ All live requests are sent to `NEXT_PUBLIC_CORE_API_URL` (default `http://localh | `POST` | `/v1/auth/siwe/verify` | — | Verify SIWE signature → token | | `POST` | `/v1/auth/siwe/logout` | Bearer | Invalidate session | -> Path and query parameters are URL-encoded. Backend responses are mapped into frontend types via the response-mapping layer in `lib/api/live.ts`. +### Local dashboard integration gateway + +When live mode is enabled, the dashboard uses server-side route handlers to access `@guildpass/integration-client` without exposing private credentials. + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/api/integration/membership?address=` | Lookup membership by wallet address | +| `GET` | `/api/integration/verify?address=` | Verify wallet status | + +> Path and query parameters are URL-encoded. The integration gateway uses `INTEGRATION_API_KEY` from server environment variables and never exposes it to the browser. --- diff --git a/app/api/integration/membership/route.ts b/app/api/integration/membership/route.ts new file mode 100644 index 0000000..4a7cfc8 --- /dev/null +++ b/app/api/integration/membership/route.ts @@ -0,0 +1,31 @@ +import { NextRequest, NextResponse } from 'next/server' +import { fetchMembershipByWallet } from '@/lib/integration-client' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +export async function GET(req: NextRequest) { + const address = req.nextUrl.searchParams.get('address') + + if (!address) { + return NextResponse.json( + { error: 'Missing required query parameter: address' }, + { status: 400 }, + ) + } + + try { + const membership = await fetchMembershipByWallet(address) + return NextResponse.json(membership) + } catch (error) { + return NextResponse.json( + { + error: + error instanceof Error + ? error.message + : 'Unable to fetch membership information', + }, + { status: 502 }, + ) + } +} diff --git a/app/api/integration/verify/route.ts b/app/api/integration/verify/route.ts new file mode 100644 index 0000000..950592c --- /dev/null +++ b/app/api/integration/verify/route.ts @@ -0,0 +1,31 @@ +import { NextRequest, NextResponse } from 'next/server' +import { verifyWallet } from '@/lib/integration-client' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +export async function GET(req: NextRequest) { + const address = req.nextUrl.searchParams.get('address') + + if (!address) { + return NextResponse.json( + { error: 'Missing required query parameter: address' }, + { status: 400 }, + ) + } + + try { + const verification = await verifyWallet(address) + return NextResponse.json(verification) + } catch (error) { + return NextResponse.json( + { + error: + error instanceof Error + ? error.message + : 'Unable to verify wallet', + }, + { status: 502 }, + ) + } +} diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index 0b57c25..afc449e 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -1,7 +1,7 @@ 'use client' import { useAccount } from 'wagmi' import { useQuery } from '@tanstack/react-query' -import { getApi, type Membership, type Session } from '@/lib/api' +import { getApi, type Membership, type Session, type WalletVerification } from '@/lib/api' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' import Link from 'next/link' @@ -25,7 +25,20 @@ export default function DashboardPage() { queryKey: ["session", address], queryFn: () => getApi(address).getSession(), enabled: !!address, - retry: 1 + retry: 1, + }) + + const { + data: verification, + isLoading: isVerifying, + isError: verifyIsError, + error: verifyError, + refetch: refetchVerification, + } = useQuery({ + queryKey: ['walletVerification', address], + queryFn: () => getApi(address).verifyWallet(address as string), + enabled: !!address, + retry: 1, }) const membership: Membership | undefined = session?.membership @@ -80,10 +93,43 @@ export default function DashboardPage() {
- + {!address ? ( + + ) : isVerifying ? ( + + ) : verifyIsError ? ( + refetchVerification()} + /> + ) : verification ? ( +
+
+ Verification: {verification.verified ? ( + Verified + ) : ( + Not verified + )} +
+ {verification.method ? ( +
+ Method: {verification.method} +
+ ) : null} +
+ Checked: {new Date(verification.checkedAt).toLocaleString()} +
+
+ ) : ( + + )}
diff --git a/lib/api/live.ts b/lib/api/live.ts index 76d4260..7536663 100644 --- a/lib/api/live.ts +++ b/lib/api/live.ts @@ -10,6 +10,7 @@ import { Role, Session, SiweAuthSession, + WalletVerification, BackendSession, BackendMember, BackendResource, @@ -153,6 +154,41 @@ async function getJson(path: string, init?: RequestInit): Promise { return JSON.parse(text) as T } +async function getIntegrationJson(path: string): Promise { + let res: Response + + try { + res = await fetch(path, { + headers: { + 'Content-Type': 'application/json', + }, + }) + } catch (cause) { + throw new ApiError({ + code: 'network_error', + safeMessage: + 'Unable to connect to the integration gateway. Please check your configuration and try again.', + retryable: true, + cause, + }) + } + + if (!res.ok) { + throw createApiError(res.status, await parseErrorBody(res)) + } + + if (res.status === 204 || res.status === 205) { + return {} as T + } + + const text = await res.text() + if (!text.trim()) { + return {} as T + } + + return JSON.parse(text) as T +} + // ── Response mappers ────────────────────────────────────────────────────────── function mapCommunity(raw: BackendSession['community']): Community { @@ -247,7 +283,23 @@ export class LiveAccessApi implements AccessApi { ? `?address=${encodeURIComponent(this.address)}` : '' const raw = await getJson(`/v1/session${addr}`) - return mapSession(raw) + const session = mapSession(raw) + + if (this.address) { + try { + const integrationMembership = await getIntegrationJson( + `/api/integration/membership?address=${encodeURIComponent(this.address)}`, + ) + if (integrationMembership) { + session.membership = integrationMembership + } + } catch { + // If the integration gateway is unavailable, retain the membership data + // returned by the core API rather than failing the entire session. + } + } + + return session } async getCommunity(): Promise { @@ -256,10 +308,15 @@ export class LiveAccessApi implements AccessApi { } async getMembership(address: string): Promise { - const raw = await getJson( - `/v1/members/${encodeURIComponent(address)}/membership`, + return await getIntegrationJson( + `/api/integration/membership?address=${encodeURIComponent(address)}`, + ) + } + + async verifyWallet(address: string): Promise { + return await getIntegrationJson( + `/api/integration/verify?address=${encodeURIComponent(address)}`, ) - return raw ? mapMembership(raw) : null } async getProfile(address: string): Promise { diff --git a/lib/api/mock.ts b/lib/api/mock.ts index ce2595d..3a53be8 100644 --- a/lib/api/mock.ts +++ b/lib/api/mock.ts @@ -27,6 +27,7 @@ import { Role, Session, SiweAuthSession, + WalletVerification, } from './types' const community: Community = { @@ -174,4 +175,12 @@ export class MockAccessApi implements AccessApi { async siweLogout(_token: string): Promise { // No server-side session to invalidate in mock mode } + + async verifyWallet(address: string): Promise { + return { + verified: true, + method: 'mock', + checkedAt: new Date().toISOString(), + } + } } diff --git a/lib/api/types.ts b/lib/api/types.ts index 3989c84..a80b6d6 100644 --- a/lib/api/types.ts +++ b/lib/api/types.ts @@ -37,6 +37,12 @@ export interface Session { community?: Community } +export interface WalletVerification { + verified: boolean + method?: string + checkedAt: string +} + export interface Resource { id: string title: string @@ -177,4 +183,7 @@ export interface AccessApi { siweVerify(message: string, signature: string): Promise /** Invalidate the current server-side session (no-op for stateless JWTs). */ siweLogout(token: string): Promise + + /** Verify a wallet and return a safe verification result. */ + verifyWallet(address: string): Promise } diff --git a/lib/integration-client.ts b/lib/integration-client.ts new file mode 100644 index 0000000..4cec3f6 --- /dev/null +++ b/lib/integration-client.ts @@ -0,0 +1,91 @@ +import type { Membership, WalletVerification } from '@/lib/api/types' + +interface IntegrationClientModule { + IntegrationClient?: any + default?: any +} + +function normalizeMembership(raw: any): Membership { + return { + address: + raw?.address ?? raw?.walletAddress ?? raw?.wallet_address ?? '', + tier: + raw?.tier ?? raw?.membershipTier ?? raw?.membership_tier ?? 'free', + active: + raw?.active ?? raw?.isActive ?? raw?.is_active ?? false, + expiresAt: raw?.expiresAt ?? raw?.expires_at ?? undefined, + } +} + +function normalizeVerification(raw: any): WalletVerification { + return { + verified: + Boolean(raw?.verified ?? raw?.isVerified ?? raw?.verified_status ?? false), + method: + raw?.method ?? raw?.verificationMethod ?? raw?.verification_method, + checkedAt: + raw?.checkedAt ?? raw?.checked_at ?? new Date().toISOString(), + } +} + +async function createIntegrationClient() { + const apiKey = process.env.INTEGRATION_API_KEY + if (!apiKey) { + throw new Error( + 'INTEGRATION_API_KEY is required to initialize @guildpass/integration-client.', + ) + } + + const module = (await import('@guildpass/integration-client')) as IntegrationClientModule + const Client = module.IntegrationClient ?? module.default + + if (typeof Client !== 'function') { + throw new Error( + 'Unable to resolve IntegrationClient from @guildpass/integration-client.', + ) + } + + return new Client({ apiKey }) +} + +function getMembershipMethod(client: any) { + return ( + client.getMembershipByWallet ?? + client.membershipByWallet ?? + client.getMembership ?? + client.membership + ) +} + +function getVerificationMethod(client: any) { + return ( + client.verifyWallet ?? + client.verifyWalletAddress ?? + client.checkWallet ?? + client.verify + ) +} + +export async function fetchMembershipByWallet(address: string): Promise { + const client = await createIntegrationClient() + const method = getMembershipMethod(client) + + if (typeof method !== 'function') { + throw new Error('IntegrationClient does not expose a wallet membership lookup method.') + } + + const raw = await method.call(client, address) + return raw ? normalizeMembership(raw) : null +} + +export async function verifyWallet(address: string): Promise { + const client = await createIntegrationClient() + const method = getVerificationMethod(client) + + if (typeof method !== 'function') { + throw new Error('IntegrationClient does not expose a wallet verification method.') + } + + const raw = await method.call(client, address) + return normalizeVerification(raw) +} diff --git a/tsconfig.json b/tsconfig.json index 6fe4463..c198117 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -36,6 +36,7 @@ "next-env.d.ts", "**/*.ts", "**/*.tsx", + "**/*.d.ts", ".next/types/**/*.ts" ], "exclude": [ diff --git a/types/integration-client.d.ts b/types/integration-client.d.ts new file mode 100644 index 0000000..8e902ec --- /dev/null +++ b/types/integration-client.d.ts @@ -0,0 +1,45 @@ +declare module '@guildpass/integration-client' { + export interface IntegrationClientOptions { + apiKey: string + } + + export interface IntegrationMembershipResponse { + address?: string + walletAddress?: string + wallet_address?: string + tier?: string + membershipTier?: string + membership_tier?: string + active?: boolean + isActive?: boolean + is_active?: boolean + expiresAt?: string + expires_at?: string + } + + export interface IntegrationWalletVerificationResponse { + verified?: boolean + isVerified?: boolean + verified_status?: boolean + method?: string + verificationMethod?: string + verification_method?: string + checkedAt?: string + checked_at?: string + } + + export class IntegrationClient { + constructor(options: IntegrationClientOptions) + getMembershipByWallet?(walletAddress: string): Promise + membershipByWallet?(walletAddress: string): Promise + getMembership?(walletAddress: string): Promise + membership?(walletAddress: string): Promise + + verifyWallet?(walletAddress: string): Promise + verifyWalletAddress?(walletAddress: string): Promise + checkWallet?(walletAddress: string): Promise + verify?(walletAddress: string): Promise + } + + export default IntegrationClient +} From efcabeea0c999cabc15a5d1a101917896c69a679 Mon Sep 17 00:00:00 2001 From: khalifa Date: Tue, 23 Jun 2026 04:14:15 -0700 Subject: [PATCH 2/2] closes #21(Add session persistence and refresh handling) --- lib/api/mock.ts | 15 ++++++++++- lib/session.ts | 11 ++++++++ lib/wallet/providers.tsx | 58 +++++++++++++++++++++++++++++++++++++++- 3 files changed, 82 insertions(+), 2 deletions(-) diff --git a/lib/api/mock.ts b/lib/api/mock.ts index 3a53be8..baaaecc 100644 --- a/lib/api/mock.ts +++ b/lib/api/mock.ts @@ -86,6 +86,15 @@ export class MockAccessApi implements AccessApi { // ── Read-only ────────────────────────────────────────────────────────────── async getSession(): Promise { + const MOCK_SESSION_STATE = process.env.NEXT_PUBLIC_MOCK_SESSION_STATE || 'valid' + if (MOCK_SESSION_STATE === 'cleared') { + return { + // No authenticated session + roles: [], + community, + } + } + const data = ensureAddress(this.address) return { address: this.address, @@ -162,7 +171,11 @@ export class MockAccessApi implements AccessApi { * deliberately fake ("mock-jwt-…") so it cannot be confused with a real token. */ async siweVerify(_message: string, _signature: string): Promise { - const expiresAt = new Date(Date.now() + 60 * 60 * 1000).toISOString() + const MOCK_SESSION_STATE = process.env.NEXT_PUBLIC_MOCK_SESSION_STATE || 'valid' + const expiresAt = + MOCK_SESSION_STATE === 'expired' + ? new Date(Date.now() - 5 * 60 * 1000).toISOString() + : new Date(Date.now() + 60 * 60 * 1000).toISOString() return { isAuthenticated: true, token: `mock-jwt-${randomHex()}`, diff --git a/lib/session.ts b/lib/session.ts index 39ee0b4..ca1dd74 100644 --- a/lib/session.ts +++ b/lib/session.ts @@ -51,7 +51,18 @@ export function clearAuthSession(): void { if (typeof window === 'undefined') return try { window.sessionStorage.removeItem(SESSION_KEY) + try { + // Notify listeners (SiweAuthProvider) that the session was cleared/invalidated. + window.dispatchEvent(new CustomEvent('siwe:invalidated')) + } catch { + // Ignore environments that disallow CustomEvent + } } catch { // Silently ignore } } + +/** Convenience to clear session and notify listeners explicitly. */ +export function invalidateAuthSession(): void { + clearAuthSession() +} diff --git a/lib/wallet/providers.tsx b/lib/wallet/providers.tsx index 8e987cd..54eb6ab 100644 --- a/lib/wallet/providers.tsx +++ b/lib/wallet/providers.tsx @@ -30,6 +30,7 @@ import { QueryClient, QueryClientProvider, useQueryClient } from '@tanstack/reac import { getApi } from '@/lib/api' import { SiweAuthSession } from '@/lib/api/types' import { clearAuthSession, loadAuthSession, storeAuthSession } from '@/lib/session' +import { isApiError } from '@/lib/api/errors' import { accessKeys } from '@/lib/query' // ── Wagmi config (unchanged) ────────────────────────────────────────────────── @@ -105,6 +106,29 @@ function SiweAuthProvider({ children }: PropsWithChildren) { } }, [address, authSession, queryClient]) + // Respond to external invalidation events (e.g. 401 detected globally) + useEffect(() => { + const handler = () => { + setAuthSession(null) + setError('Session expired. Please sign in again.') + try { + clearAuthSession() + } catch { + // ignore + } + try { + disconnect() + } catch { + // ignore + } + queryClient.removeQueries({ queryKey: ['session'] }) + queryClient.removeQueries({ queryKey: accessKeys.all }) + } + + window.addEventListener('siwe:invalidated', handler) + return () => window.removeEventListener('siwe:invalidated', handler) + }, [disconnect, queryClient]) + const signIn = useCallback(async () => { if (!address) { setError('Connect your wallet before signing in.') @@ -189,7 +213,39 @@ function SiweAuthProvider({ children }: PropsWithChildren) { // ── Root providers (public export, used in app/layout.tsx) ─────────────────── export function RootProviders({ children }: PropsWithChildren) { - const [queryClient] = useState(() => new QueryClient()) + const [queryClient] = useState(() => + new QueryClient({ + defaultOptions: { + queries: { + onError: (err: unknown) => { + try { + if (isApiError(err) && err.code === 'unauthorized') { + // Clear persisted session and cached queries on 401 so UI resets + clearAuthSession() + // best-effort: remove session-related cache + // Note: QueryClient instance is available as `queryClient` here, + // but removing queries from within the constructor callback is + // not supported — we'll remove them after creation below. + } + } catch { + // ignore + } + }, + }, + }, + }), + ) + + // After creating the client, ensure session-related queries are cleared + // when we detect an unauthorized error via the onError hook above. + useEffect(() => { + const handler = () => { + queryClient.removeQueries({ queryKey: ['session'] }) + queryClient.removeQueries({ queryKey: accessKeys.all }) + } + window.addEventListener('siwe:invalidated', handler) + return () => window.removeEventListener('siwe:invalidated', handler) + }, [queryClient]) return (