diff --git a/AGENTS.md b/AGENTS.md index 31a07f9..4e9bc7f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,23 +20,29 @@ These instructions apply to the entire repository. root package. Keep `packages: ["."]` if the file remains present. - UI is built with Chakra UI plus RainbowKit, Wagmi, Viem, TanStack Query, and React Icons. -- The main page gates access by connected wallet, Gnosis-chain token balance, - and a signed message. -- API routes under `app/api/` verify the signed message server-side, fetch - membership data from an external subgraph, and create S3 signed URLs. +- The main page gates access with a connected wallet and an EIP-4361 sign-in, + then restores a short-lived member session from an HttpOnly cookie. +- API routes under `app/api/` verify SIWE challenges, retain signed sessions, + fetch membership data from the DAOhaus subgraph, and create S3 signed URLs. ## Repository Map -- `app/page.tsx`: client flow for wallet connection, balance check, message - signing, file list fetching, and file link requests. +- `app/page.tsx`: client flow for wallet connection, SIWE, retained-session + restoration, file list fetching, and file link requests. - `app/layout.tsx`: global providers, RainbowKit/Wagmi setup, Google font, and page frame. - `app/api/files/route.ts`: verifies membership and returns available S3 objects. - `app/api/channel/route.ts`: verifies membership and returns a short-lived signed URL for one S3 object. -- `app/api/shared/memberAuth.ts`: shared request validation, message constant, - member query, and membership error text. +- `app/api/auth/`: SIWE challenge, verification, retained-session, and logout + route handlers. +- `app/api/shared/memberAuth.ts`: shared request validation, DAOhaus member + query with the 100-share threshold, and session authorization. +- `app/api/shared/session.ts`: signed challenge/session tokens and secure cookie + helpers backed by `JWT_SECRET`. +- `app/api/shared/authRateLimit.ts`: process-local authentication and RPC + throttling, including `429` responses with `Retry-After`. - `app/config.ts`: server-side S3 client configuration from environment variables. - `app/utils/requests.ts`: client request helpers and API error normalization. @@ -69,6 +75,9 @@ Known environment variable names: - `S3_SECRET` - `THE_GRAPH_API_KEY` - `JWT_SECRET` +- `GNOSIS_RPC_URL` + +`JWT_SECRET` must be a cryptographically random value of at least 32 bytes. Document variable names when needed, but never document secret values. diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts new file mode 100644 index 0000000..b678dca --- /dev/null +++ b/app/api/auth/logout/route.ts @@ -0,0 +1,19 @@ +import { NextResponse } from "next/server"; + +import { clearAuthCookies, getSameOrigin } from "../../shared/session"; + +export async function POST(request: Request) { + if (!getSameOrigin(request)) { + return NextResponse.json( + { error: "Invalid request origin" }, + { status: 403 }, + ); + } + + const response = NextResponse.json( + { authenticated: false }, + { headers: { "Cache-Control": "no-store" } }, + ); + clearAuthCookies(response); + return response; +} diff --git a/app/api/auth/message/route.ts b/app/api/auth/message/route.ts new file mode 100644 index 0000000..ca6489f --- /dev/null +++ b/app/api/auth/message/route.ts @@ -0,0 +1,96 @@ +import { NextResponse } from "next/server"; +import { getAddress, isAddress } from "viem"; +import { createSiweMessage, generateSiweNonce } from "viem/siwe"; +import { gnosis } from "viem/chains"; + +import { + authRateLimitResponse, + checkAuthGlobalBudget, + checkAuthRateLimit, +} from "../../shared/authRateLimit"; +import { + createChallengeToken, + getMessageDigest, + getSameOrigin, + setChallengeCookie, +} from "../../shared/session"; + +type MessageRequestBody = { + address: string; + chainId: number; +}; + +function isMessageRequestBody(value: unknown): value is MessageRequestBody { + if (!value || typeof value !== "object") return false; + + const candidate = value as Partial; + return ( + typeof candidate.address === "string" && + isAddress(candidate.address) && + candidate.chainId === gnosis.id + ); +} + +export async function POST(request: Request) { + const origin = getSameOrigin(request); + if (!origin) { + return NextResponse.json( + { error: "Invalid request origin" }, + { status: 403 }, + ); + } + + let body: MessageRequestBody; + + try { + const parsed = (await request.json()) as unknown; + if (!isMessageRequestBody(parsed)) { + return NextResponse.json( + { error: "Connect a wallet on Gnosis Chain to continue." }, + { status: 400 }, + ); + } + body = parsed; + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + + const address = getAddress(body.address); + const rateLimit = checkAuthRateLimit("message", address, 30); + if (!rateLimit.allowed) { + return authRateLimitResponse(rateLimit.retryAfterSeconds); + } + + const globalLimit = checkAuthGlobalBudget("message", 300); + if (!globalLimit.allowed) { + return authRateLimitResponse(globalLimit.retryAfterSeconds); + } + + const nonce = generateSiweNonce(); + const now = new Date(); + const originUrl = new URL(origin); + const message = createSiweMessage({ + address, + chainId: gnosis.id, + domain: originUrl.host, + expirationTime: new Date(now.getTime() + 5 * 60 * 1000), + issuedAt: now, + nonce, + scheme: originUrl.protocol.slice(0, -1), + statement: "Sign in to the RaidGuild Guild Archive.", + uri: origin, + version: "1", + }); + const challengeToken = await createChallengeToken({ + address, + messageDigest: getMessageDigest(message), + nonce, + }); + const response = NextResponse.json( + { message }, + { headers: { "Cache-Control": "no-store" } }, + ); + + setChallengeCookie(response, challengeToken); + return response; +} diff --git a/app/api/auth/session/route.ts b/app/api/auth/session/route.ts new file mode 100644 index 0000000..2b9f19f --- /dev/null +++ b/app/api/auth/session/route.ts @@ -0,0 +1,59 @@ +import { NextResponse } from "next/server"; + +import { + authRateLimitResponse, + checkAuthRateLimit, +} from "../../shared/authRateLimit"; +import { + isEligibleMemberAddress, + logServerError, +} from "../../shared/memberAuth"; +import { + clearSessionCookie, + createSessionToken, + readSessionAddress, + setSessionCookie, +} from "../../shared/session"; + +export async function GET() { + const address = await readSessionAddress(); + + if (!address) { + const response = NextResponse.json( + { authenticated: false }, + { headers: { "Cache-Control": "no-store" } }, + ); + clearSessionCookie(response); + return response; + } + + try { + const rateLimit = checkAuthRateLimit("session", address, 60); + if (!rateLimit.allowed) { + return authRateLimitResponse(rateLimit.retryAfterSeconds); + } + + if (!(await isEligibleMemberAddress(address))) { + const response = NextResponse.json( + { authenticated: false }, + { headers: { "Cache-Control": "no-store" } }, + ); + clearSessionCookie(response); + return response; + } + + const refreshedToken = await createSessionToken(address); + const response = NextResponse.json( + { authenticated: true, address }, + { headers: { "Cache-Control": "no-store" } }, + ); + setSessionCookie(response, refreshedToken); + return response; + } catch (error: unknown) { + logServerError("Error restoring wallet session", error); + return NextResponse.json( + { error: "Unable to restore your session right now." }, + { status: 500, headers: { "Cache-Control": "no-store" } }, + ); + } +} diff --git a/app/api/auth/verify/route.ts b/app/api/auth/verify/route.ts new file mode 100644 index 0000000..d7017ae --- /dev/null +++ b/app/api/auth/verify/route.ts @@ -0,0 +1,203 @@ +import { NextResponse } from "next/server"; +import { + createPublicClient, + getAddress, + http, + isAddressEqual, + isHex, + type Hex, + verifyMessage, +} from "viem"; +import { + parseSiweMessage, + validateSiweMessage, + verifySiweMessage, +} from "viem/siwe"; +import { gnosis } from "viem/chains"; + +import { CONFIG } from "../../../config"; +import { + authRateLimitResponse, + checkAuthRateLimit, + checkAuthRpcBudget, +} from "../../shared/authRateLimit"; +import { + isEligibleMemberAddress, + logServerError, + NOT_MEMBER_ERROR, +} from "../../shared/memberAuth"; +import { + clearChallengeCookie, + createSessionToken, + getMessageDigest, + getSameOrigin, + readChallenge, + setSessionCookie, +} from "../../shared/session"; + +type VerifyRequestBody = { + message: string; + signature: Hex; +}; + +const gnosisClient = createPublicClient({ + chain: gnosis, + transport: http(CONFIG.GNOSIS_RPC_URL?.trim() || undefined, { + retryCount: 1, + timeout: 5_000, + }), +}); + +function isVerifyRequestBody(value: unknown): value is VerifyRequestBody { + if (!value || typeof value !== "object") return false; + + const candidate = value as Partial; + return ( + typeof candidate.message === "string" && + candidate.message.length > 0 && + candidate.message.length <= 4096 && + typeof candidate.signature === "string" && + candidate.signature.length <= 4096 && + isHex(candidate.signature) + ); +} + +export async function POST(request: Request) { + const origin = getSameOrigin(request); + if (!origin) { + return NextResponse.json( + { error: "Invalid request origin" }, + { status: 403 }, + ); + } + + let body: VerifyRequestBody; + + try { + const parsed = (await request.json()) as unknown; + if (!isVerifyRequestBody(parsed)) { + return NextResponse.json( + { error: "Invalid verification request" }, + { status: 400 }, + ); + } + body = parsed; + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + + const challenge = await readChallenge(); + if ( + !challenge || + challenge.messageDigest !== getMessageDigest(body.message) + ) { + return NextResponse.json( + { error: "Your sign-in request expired. Please try again." }, + { status: 401 }, + ); + } + + const rateLimit = checkAuthRateLimit( + "verify", + getAddress(challenge.address), + 20, + ); + if (!rateLimit.allowed) { + return authRateLimitResponse(rateLimit.retryAfterSeconds); + } + + let parsedMessage: ReturnType; + + try { + parsedMessage = parseSiweMessage(body.message); + } catch { + return NextResponse.json( + { error: "Invalid sign-in message" }, + { status: 401 }, + ); + } + const requestUrl = new URL(origin); + const messageIsValid = + parsedMessage.address !== undefined && + parsedMessage.chainId === gnosis.id && + parsedMessage.uri === origin && + parsedMessage.version === "1" && + isAddressEqual(parsedMessage.address, getAddress(challenge.address)) && + validateSiweMessage({ + address: getAddress(challenge.address), + domain: requestUrl.host, + message: parsedMessage, + nonce: challenge.nonce, + scheme: requestUrl.protocol.slice(0, -1), + }); + + if (!messageIsValid || !parsedMessage.address) { + return NextResponse.json( + { error: "Invalid sign-in message" }, + { status: 401 }, + ); + } + + try { + let signatureIsValid = false; + + try { + signatureIsValid = await verifyMessage({ + address: parsedMessage.address, + message: body.message, + signature: body.signature, + }); + } catch { + // Contract-account signatures can be longer than recoverable EOA + // signatures, so local recovery failure must still reach ERC-1271/6492. + } + + if (!signatureIsValid) { + const rpcBudget = checkAuthRpcBudget( + getAddress(parsedMessage.address), + ); + if (!rpcBudget.allowed) { + return authRateLimitResponse(rpcBudget.retryAfterSeconds); + } + + signatureIsValid = await verifySiweMessage(gnosisClient, { + address: parsedMessage.address, + domain: requestUrl.host, + message: body.message, + nonce: challenge.nonce, + scheme: requestUrl.protocol.slice(0, -1), + signature: body.signature, + }); + } + + if (!signatureIsValid) { + return NextResponse.json( + { error: "Invalid wallet signature" }, + { status: 401 }, + ); + } + + if (!(await isEligibleMemberAddress(parsedMessage.address))) { + return NextResponse.json( + { error: NOT_MEMBER_ERROR }, + { status: 403 }, + ); + } + + const sessionToken = await createSessionToken(parsedMessage.address); + const response = NextResponse.json( + { authenticated: true, address: getAddress(parsedMessage.address) }, + { headers: { "Cache-Control": "no-store" } }, + ); + + clearChallengeCookie(response); + setSessionCookie(response, sessionToken); + return response; + } catch (error: unknown) { + logServerError("Error verifying wallet session", error); + return NextResponse.json( + { error: "Unable to verify membership right now." }, + { status: 500 }, + ); + } +} diff --git a/app/api/channel/route.ts b/app/api/channel/route.ts index eb7b3ac..129bae9 100644 --- a/app/api/channel/route.ts +++ b/app/api/channel/route.ts @@ -1,19 +1,38 @@ import { GetObjectCommand } from "@aws-sdk/client-s3"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; -import { verifyMessage } from "ethers"; import { NextResponse } from "next/server"; import { getS3Bucket, s3Client } from "../../config"; import { - MEMBER_SIGN_MESSAGE, - NOT_MEMBER_ERROR, type ChannelRequestBody, - fetchMemberAddresses, isChannelRequestBody, logServerError, + memberSessionErrorResponse, + requireMemberSession, } from "../shared/memberAuth"; +import { getSameOrigin } from "../shared/session"; export async function POST(req: Request) { + if (!getSameOrigin(req)) { + return NextResponse.json( + { error: "Invalid request origin" }, + { status: 403, headers: { "Cache-Control": "no-store" } }, + ); + } + + try { + await requireMemberSession(); + } catch (error: unknown) { + const sessionErrorResponse = memberSessionErrorResponse(error); + if (sessionErrorResponse) return sessionErrorResponse; + + logServerError("Error authorizing channel request", error); + return NextResponse.json( + { error: "Failed to fetch data" }, + { status: 500, headers: { "Cache-Control": "no-store" } }, + ); + } + let requestBody: ChannelRequestBody; try { @@ -21,48 +40,40 @@ export async function POST(req: Request) { if (!isChannelRequestBody(parsed)) { return NextResponse.json( { error: "Invalid request body" }, - { status: 400 }, + { status: 400, headers: { "Cache-Control": "no-store" } }, ); } requestBody = parsed; } catch { - return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); - } - - let address: string; - - try { - address = verifyMessage(MEMBER_SIGN_MESSAGE, requestBody.signature); - } catch { - return NextResponse.json({ error: "Invalid signature" }, { status: 400 }); + return NextResponse.json( + { error: "Invalid JSON" }, + { status: 400, headers: { "Cache-Control": "no-store" } }, + ); } try { - const members = await fetchMemberAddresses(); + const bucketParams = { + Bucket: getS3Bucket(), + Key: requestBody.key, + }; - if (members.includes(address.toLowerCase())) { - const bucketParams = { - Bucket: getS3Bucket(), - Key: requestBody.key, - }; - - const url = await getSignedUrl( - s3Client, - new GetObjectCommand(bucketParams), - { - expiresIn: 15 * 60, - }, - ); + const url = await getSignedUrl( + s3Client, + new GetObjectCommand(bucketParams), + { + expiresIn: 15 * 60, + }, + ); - return NextResponse.json({ channel: url }); - } else { - return NextResponse.json({ error: NOT_MEMBER_ERROR }, { status: 403 }); - } + return NextResponse.json( + { channel: url }, + { headers: { "Cache-Control": "no-store" } }, + ); } catch (error: unknown) { logServerError("Error fetching channel", error); return NextResponse.json( { error: "Failed to fetch data" }, - { status: 500 }, + { status: 500, headers: { "Cache-Control": "no-store" } }, ); } } diff --git a/app/api/files/route.ts b/app/api/files/route.ts index c00c9c2..9f2b215 100644 --- a/app/api/files/route.ts +++ b/app/api/files/route.ts @@ -2,17 +2,14 @@ import { ListObjectsV2Command, type ListObjectsV2CommandOutput, } from "@aws-sdk/client-s3"; -import { verifyMessage } from "ethers"; import { NextResponse } from "next/server"; import { getS3Bucket, s3Client } from "../../config"; import { - MEMBER_SIGN_MESSAGE, - NOT_MEMBER_ERROR, - type SignatureRequestBody, - fetchMemberAddresses, - isSignatureRequestBody, logServerError, + memberSessionErrorResponse, + requireMemberSession, } from "../shared/memberAuth"; +import { getSameOrigin } from "../shared/session"; const S3_LIST_PAGE_SIZE = 500; const S3_LIST_MAX_PAGES = 10; @@ -39,64 +36,52 @@ function addValhallaFiles( } } -export async function POST(req: Request) { - let requestBody: SignatureRequestBody; - - try { - const parsed = (await req.json()) as unknown; - if (!isSignatureRequestBody(parsed)) { - return NextResponse.json( - { error: "Invalid request body" }, - { status: 400 }, - ); - } - - requestBody = parsed; - } catch { - return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); +export async function POST(request: Request) { + if (!getSameOrigin(request)) { + return NextResponse.json( + { error: "Invalid request origin" }, + { status: 403, headers: { "Cache-Control": "no-store" } }, + ); } - let address: string; - try { - address = verifyMessage(MEMBER_SIGN_MESSAGE, requestBody.signature); - } catch { - return NextResponse.json({ error: "Invalid signature" }, { status: 400 }); - } - - try { - const members = await fetchMemberAddresses(); - - if (members.includes(address.toLowerCase())) { - const bucketParams = { Bucket: getS3Bucket() }; - const files: ValhallaFile[] = []; - let continuationToken: string | undefined; - let pagesFetched = 0; - - do { - if (pagesFetched >= S3_LIST_MAX_PAGES) { - throw new Error("S3 file listing exceeded maximum page count"); - } - - const data: ListObjectsV2CommandOutput = await s3Client.send( - new ListObjectsV2Command({ - ...bucketParams, - MaxKeys: S3_LIST_PAGE_SIZE, - ContinuationToken: continuationToken, - }), - ); + await requireMemberSession(); + + const bucketParams = { Bucket: getS3Bucket() }; + const files: ValhallaFile[] = []; + let continuationToken: string | undefined; + let pagesFetched = 0; + + do { + if (pagesFetched >= S3_LIST_MAX_PAGES) { + throw new Error("S3 file listing exceeded maximum page count"); + } + + const data: ListObjectsV2CommandOutput = await s3Client.send( + new ListObjectsV2Command({ + ...bucketParams, + MaxKeys: S3_LIST_PAGE_SIZE, + ContinuationToken: continuationToken, + }), + ); - pagesFetched += 1; - addValhallaFiles(files, data.Contents); - continuationToken = data.NextContinuationToken; - } while (continuationToken); + pagesFetched += 1; + addValhallaFiles(files, data.Contents); + continuationToken = data.NextContinuationToken; + } while (continuationToken); - return NextResponse.json({ response: files }); - } else { - return NextResponse.json({ error: NOT_MEMBER_ERROR }, { status: 403 }); - } + return NextResponse.json( + { response: files }, + { headers: { "Cache-Control": "no-store" } }, + ); } catch (error: unknown) { + const sessionErrorResponse = memberSessionErrorResponse(error); + if (sessionErrorResponse) return sessionErrorResponse; + logServerError("Error fetching files", error); - return NextResponse.json({ error: "An error occurred." }, { status: 500 }); + return NextResponse.json( + { error: "An error occurred." }, + { status: 500, headers: { "Cache-Control": "no-store" } }, + ); } } diff --git a/app/api/shared/authRateLimit.ts b/app/api/shared/authRateLimit.ts new file mode 100644 index 0000000..e8c99fd --- /dev/null +++ b/app/api/shared/authRateLimit.ts @@ -0,0 +1,130 @@ +import { NextResponse } from "next/server"; + +const AUTH_RATE_LIMIT_WINDOW_MS = 60 * 1000; +const AUTH_RATE_LIMIT_MAX_KEYS = 5_000; + +type RateLimitEntry = { + count: number; + resetAt: number; +}; + +type AuthRateLimitScope = "message" | "rpc" | "session" | "verify"; +type GlobalBudgetScope = "message" | "rpc"; + +// These counters are an intentional process-local first defense: they reset on +// cold starts and multiply across instances. Horizontally scaled deployments +// should also enforce shared or edge/WAF limits. +const rateLimitEntries = new Map< + AuthRateLimitScope, + Map +>(); +const globalBudgets = new Map(); +let nextCleanupAt = 0; + +function cleanupExpiredEntries(now: number) { + if (now < nextCleanupAt) return; + + for (const entries of rateLimitEntries.values()) { + for (const [key, entry] of entries) { + if (entry.resetAt <= now) entries.delete(key); + } + } + + for (const [scope, entry] of globalBudgets) { + if (entry.resetAt <= now) globalBudgets.delete(scope); + } + + nextCleanupAt = now + AUTH_RATE_LIMIT_WINDOW_MS; +} + +export function checkAuthRateLimit( + scope: AuthRateLimitScope, + identity: string, + limit: number, +) { + const now = Date.now(); + cleanupExpiredEntries(now); + + let entries = rateLimitEntries.get(scope); + if (!entries) { + entries = new Map(); + rateLimitEntries.set(scope, entries); + } + + const existing = entries.get(identity); + + if (!existing || existing.resetAt <= now) { + if (entries.size >= AUTH_RATE_LIMIT_MAX_KEYS) { + const oldestKey = entries.keys().next().value; + if (oldestKey !== undefined) entries.delete(oldestKey); + } + + entries.set(identity, { + count: 1, + resetAt: now + AUTH_RATE_LIMIT_WINDOW_MS, + }); + return { allowed: true, retryAfterSeconds: 0 }; + } + + if (existing.count >= limit) { + return { + allowed: false, + retryAfterSeconds: Math.max( + 1, + Math.ceil((existing.resetAt - now) / 1000), + ), + }; + } + + existing.count += 1; + return { allowed: true, retryAfterSeconds: 0 }; +} + +export function checkAuthGlobalBudget( + scope: GlobalBudgetScope, + limit: number, +) { + const now = Date.now(); + const budget = globalBudgets.get(scope); + + if (!budget || budget.resetAt <= now) { + globalBudgets.set(scope, { + count: 1, + resetAt: now + AUTH_RATE_LIMIT_WINDOW_MS, + }); + return { allowed: true, retryAfterSeconds: 0 }; + } + + if (budget.count >= limit) { + return { + allowed: false, + retryAfterSeconds: Math.max( + 1, + Math.ceil((budget.resetAt - now) / 1000), + ), + }; + } + + budget.count += 1; + return { allowed: true, retryAfterSeconds: 0 }; +} + +export function checkAuthRpcBudget(identity: string) { + const identityBudget = checkAuthRateLimit("rpc", identity, 5); + if (!identityBudget.allowed) return identityBudget; + + return checkAuthGlobalBudget("rpc", 120); +} + +export function authRateLimitResponse(retryAfterSeconds: number) { + return NextResponse.json( + { error: "Too many requests. Please wait and try again." }, + { + headers: { + "Cache-Control": "no-store", + "Retry-After": String(retryAfterSeconds), + }, + status: 429, + }, + ); +} diff --git a/app/api/shared/memberAuth.ts b/app/api/shared/memberAuth.ts index 40bb192..10798f8 100644 --- a/app/api/shared/memberAuth.ts +++ b/app/api/shared/memberAuth.ts @@ -1,15 +1,12 @@ import axios from "axios"; +import { NextResponse } from "next/server"; import { CONFIG } from "../../config"; +import { readSessionAddress } from "./session"; -export const MEMBER_SIGN_MESSAGE = "gm raidguild member"; export const NOT_MEMBER_ERROR = - "Your wallet address is not a RaidGuild member."; + "This wallet does not hold at least 100 RaidGuild shares."; -export type SignatureRequestBody = { - signature: string; -}; - -export type ChannelRequestBody = SignatureRequestBody & { +export type ChannelRequestBody = { key: string; }; @@ -25,8 +22,9 @@ const MEMBER_ADDRESSES_PAGE_SIZE = 400; const MEMBER_ADDRESSES_MAX_PAGES = 25; const MEMBER_ADDRESSES_CACHE_TTL_MS = 5 * 60 * 1000; const MEMBER_ADDRESSES_REQUEST_TIMEOUT_MS = 10 * 1000; -const MEMBERS_SUBGRAPH_ID = - "6x9FK3iuhVFaH9sZ39m8bKB5eckax8sjxooBPNKWWK8r"; +// DAOhaus indexes the raw 18-decimal ERC-20 balance: 100 shares = 100 * 10^18. +const MEMBERSHIP_MIN_SHARES = "100000000000000000000"; +const MEMBERS_SUBGRAPH_ID = "6x9FK3iuhVFaH9sZ39m8bKB5eckax8sjxooBPNKWWK8r"; const URL_PATTERN = /https?:\/\/\S+/g; let memberAddressesCache: @@ -36,17 +34,6 @@ let memberAddressesCache: } | undefined; -export function isSignatureRequestBody( - value: unknown, -): value is SignatureRequestBody { - if (!value || typeof value !== "object") { - return false; - } - - const candidate = value as Partial; - return typeof candidate.signature === "string"; -} - export function isChannelRequestBody( value: unknown, ): value is ChannelRequestBody { @@ -56,7 +43,6 @@ export function isChannelRequestBody( const candidate = value as Partial; return ( - typeof candidate.signature === "string" && typeof candidate.key === "string" && candidate.key.length > 0 && !candidate.key.endsWith("/") @@ -76,7 +62,6 @@ export function logServerError(message: string, error: unknown) { if (error instanceof Error) { console.error(message, { - message: sanitizeLogMessage(error.message), name: error.name, }); return; @@ -127,8 +112,8 @@ export async function fetchMemberAddresses(): Promise { memberSubgraphUrl, { query: ` - query listMembers($skip: Int!, $first: Int!) { - members(where: { dao: "0xf02fd4286917270cb94fbc13a0f4e1ed76f7e986" }, skip: $skip, first: $first, orderBy: createdAt, orderDirection: desc) { + query listMembers($skip: Int!, $first: Int!, $minimumShares: BigInt!) { + members(where: { dao: "0xf02fd4286917270cb94fbc13a0f4e1ed76f7e986", shares_gte: $minimumShares }, skip: $skip, first: $first, orderBy: createdAt, orderDirection: desc) { memberAddress } } @@ -137,6 +122,7 @@ export async function fetchMemberAddresses(): Promise { variables: { skip, first: MEMBER_ADDRESSES_PAGE_SIZE, + minimumShares: MEMBERSHIP_MIN_SHARES, }, }, { @@ -170,3 +156,43 @@ export async function fetchMemberAddresses(): Promise { throw new Error("Member lookup exceeded maximum page count"); } + +export async function isEligibleMemberAddress(address: string) { + const members = await fetchMemberAddresses(); + return members.includes(address.toLowerCase()); +} + +export class MemberSessionError extends Error { + constructor( + message: string, + readonly status: 401 | 403, + ) { + super(message); + this.name = "MemberSessionError"; + } +} + +export function memberSessionErrorResponse(error: unknown) { + if (!(error instanceof MemberSessionError)) return null; + + return NextResponse.json( + { error: error.message }, + { + headers: { "Cache-Control": "no-store" }, + status: error.status, + }, + ); +} + +export async function requireMemberSession() { + const address = await readSessionAddress(); + if (!address) { + throw new MemberSessionError("Authentication required.", 401); + } + + if (!(await isEligibleMemberAddress(address))) { + throw new MemberSessionError(NOT_MEMBER_ERROR, 403); + } + + return address; +} diff --git a/app/api/shared/session.ts b/app/api/shared/session.ts new file mode 100644 index 0000000..8d15e32 --- /dev/null +++ b/app/api/shared/session.ts @@ -0,0 +1,189 @@ +import { createHash } from "node:crypto"; + +import { jwtVerify, SignJWT } from "jose"; +import { cookies } from "next/headers"; +import { NextResponse } from "next/server"; +import { getAddress, isAddress } from "viem"; + +import { CONFIG } from "../../config"; + +const AUTH_ISSUER = "the-valhalla"; +const CHALLENGE_AUDIENCE = "the-valhalla-siwe-challenge"; +const SESSION_AUDIENCE = "the-valhalla-session"; +const CHALLENGE_TTL_SECONDS = 5 * 60; +const SESSION_TTL_SECONDS = 7 * 24 * 60 * 60; +const IS_PRODUCTION = process.env.NODE_ENV === "production"; + +export const CHALLENGE_COOKIE_NAME = IS_PRODUCTION + ? "__Host-valhalla_challenge" + : "valhalla_challenge"; +export const SESSION_COOKIE_NAME = IS_PRODUCTION + ? "__Host-valhalla_session" + : "valhalla_session"; + +type ChallengePayload = { + address: string; + messageDigest: string; + nonce: string; +}; + +function getSessionKey() { + const secret = CONFIG.JWT_SECRET?.trim(); + + if (!secret || new TextEncoder().encode(secret).byteLength < 32) { + throw new Error("JWT_SECRET must contain at least 32 bytes"); + } + + return new TextEncoder().encode(secret); +} + +function cookieOptions(maxAge: number) { + return { + httpOnly: true, + maxAge, + path: "/" as const, + sameSite: "lax" as const, + secure: IS_PRODUCTION, + }; +} + +async function signToken( + payload: Record, + audience: string, + expiresIn: number, +) { + return new SignJWT(payload) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt() + .setIssuer(AUTH_ISSUER) + .setAudience(audience) + .setExpirationTime(`${expiresIn}s`) + .sign(getSessionKey()); +} + +async function verifyToken(token: string | undefined, audience: string) { + if (!token) return null; + + try { + const { payload } = await jwtVerify(token, getSessionKey(), { + algorithms: ["HS256"], + audience, + issuer: AUTH_ISSUER, + }); + + return payload; + } catch { + return null; + } +} + +export function getMessageDigest(message: string) { + return createHash("sha256").update(message).digest("base64url"); +} + +export async function createChallengeToken(payload: ChallengePayload) { + return signToken(payload, CHALLENGE_AUDIENCE, CHALLENGE_TTL_SECONDS); +} + +export async function readChallenge(): Promise { + const cookieStore = await cookies(); + const payload = await verifyToken( + cookieStore.get(CHALLENGE_COOKIE_NAME)?.value, + CHALLENGE_AUDIENCE, + ); + + if ( + !payload || + typeof payload.address !== "string" || + !isAddress(payload.address) || + typeof payload.messageDigest !== "string" || + typeof payload.nonce !== "string" + ) { + return null; + } + + return { + address: getAddress(payload.address), + messageDigest: payload.messageDigest, + nonce: payload.nonce, + }; +} + +export async function createSessionToken(address: string) { + return signToken( + { address: getAddress(address) }, + SESSION_AUDIENCE, + SESSION_TTL_SECONDS, + ); +} + +export async function readSessionAddress() { + const cookieStore = await cookies(); + const payload = await verifyToken( + cookieStore.get(SESSION_COOKIE_NAME)?.value, + SESSION_AUDIENCE, + ); + + if ( + !payload || + typeof payload.address !== "string" || + !isAddress(payload.address) + ) { + return null; + } + + return getAddress(payload.address); +} + +export function setChallengeCookie(response: NextResponse, token: string) { + response.cookies.set( + CHALLENGE_COOKIE_NAME, + token, + cookieOptions(CHALLENGE_TTL_SECONDS), + ); +} + +export function setSessionCookie(response: NextResponse, token: string) { + response.cookies.set( + SESSION_COOKIE_NAME, + token, + cookieOptions(SESSION_TTL_SECONDS), + ); +} + +export function clearChallengeCookie(response: NextResponse) { + response.cookies.set(CHALLENGE_COOKIE_NAME, "", cookieOptions(0)); +} + +export function clearSessionCookie(response: NextResponse) { + response.cookies.set(SESSION_COOKIE_NAME, "", cookieOptions(0)); +} + +export function clearAuthCookies(response: NextResponse) { + clearChallengeCookie(response); + clearSessionCookie(response); +} + +export function getSameOrigin(request: Request) { + const requestUrl = new URL(request.url); + const origin = request.headers.get("origin"); + const requestHost = request.headers.get("host") ?? requestUrl.host; + + if (!origin) { + return null; + } + + try { + const originUrl = new URL(origin); + if ( + originUrl.host !== requestHost || + originUrl.protocol !== requestUrl.protocol + ) { + return null; + } + + return originUrl.origin; + } catch { + return null; + } +} diff --git a/app/config.ts b/app/config.ts index d82ea85..20a8ea8 100644 --- a/app/config.ts +++ b/app/config.ts @@ -1,6 +1,7 @@ import { S3 } from "@aws-sdk/client-s3"; export const CONFIG = { + GNOSIS_RPC_URL: process.env.GNOSIS_RPC_URL, JWT_SECRET: process.env.JWT_SECRET, THE_GRAPH_API_KEY: process.env.THE_GRAPH_API_KEY, }; diff --git a/app/globals.css b/app/globals.css index 5afd466..5e2fe81 100644 --- a/app/globals.css +++ b/app/globals.css @@ -253,6 +253,11 @@ a:focus-visible { padding-block: clamp(2rem, 4vw, 4rem); } +.valhalla-main:has(.archive) { + justify-content: flex-start; + padding-top: clamp(1.25rem, 2vw, 2rem); +} + .hero { display: grid; align-items: center; @@ -311,11 +316,23 @@ a:focus-visible { } .hero-action { + display: inline-flex; +} + +.hero-access { + position: relative; + z-index: 2; margin-top: 2rem; } -.hero-step { - margin-bottom: 0.75rem; +.hero-access .gate-card { + align-items: start; + gap: 1.25rem; + grid-template-columns: 1fr; +} + +.hero-access .gate-action { + justify-content: flex-start; } .hero-art { @@ -371,12 +388,6 @@ a:focus-visible { -webkit-mask-composite: source-in; } -.access-region { - position: relative; - z-index: 2; - margin-top: clamp(2rem, 5vw, 4rem); -} - .gate-card { display: grid; align-items: center; @@ -403,10 +414,6 @@ a:focus-visible { max-width: 43rem; } -.gate-eyebrow { - margin-bottom: 0.65rem; -} - .gate-title { display: flex; align-items: center; @@ -474,7 +481,7 @@ a:focus-visible { } .archive { - margin-top: clamp(3rem, 7vw, 6rem); + width: 100%; } .archive-header { @@ -815,7 +822,7 @@ a:focus-visible { margin-top: 1.5rem; } - .hero-action { + .hero-access { margin-top: 1.5rem; } @@ -827,10 +834,6 @@ a:focus-visible { display: none; } - .access-region { - margin-top: 2rem; - } - .gate-card { align-items: start; grid-template-columns: 1fr; diff --git a/app/page.tsx b/app/page.tsx index 12240a9..00ee85b 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -2,6 +2,7 @@ import Image from "next/image"; import { + useCallback, useEffect, useMemo, useRef, @@ -10,8 +11,9 @@ import { type Ref, } from "react"; import { LuExternalLink, LuFileText, LuSearch, LuX } from "react-icons/lu"; -import { useMutation, useQuery } from "@tanstack/react-query"; -import { useAccount, useBalance, useSignMessage } from "wagmi"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useAccount, useSignMessage } from "wagmi"; +import type { Address } from "viem"; import { gnosis } from "viem/chains"; import { @@ -20,20 +22,105 @@ import { } from "./shared/WalletControl"; import { fuzzyScore } from "./utils/fuzzy"; import { + createAuthMessage, + getAuthSession, getValhallaFile, getValhallaFiles, + logoutAuthSession, + verifyAuthMessage, + ApiRequestError, + type AuthSession, type ValhallaFile, } from "./utils/requests"; -const SHARES_TOKEN_ADDRESS = "0x372fc5a6b0b12ae174f09f6fc849a83de6b503b6"; -const MEMBERSHIP_THRESHOLD = 100; +const AUTH_SESSION_QUERY_KEY = ["valhalla-session"] as const; + +type AuthPhase = "idle" | "preparing" | "awaiting-signature" | "verifying"; +type AccessState = + | "archive" + | "archive-error" + | "archive-loading" + | "check-in" + | "idle" + | "loading" + | "logout-error" + | "network-error" + | "session-error" + | "signing-out"; + +type AccessStateInput = { + address?: Address; + chainId?: number; + filesError: unknown; + hasVerifiedAccess: boolean; + isConnecting: boolean; + isEndingSession: boolean; + isFilesLoading: boolean; + isSessionLoading: boolean; + logoutError: string; + sessionError: unknown; +}; + +function resolveAccessState(input: AccessStateInput): AccessState { + if (input.hasVerifiedAccess) { + if (input.isFilesLoading) return "archive-loading"; + return input.filesError ? "archive-error" : "archive"; + } + if (input.isEndingSession) return "signing-out"; + if (input.logoutError) return "logout-error"; + if (input.isSessionLoading || input.isConnecting) return "loading"; + if (input.sessionError) return "session-error"; + if (!input.address) return "idle"; + if (input.chainId !== gnosis.id) return "network-error"; + return "check-in"; +} + +function checkInAnnouncement(authPhase: AuthPhase) { + switch (authPhase) { + case "awaiting-signature": + return "Check your wallet to sign the Valhalla sign-in message."; + case "verifying": + return "Verifying your RaidGuild membership."; + case "preparing": + return "Preparing your Valhalla sign-in message."; + default: + return "Sign a message to verify your membership and start a session."; + } +} + +type StatusAnnouncementContext = { + authPhase: AuthPhase; + isConnecting: boolean; + visibleFilesCount: number; +}; + +const STATUS_ANNOUNCEMENTS: Record< + AccessState, + (context: StatusAnnouncementContext) => string +> = { + "archive-error": () => "The archive did not open. Try again.", + "archive-loading": () => "Opening the archive.", + "check-in": ({ authPhase }) => checkInAnnouncement(authPhase), + "logout-error": () => "We could not finish signing you out.", + "network-error": () => "Switch to Gnosis Chain to continue.", + "session-error": () => "We could not restore your member session.", + "signing-out": () => "Signing you out of Valhalla.", + archive: ({ visibleFilesCount }) => + `Guild archive open with ${visibleFilesCount} ${ + visibleFilesCount === 1 ? "file" : "files" + } available.`, + idle: () => "", + loading: ({ isConnecting }) => + isConnecting + ? "Connecting your wallet." + : "Checking for an existing member session.", +}; type GatePanelProps = { description: string; title: string; children?: ReactNode; error?: string; - eyebrow?: string; headingRef?: Ref; isLoading?: boolean; tone?: "default" | "error"; @@ -43,7 +130,6 @@ function GatePanel({ children, description, error, - eyebrow, headingRef, isLoading = false, title, @@ -55,7 +141,6 @@ function GatePanel({ aria-busy={isLoading || undefined} >
- {eyebrow ?

{eyebrow}

: null}

