Skip to content
Merged
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
25 changes: 17 additions & 8 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.

Expand Down
19 changes: 19 additions & 0 deletions app/api/auth/logout/route.ts
Original file line number Diff line number Diff line change
@@ -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;
}
96 changes: 96 additions & 0 deletions app/api/auth/message/route.ts
Original file line number Diff line number Diff line change
@@ -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<MessageRequestBody>;
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);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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;
}
59 changes: 59 additions & 0 deletions app/api/auth/session/route.ts
Original file line number Diff line number Diff line change
@@ -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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} 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" } },
);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Loading