diff --git a/.eslintrc.json b/.eslintrc.json
deleted file mode 100644
index 3a022bb..0000000
--- a/.eslintrc.json
+++ /dev/null
@@ -1,19 +0,0 @@
-{
- "extends": ["next/core-web-vitals", "next/typescript"],
- "rules": {
- // Escrow/payout service boundaries are hard (ADR-001): vendor SDKs are
- // only reachable through lib/ports. Kept as a warning until the boundary
- // import lint rule lands in Phase 3.
- "no-restricted-imports": [
- "warn",
- {
- "patterns": [
- {
- "group": ["ethers"],
- "message": "Vendor SDKs must be accessed via lib/ports — never imported directly (ADR-001)."
- }
- ]
- }
- ]
- }
-}
diff --git a/.gitignore b/.gitignore
index 6600e5a..628c135 100644
--- a/.gitignore
+++ b/.gitignore
@@ -52,3 +52,4 @@ next-env.d.ts
# Claude Code local settings (per-machine, never committed)
.claude/
+.npmrc
diff --git a/app/(app)/organizer/hackathons/[slug]/media/page.tsx b/app/(app)/organizer/hackathons/[slug]/media/page.tsx
index 639caaa..f3bb7c4 100644
--- a/app/(app)/organizer/hackathons/[slug]/media/page.tsx
+++ b/app/(app)/organizer/hackathons/[slug]/media/page.tsx
@@ -31,9 +31,15 @@ export default async function EventMediaPage({
if (!membership || membership.role === "MEMBER") notFound();
const deadline = event.mediaDeadlineAt;
+ // Server Component: this runs once per server request, not a client
+ // re-render, so Date.now() is the intended per-request deadline check
+ // (react-hooks/purity is new in eslint-config-next 16 and doesn't
+ // distinguish RSC render from client render).
const hoursLeft = deadline
- ? Math.round((deadline.getTime() - Date.now()) / 3_600_000)
+ ? // eslint-disable-next-line react-hooks/purity -- see comment above
+ Math.round((deadline.getTime() - Date.now()) / 3_600_000)
: null;
+ // eslint-disable-next-line react-hooks/purity -- see comment above
const pastDeadline = deadline ? deadline.getTime() < Date.now() : false;
const approvedCount = event.media.filter((asset) => asset.status === "APPROVED").length;
diff --git a/app/(auth)/invites/[token]/page.tsx b/app/(auth)/invites/[token]/page.tsx
index 0142005..f21ddfe 100644
--- a/app/(auth)/invites/[token]/page.tsx
+++ b/app/(auth)/invites/[token]/page.tsx
@@ -14,6 +14,10 @@ export default async function InvitePage({ params }: { params: Promise<{ token:
if (
!invitation ||
invitation.acceptedAt ||
+ // Server Component: runs once per server request, so Date.now() is the
+ // intended per-request expiry check (react-hooks/purity is new in
+ // eslint-config-next 16 and doesn't distinguish RSC render from client).
+ // eslint-disable-next-line react-hooks/purity -- see comment above
invitation.expiresAt.getTime() <= Date.now()
) {
return ;
diff --git a/app/(marketing)/blog/[slug]/page.tsx b/app/(marketing)/blog/[slug]/page.tsx
index 4437db9..19a16b3 100644
--- a/app/(marketing)/blog/[slug]/page.tsx
+++ b/app/(marketing)/blog/[slug]/page.tsx
@@ -91,7 +91,7 @@ export default async function BlogPostPage({ params }: PageProps) {
src={meta.cover}
alt={meta.coverAlt}
fill
- priority
+ preload
sizes="(min-width: 1440px) 1392px, 100vw"
className="object-cover"
/>
diff --git a/app/(marketing)/hackathons/[slug]/page.tsx b/app/(marketing)/hackathons/[slug]/page.tsx
index 912d9fe..782fb50 100644
--- a/app/(marketing)/hackathons/[slug]/page.tsx
+++ b/app/(marketing)/hackathons/[slug]/page.tsx
@@ -246,7 +246,7 @@ export default async function EventDetailPage({ params, searchParams }: PageProp
src={cover}
alt={`${event.title} hackathon cover`}
fill
- priority
+ preload
sizes="(min-width: 1024px) 380px, 100vw"
unoptimized={!cover.startsWith("/")}
className="object-cover"
diff --git a/components/auth/oauth-popup-pages.tsx b/components/auth/oauth-popup-pages.tsx
index cd9607c..11b5150 100644
--- a/components/auth/oauth-popup-pages.tsx
+++ b/components/auth/oauth-popup-pages.tsx
@@ -52,7 +52,10 @@ export function OAuthPopupDone() {
const [orphaned, setOrphaned] = useState(false);
useEffect(() => {
if (!reportToOpener({ type: OAUTH_POPUP_NAME, status: "success" })) {
- // Opened outside the popup flow (or the original tab was closed): carry on here.
+ // Opened outside the popup flow (or the original tab was closed): carry
+ // on here. This relays a one-time external result, not reactive state
+ // (react-hooks/set-state-in-effect is new in eslint-config-next 16).
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- see comment above
setOrphaned(true);
window.location.replace("/dashboard");
}
diff --git a/components/hackathons/hackathon-card.tsx b/components/hackathons/hackathon-card.tsx
index d5a33a1..6718a82 100644
--- a/components/hackathons/hackathon-card.tsx
+++ b/components/hackathons/hackathon-card.tsx
@@ -35,7 +35,7 @@ export function HackathonCover({
// Uploaded covers live on R2 and are already sized to 1600x900.
unoptimized={!cover.startsWith("/")}
alt={`${event.title}, a hackathon on HackVillage`}
- priority={priority}
+ preload={priority}
fill
sizes={sizes}
className={cn(
diff --git a/components/judging/judging-section.tsx b/components/judging/judging-section.tsx
index 00f59b3..896cfdf 100644
--- a/components/judging/judging-section.tsx
+++ b/components/judging/judging-section.tsx
@@ -26,6 +26,10 @@ export async function JudgingSection({
status: EventStatus;
}) {
const readiness = await judgingReadiness(eventId);
+ // Server Component: runs once per server request, so Date.now() is the
+ // intended per-request "has the event ended" check (react-hooks/purity is
+ // new in eslint-config-next 16 and doesn't distinguish RSC from client).
+ // eslint-disable-next-line react-hooks/purity -- see comment above
const ended = endsAt.getTime() <= Date.now();
const judging = status === "JUDGING";
diff --git a/components/patterns/app-sidebar.tsx b/components/patterns/app-sidebar.tsx
index 5a50061..96499c2 100644
--- a/components/patterns/app-sidebar.tsx
+++ b/components/patterns/app-sidebar.tsx
@@ -138,6 +138,10 @@ export function AppTopBar({
const [mounted, setMounted] = useState(false);
useEffect(() => {
+ // Hydration-safe mount flag: intentionally renders differently once the
+ // client has mounted (react-hooks/set-state-in-effect is new in
+ // eslint-config-next 16; this is the standard pattern for it).
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- see comment above
setMounted(true);
}, []);
diff --git a/components/patterns/auth-visual.tsx b/components/patterns/auth-visual.tsx
index d02b1c4..d82f3e5 100644
--- a/components/patterns/auth-visual.tsx
+++ b/components/patterns/auth-visual.tsx
@@ -13,7 +13,7 @@ export function AuthVisual() {
src="/marketing/auth/hackathon-builders-welcome.webp"
alt=""
fill
- priority
+ preload
sizes="50vw"
className="object-cover object-top"
/>
diff --git a/components/patterns/legal-toc.tsx b/components/patterns/legal-toc.tsx
index e4aa6ae..d9ae9b6 100644
--- a/components/patterns/legal-toc.tsx
+++ b/components/patterns/legal-toc.tsx
@@ -25,6 +25,10 @@ export function LegalToc({ containerId }: { containerId: string }) {
headings.forEach((heading, index) => {
heading.id = ids[index];
});
+ // Builds the TOC from headings read out of the DOM after mount, which
+ // only exists client-side (react-hooks/set-state-in-effect is new in
+ // eslint-config-next 16 and flags this DOM-read-then-setState pattern).
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- see comment above
setEntries(
headings.map((heading, index) => ({
id: ids[index],
diff --git a/components/patterns/mobile-nav.tsx b/components/patterns/mobile-nav.tsx
index e6d5951..af8309a 100644
--- a/components/patterns/mobile-nav.tsx
+++ b/components/patterns/mobile-nav.tsx
@@ -37,6 +37,10 @@ export function MobileNav({ navLinks, organizerLinks, trailingLinks }: MobileNav
const [mounted, setMounted] = useState(false);
useEffect(() => {
+ // Hydration-safe mount flag: intentionally renders differently once the
+ // client has mounted (react-hooks/set-state-in-effect is new in
+ // eslint-config-next 16; this is the standard pattern for it).
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- see comment above
setMounted(true);
}, []);
diff --git a/eslint.config.mjs b/eslint.config.mjs
new file mode 100644
index 0000000..8f8d195
--- /dev/null
+++ b/eslint.config.mjs
@@ -0,0 +1,39 @@
+import { defineConfig, globalIgnores } from "eslint/config";
+import nextVitals from "eslint-config-next/core-web-vitals";
+import nextTs from "eslint-config-next/typescript";
+
+export default defineConfig([
+ ...nextVitals,
+ ...nextTs,
+ globalIgnores([
+ ".next/**",
+ "out/**",
+ "build/**",
+ "coverage/**",
+ "next-env.d.ts",
+ // Hardhat output (contracts carry their own toolchain).
+ "artifacts/**",
+ "cache/**",
+ "typechain-types/**",
+ "public/uploads/**",
+ ]),
+ {
+ rules: {
+ // Escrow/payout service boundaries are hard (ADR-001): vendor SDKs are
+ // only reachable through lib/ports. Kept as a warning until the boundary
+ // import lint rule lands in Phase 3.
+ "no-restricted-imports": [
+ "warn",
+ {
+ patterns: [
+ {
+ group: ["ethers"],
+ message:
+ "Vendor SDKs must be accessed via lib/ports — never imported directly (ADR-001).",
+ },
+ ],
+ },
+ ],
+ },
+ },
+]);
diff --git a/lib/auth/guards.ts b/lib/auth/guards.ts
index a0ed0be..a76b198 100644
--- a/lib/auth/guards.ts
+++ b/lib/auth/guards.ts
@@ -7,9 +7,8 @@ import { canAccessSurface, isOnboarded } from "@/lib/auth/rbac";
/**
* Server-side guards (Phase 1). Layouts and server actions call these —
- * enforcement NEVER lives in the client. Middleware is deliberately unused:
- * the session check reads Prisma (revocation, roles), which doesn't belong in
- * middleware; the layout tree is the single, stronger gate.
+ * enforcement NEVER lives in the client. proxy.ts only refreshes sessions and
+ * never decides access: the layout tree is the single, stronger gate.
*/
export interface CurrentUser extends SessionUserLike {
diff --git a/lib/auth/index.ts b/lib/auth/index.ts
index 9636339..9203aa8 100644
--- a/lib/auth/index.ts
+++ b/lib/auth/index.ts
@@ -135,7 +135,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
// jwt callback ends any session whose version is stale or whose account is
// gone (ADR-005 amended; see lib/auth/session-version.ts).
// The cookie lives as long as the longest inactivity window and slides on
- // every request (middleware.ts refreshes it). The exact per-role idle and
+ // every request (proxy.ts refreshes it). The exact per-role idle and
// overall limits are enforced in the jwt callback (lib/auth/session-policy).
session: { strategy: "jwt", maxAge: MAX_IDLE_SECONDS },
pages: { signIn: "/signin", error: "/signin" },
diff --git a/next.config.ts b/next.config.ts
index 88f5e76..0c47ccb 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -103,7 +103,7 @@ function sentryIngestOrigin(): string | null {
* keeps 'unsafe-inline' because the App Router streams its RSC payload in
* inline