Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down Expand Up @@ -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 |

Expand Down Expand Up @@ -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=<wallet>` | Lookup membership by wallet address |
| `GET` | `/api/integration/verify?address=<wallet>` | 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.

---

Expand Down
31 changes: 31 additions & 0 deletions app/api/integration/membership/route.ts
Original file line number Diff line number Diff line change
@@ -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 },
)
}
}
31 changes: 31 additions & 0 deletions app/api/integration/verify/route.ts
Original file line number Diff line number Diff line change
@@ -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 },
)
}
}
58 changes: 52 additions & 6 deletions app/dashboard/page.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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<WalletVerification>({
queryKey: ['walletVerification', address],
queryFn: () => getApi(address).verifyWallet(address as string),
enabled: !!address,
retry: 1,
})

const membership: Membership | undefined = session?.membership
Expand Down Expand Up @@ -80,10 +93,43 @@ export default function DashboardPage() {
</Section>

<Section title="Profile Summary">
<EmptyState
title="Profile details unavailable"
message="Basic profile details will appear here when they are available."
/>
{!address ? (
<DeniedState
title="Wallet connection required"
message="Connect your wallet to load your profile and verification state."
/>
) : isVerifying ? (
<LoadingState />
) : verifyIsError ? (
<ErrorState
title="Wallet verification failed"
message={safeErrorMessage(verifyError)}
onRetry={() => refetchVerification()}
/>
) : verification ? (
<div className="space-y-2">
<div className="text-sm text-muted-foreground">
Verification: {verification.verified ? (
<Badge variant="success">Verified</Badge>
) : (
<Badge variant="destructive">Not verified</Badge>
)}
</div>
{verification.method ? (
<div className="text-sm text-muted-foreground">
Method: {verification.method}
</div>
) : null}
<div className="text-sm text-muted-foreground">
Checked: {new Date(verification.checkedAt).toLocaleString()}
</div>
</div>
) : (
<EmptyState
title="Profile details unavailable"
message="Basic profile details will appear here when they are available."
/>
)}
</Section>

<Section title="Badges">
Expand Down
65 changes: 61 additions & 4 deletions lib/api/live.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
Role,
Session,
SiweAuthSession,
WalletVerification,
BackendSession,
BackendMember,
BackendResource,
Expand Down Expand Up @@ -153,6 +154,41 @@ async function getJson<T>(path: string, init?: RequestInit): Promise<T> {
return JSON.parse(text) as T
}

async function getIntegrationJson<T>(path: string): Promise<T> {
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 {
Expand Down Expand Up @@ -247,7 +283,23 @@ export class LiveAccessApi implements AccessApi {
? `?address=${encodeURIComponent(this.address)}`
: ''
const raw = await getJson<BackendSession>(`/v1/session${addr}`)
return mapSession(raw)
const session = mapSession(raw)

if (this.address) {
try {
const integrationMembership = await getIntegrationJson<Membership | null>(
`/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<Community> {
Expand All @@ -256,10 +308,15 @@ export class LiveAccessApi implements AccessApi {
}

async getMembership(address: string): Promise<Membership | null> {
const raw = await getJson<BackendMember | null>(
`/v1/members/${encodeURIComponent(address)}/membership`,
return await getIntegrationJson<Membership | null>(
`/api/integration/membership?address=${encodeURIComponent(address)}`,
)
}

async verifyWallet(address: string): Promise<WalletVerification> {
return await getIntegrationJson<WalletVerification>(
`/api/integration/verify?address=${encodeURIComponent(address)}`,
)
return raw ? mapMembership(raw) : null
}

async getProfile(address: string): Promise<MemberProfile | null> {
Expand Down
24 changes: 23 additions & 1 deletion lib/api/mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
Role,
Session,
SiweAuthSession,
WalletVerification,
} from './types'

const community: Community = {
Expand Down Expand Up @@ -85,6 +86,15 @@ export class MockAccessApi implements AccessApi {
// ── Read-only ──────────────────────────────────────────────────────────────

async getSession(): Promise<Session> {
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,
Expand Down Expand Up @@ -161,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<SiweAuthSession> {
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()}`,
Expand All @@ -174,4 +188,12 @@ export class MockAccessApi implements AccessApi {
async siweLogout(_token: string): Promise<void> {
// No server-side session to invalidate in mock mode
}

async verifyWallet(address: string): Promise<WalletVerification> {
return {
verified: true,
method: 'mock',
checkedAt: new Date().toISOString(),
}
}
}
9 changes: 9 additions & 0 deletions lib/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -177,4 +183,7 @@ export interface AccessApi {
siweVerify(message: string, signature: string): Promise<SiweAuthSession>
/** Invalidate the current server-side session (no-op for stateless JWTs). */
siweLogout(token: string): Promise<void>

/** Verify a wallet and return a safe verification result. */
verifyWallet(address: string): Promise<WalletVerification>
}
Loading