-
-
-

Members’ archive

-

- Enter Valhalla. -

-

- {address - ? "Your member wallet is connected. Your archive access appears below." - : "A private archive of all RaidGuild Discord server channels. Connect your member wallet to enter."} -

-
- {!address ? ( -

Step 01 · Connect

- ) : null} - -
-
- -
); } function HomeContent() { - const { address, isConnecting } = useAccount(); - const { - data: signatureData, - error: signError, - isPending: isSigning, - isSuccess: isSignSuccess, - signMessage, - } = useSignMessage(); - - const { - data: shares, - error: sharesError, - isFetching: isSharesFetching, - isLoading: isSharesLoading, - refetch: refetchShares, - } = useBalance({ - token: SHARES_TOKEN_ADDRESS, - address, - chainId: gnosis.id, - query: { - refetchOnWindowFocus: false, - }, - }); + const { address, chainId, isConnecting } = useAccount(); + const { reset: resetSignature, signMessageAsync } = useSignMessage(); + const queryClient = useQueryClient(); const [actionError, setActionError] = useState(""); + const [authPhase, setAuthPhase] = useState("idle"); + const [isEndingSession, setIsEndingSession] = useState(false); + const [logoutError, setLogoutError] = useState(""); const [searchQuery, setSearchQuery] = useState(""); const [channelsBeingFetched, setChannelsBeingFetched] = useState>( new Set(), ); const focusTargetRef = useRef(null); const focusAfterTransitionRef = useRef(false); + const authAttemptRef = useRef(0); + const logoutRequestRef = useRef | null>(null); const previousAddressRef = useRef(undefined); const searchInputRef = useRef(null); const walletConnectRequestedRef = useRef(false); - const isMember = - !isSharesLoading && - !isSharesFetching && - Number(shares?.formatted || 0) >= MEMBERSHIP_THRESHOLD; + const finishLogout = useCallback(() => { + if (logoutRequestRef.current) return logoutRequestRef.current; + + setIsEndingSession(true); + setLogoutError(""); + + const request = logoutAuthSession() + .catch((error: unknown) => { + const message = + error instanceof Error + ? error.message + : "Unable to end your session right now."; + setLogoutError(message); + throw error; + }) + .finally(() => { + logoutRequestRef.current = null; + setIsEndingSession(false); + }); + + logoutRequestRef.current = request; + return request; + }, []); + + const { + data: authSession, + error: sessionError, + isLoading: isSessionLoading, + refetch: refetchSession, + } = useQuery({ + queryKey: AUTH_SESSION_QUERY_KEY, + queryFn: getAuthSession, + refetchOnWindowFocus: false, + retry: false, + }); + const sessionAddress = authSession?.authenticated + ? authSession.address + : undefined; + const walletMatchesSession = + address && + sessionAddress && + address.toLowerCase() === sessionAddress.toLowerCase(); + const hasVerifiedAccess = Boolean( + address && sessionAddress && walletMatchesSession, + ); + + const { + error: authError, + isPending: isAuthenticating, + mutate: authenticate, + reset: resetAuthentication, + } = useMutation< + AuthSession, + Error, + { address: Address; attemptId: number; chainId: number } + >({ + mutationFn: async ({ + address: walletAddress, + attemptId, + chainId: walletChainId, + }) => { + setActionError(""); + const message = await createAuthMessage(walletAddress, walletChainId); + + if (authAttemptRef.current !== attemptId) { + throw new Error("Wallet connection changed. Please try again."); + } + + setAuthPhase("awaiting-signature"); + const signature = await signMessageAsync({ + account: walletAddress, + message, + }); + + if (authAttemptRef.current !== attemptId) { + throw new Error("Wallet connection changed. Please try again."); + } + + setAuthPhase("verifying"); + const session = await verifyAuthMessage(message, signature); + + if (authAttemptRef.current !== attemptId) { + queryClient.setQueryData(AUTH_SESSION_QUERY_KEY, { + authenticated: false, + }); + queryClient.removeQueries({ queryKey: ["valhalla-files"] }); + await finishLogout(); + throw new Error("Wallet connection changed. Please try again."); + } + + if (!session.authenticated) { + throw new Error("Wallet verification did not create a session."); + } + + return session; + }, + onSuccess: (session) => { + focusAfterTransitionRef.current = true; + setActionError(""); + queryClient.setQueryData(AUTH_SESSION_QUERY_KEY, session); + }, + onSettled: (_, __, variables) => { + if (authAttemptRef.current === variables.attemptId) { + setAuthPhase("idle"); + } + }, + }); const { data: files = [], @@ -175,19 +314,19 @@ function HomeContent() { isLoading: isFilesLoading, refetch: refetchFiles, } = useQuery({ - queryKey: ["valhalla-files", signatureData], - queryFn: () => getValhallaFiles(signatureData as string), - enabled: Boolean(signatureData), + queryKey: ["valhalla-files", sessionAddress], + queryFn: getValhallaFiles, + enabled: hasVerifiedAccess, refetchOnWindowFocus: false, refetchOnMount: false, }); - const { error: fileError, mutate: openFileChannel } = useMutation< - string, - Error, - string - >({ - mutationFn: (key: string) => getValhallaFile(signatureData as string, key), + const { + error: fileError, + mutate: openFileChannel, + reset: resetFileRequest, + } = useMutation({ + mutationFn: getValhallaFile, onMutate: (key) => setChannelsBeingFetched((previous) => { const next = new Set(previous); @@ -209,8 +348,8 @@ function HomeContent() { const getFile = (key: string) => { setActionError(""); - if (!signatureData) { - setActionError("Your check-in signature is no longer available."); + if (!hasVerifiedAccess) { + setActionError("Your member session is no longer available."); return; } @@ -241,43 +380,57 @@ function HomeContent() { .map(({ file }) => file); }, [searchQuery, visibleFiles]); const errorMessage = actionError || fileError?.message; - const isCheckingAccess = - isConnecting || isSharesLoading || isSharesFetching || isFilesLoading; - const accessState = isCheckingAccess - ? "loading" - : !address - ? "idle" - : sharesError - ? "membership-error" - : shares !== undefined && !isMember - ? "not-member" - : isMember && !isSignSuccess - ? "check-in" - : !isSignSuccess - ? "verification-error" - : filesError - ? "archive-error" - : "archive"; - const statusAnnouncement = - accessState === "loading" - ? isFilesLoading - ? "Opening the archive." - : "Reading your guild shares." - : accessState === "membership-error" - ? "We could not read your membership." - : accessState === "not-member" - ? "This wallet is not recognized as a RaidGuild member." - : accessState === "check-in" - ? "Membership confirmed. Sign a free message to open the archive." - : accessState === "verification-error" - ? "We could not confirm your membership." - : accessState === "archive-error" - ? "The archive did not open. Try again." - : accessState === "archive" - ? `Guild archive open with ${visibleFiles.length} ${ - visibleFiles.length === 1 ? "file" : "files" - } available.` - : ""; + const accessState = resolveAccessState({ + address, + chainId, + filesError, + hasVerifiedAccess, + isConnecting, + isEndingSession, + isFilesLoading, + isSessionLoading, + logoutError, + sessionError, + }); + const statusAnnouncement = STATUS_ANNOUNCEMENTS[accessState]({ + authPhase, + isConnecting, + visibleFilesCount: visibleFiles.length, + }); + + useEffect(() => { + const protectedAccessError = [filesError, fileError].find( + (error) => + error instanceof ApiRequestError && + (error.status === 401 || error.status === 403), + ); + + if (!protectedAccessError) return; + + authAttemptRef.current += 1; + queryClient.setQueryData(AUTH_SESSION_QUERY_KEY, { + authenticated: false, + }); + queryClient.removeQueries({ queryKey: ["valhalla-files"] }); + resetSignature(); + resetAuthentication(); + resetFileRequest(); + // The reset helpers clear related errors, so restore this message after + // their state updates have settled. + queueMicrotask(() => { + setAuthPhase("idle"); + setActionError(protectedAccessError.message); + }); + void finishLogout().catch(() => undefined); + }, [ + fileError, + filesError, + finishLogout, + queryClient, + resetAuthentication, + resetFileRequest, + resetSignature, + ]); useEffect(() => { const handleWalletConnectRequest = () => { @@ -297,9 +450,34 @@ function HomeContent() { }, []); useEffect(() => { + const previousAddress = previousAddressRef.current; + const accountChanged = + Boolean(previousAddress) && previousAddress !== address; + const connectedAccountDoesNotMatchSession = Boolean( + address && + sessionAddress && + address.toLowerCase() !== sessionAddress.toLowerCase(), + ); + + if (accountChanged || connectedAccountDoesNotMatchSession) { + authAttemptRef.current += 1; + queryClient.setQueryData(AUTH_SESSION_QUERY_KEY, { + authenticated: false, + }); + queryClient.removeQueries({ queryKey: ["valhalla-files"] }); + resetSignature(); + resetAuthentication(); + resetFileRequest(); + queueMicrotask(() => { + setActionError(""); + setAuthPhase("idle"); + }); + void finishLogout().catch(() => undefined); + } + if ( address && - address !== previousAddressRef.current && + address !== previousAddress && walletConnectRequestedRef.current ) { focusAfterTransitionRef.current = true; @@ -310,7 +488,15 @@ function HomeContent() { } previousAddressRef.current = address; - }, [address]); + }, [ + address, + finishLogout, + queryClient, + resetAuthentication, + resetFileRequest, + resetSignature, + sessionAddress, + ]); useEffect(() => { if ( @@ -329,12 +515,6 @@ function HomeContent() { return () => window.cancelAnimationFrame(frame); }, [accessState]); - useEffect(() => { - if (signError) { - focusAfterTransitionRef.current = false; - } - }, [signError]); - const clearSearch = () => { setSearchQuery(""); window.requestAnimationFrame(() => searchInputRef.current?.focus()); @@ -349,35 +529,59 @@ function HomeContent() { ); - if (isCheckingAccess) { - return renderWithStatus( -
+ if (!hasVerifiedAccess) { + let accessContent: ReactNode; + + if (isEndingSession) { + accessContent = ( + + ); + } else if (logoutError) { + accessContent = ( + + + + ); + } else if (isSessionLoading || isConnecting) { + accessContent = ( -
, - ); - } - - if (!address) { - return renderWithStatus(null); - } - - if (sharesError) { - return renderWithStatus( -
+ ); + } else if (sessionError) { + accessContent = ( @@ -386,66 +590,98 @@ function HomeContent() { type="button" onClick={() => { focusAfterTransitionRef.current = true; - void refetchShares(); + void refetchSession(); }} > Try again -
, - ); - } - - if (shares !== undefined && !isMember) { - return renderWithStatus( -
+ ); + } else if (!address) { + accessContent = ( +
+ +
+ ); + } else if (chainId !== gnosis.id) { + accessContent = ( -
, - ); - } - - if (isMember && !isSignSuccess) { - return renderWithStatus( -
+ > + + + ); + } else { + accessContent = ( -
, - ); - } + ); + } - if (!isSignSuccess) { return renderWithStatus( -
- -
, +
+
+

Members’ archive

+

+ Enter Valhalla. +

+

+ A private archive of all RaidGuild Discord server channels. Connect + your member wallet and verify your membership to enter. +

+
{accessContent}
+
+ +
, ); } @@ -453,22 +689,24 @@ function HomeContent() {
-

Step 03 · Enter

-

Members’ archive

+

Guild archive -

+

- {filesError - ? "Archive unavailable" - : `${visibleFiles.length} ${ - visibleFiles.length === 1 ? "file" : "files" - } available`} + {isFilesLoading + ? "Opening archive" + : filesError + ? "Archive unavailable" + : `${visibleFiles.length} ${ + visibleFiles.length === 1 ? "file" : "files" + } available`} @@ -516,10 +754,16 @@ function HomeContent() { ) : null} - {filesError ? ( + {isFilesLoading ? ( +
+
+ ) : filesError ? (
)} diff --git a/app/utils/requests.ts b/app/utils/requests.ts index a70ffff..d3fd43e 100644 --- a/app/utils/requests.ts +++ b/app/utils/requests.ts @@ -4,43 +4,89 @@ export type ValhallaFile = { Key: string; }; -const getApiErrorMessage = (error: unknown, fallbackMessage: string) => { +export type AuthSession = + { authenticated: true; address: string } | { authenticated: false }; + +export class ApiRequestError extends Error { + constructor( + message: string, + readonly status?: number, + ) { + super(message); + this.name = "ApiRequestError"; + } +} + +const getApiError = (error: unknown, fallbackMessage: string) => { if (axios.isAxiosError(error)) { const apiMessage = error.response?.data?.error; if (typeof apiMessage === "string" && apiMessage.length > 0) { - return apiMessage; + return new ApiRequestError(apiMessage, error.response?.status); } + + return new ApiRequestError(fallbackMessage, error.response?.status); } - return fallbackMessage; + return new ApiRequestError(fallbackMessage); +}; + +export const getAuthSession = async () => { + try { + const data = await axios.get("/api/auth/session"); + return data.data; + } catch (error) { + throw getApiError(error, "Unable to restore your session right now."); + } +}; + +export const createAuthMessage = async (address: string, chainId: number) => { + try { + const data = await axios.post<{ message: string }>("/api/auth/message", { + address, + chainId, + }); + return data.data.message; + } catch (error) { + throw getApiError(error, "Unable to start wallet verification."); + } }; -export const getValhallaFiles = async (signature: string) => { +export const verifyAuthMessage = async (message: string, signature: string) => { try { - const data = await axios.post<{ response: ValhallaFile[] }>("/api/files", { + const data = await axios.post("/api/auth/verify", { + message, signature, }); + return data.data; + } catch (error) { + throw getApiError(error, "Unable to verify this wallet right now."); + } +}; + +export const logoutAuthSession = async () => { + try { + await axios.post("/api/auth/logout"); + } catch (error) { + throw getApiError(error, "Unable to end your session right now."); + } +}; + +export const getValhallaFiles = async () => { + try { + const data = await axios.post<{ response: ValhallaFile[] }>("/api/files"); return data.data.response; } catch (error) { - throw new Error( - getApiErrorMessage(error, "Unable to fetch Valhalla files right now."), - ); + throw getApiError(error, "Unable to fetch Valhalla files right now."); } }; -export const getValhallaFile = async (signature: string, key: string) => { +export const getValhallaFile = async (key: string) => { try { - const data = await axios.post("/api/channel", { + const data = await axios.post<{ channel: string }>("/api/channel", { key, - signature, }); return data.data.channel; } catch (error) { - throw new Error( - getApiErrorMessage( - error, - "Unable to fetch this Valhalla file right now.", - ), - ); + throw getApiError(error, "Unable to fetch this Valhalla file right now."); } }; diff --git a/docs/session-workflow.md b/docs/session-workflow.md index 860738e..4f4b6c4 100644 --- a/docs/session-workflow.md +++ b/docs/session-workflow.md @@ -67,7 +67,9 @@ foundational pattern, destructive data change, or material expansion of scope. - Keep changes within the agreed feature or fix. - Treat the candidate's intended parent or merge base as the reviewed baseline, using `main` only when it is the intended parent, and use a clearly named - feature branch before publishing work. + branch before publishing work. Prefix branch names by change type using + conventional prefixes such as `feat/`, `fix/`, `docs/`, or `chore/`; do not + use an agent or tool name as the branch prefix. - Preserve existing and unrelated worktree changes. - Keep credentials, signatures, signed URLs, private endpoints, real user data, and internal-only notes out of source, tests, logs, screenshots, and diff --git a/package.json b/package.json index da4575b..8d4ff03 100644 --- a/package.json +++ b/package.json @@ -22,8 +22,8 @@ "@x402/evm": "^2.18.0", "@x402/svm": "^2.18.0", "axios": "^1.18.1", - "ethers": "^6.17.0", "framer-motion": "^12.42.2", + "jose": "^6.2.4", "next": "16.2.10", "next-themes": "^0.4.6", "react": "19.2.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index db2fea4..b0d13e4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,12 +47,12 @@ importers: axios: specifier: ^1.18.1 version: 1.18.1 - ethers: - specifier: ^6.17.0 - version: 6.17.0(bufferutil@4.1.0)(utf-8-validate@5.0.10) framer-motion: specifier: ^12.42.2 version: 12.42.2(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + jose: + specifier: ^6.2.4 + version: 6.2.4 next: specifier: 16.2.10 version: 16.2.10(@babel/core@7.29.7)(babel-plugin-macros@3.1.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -2652,10 +2652,6 @@ packages: ethereum-cryptography@2.2.1: resolution: {integrity: sha512-r/W8lkHSiTLxUxW8Rf3u4HGB0xQweG2RyETjywylKZSzLWoWAijRz8WCuOtJ6wah+avllXBqZuk29HCCvhEIRg==} - ethers@6.17.0: - resolution: {integrity: sha512-BpyrpIPJ3ydEVow8zGaz1DuPS7YU8DcWxuBnY9a0UA/lvAPwrMr+EPXsfrul628SRaekPNeIM4UFh/91GWZang==} - engines: {node: '>=14.0.0'} - eventemitter2@6.4.9: resolution: {integrity: sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==} @@ -8795,19 +8791,6 @@ snapshots: '@scure/bip32': 1.4.0 '@scure/bip39': 1.3.0 - ethers@6.17.0(bufferutil@4.1.0)(utf-8-validate@5.0.10): - dependencies: - '@adraffy/ens-normalize': 1.11.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.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10) - transitivePeerDependencies: - - bufferutil - - utf-8-validate - eventemitter2@6.4.9: {} eventemitter3@5.0.1: {} diff --git a/sample.env b/sample.env index 3eed097..759deea 100644 --- a/sample.env +++ b/sample.env @@ -4,8 +4,11 @@ NEXT_PUBLIC_PROJECT_ID='walletconnect-project-id' # Additional hostnames allowed to proxy the Next.js development server DEV_ALLOWED_ORIGINS='your-development-hostname.example' -# JWT Secret - unsure if used -JWT_SECRET='your_jwt_secret_here' +# JWT Secret - generate at least 32 random bytes (for example, openssl rand -hex 32) +JWT_SECRET='replace-me' + +# Optional dedicated Gnosis Chain RPC used for smart-account signature checks +GNOSIS_RPC_URL='' # S3 Configuration S3_BUCKET='your-bucket-name-here'