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
7 changes: 5 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions middleware.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { NextResponse, type NextRequest } from "next/server";
import { gate } from "@/lib/crawl-gateway";
import { meter } from "@/lib/throttle";
import { trackReferralCode } from "@profullstack/stack/referrals";

export async function middleware(request: NextRequest) {
Expand All @@ -8,6 +9,11 @@ export async function middleware(request: NextRequest) {
// middleware.ts is the one Next runs here; src/proxy.ts is not wired.
const answer = await gate(request);
if (answer) return answer;

// Then the site-wide allowance on page routes: 100 requests a minute per
// caller, and going over is answered 402 with the same offer (lib/throttle).
const overLimit = await meter(request);
if (overLimit) return overLimit;
return trackReferralCode(request, NextResponse.next() as any);
}

Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@
"@profullstack/footer": "latest",
"@profullstack/referrals": "^0.1.0",
"@profullstack/stack": "^0.1.3",
"@profullstack/x402-gateway": "^0.7.0",
"@profullstack/throttle": "^0.3.2",
"@profullstack/x402-gateway": "^0.10.1",
"@sentry/nextjs": "^10.56.0",
"@supabase/ssr": "^0.9.0",
"@supabase/supabase-js": "^2.107.0",
Expand Down
35 changes: 35 additions & 0 deletions src/lib/throttle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* The site-wide allowance: a hundred requests a minute, per caller, on every
* page route. Going over is answered 402 with the crawl gateway's offer, not
* 429. Wired 2026-10-19 (bot hits decisions: "yes" to a gateway on every site).
*
* WHY. The gate charges crawlers that say who they are. A scraper wearing a
* browser string was never counted here at all; that is the shape that walked
* 19,000 URLs a day off coinpayportal on 2026-09-08.
*
* What is never metered: /api/ (webhooks, daemons, CLIs and polling clients
* have their own limits and must never be answered with a price), a Next.js
* link prefetch (a page already loaded asking ahead for the links it shows; a
* front page full of links would otherwise spend a reader's allowance for
* them), and whatever the gateway vouches for (verified search crawlers,
* retrieval crawlers, AI chat fetchers, paid passes).
*
* Imports nothing Node-only: the middleware may run at the edge.
*/

import { createThrottle } from "@profullstack/throttle";
import { gateway } from "@/lib/crawl-gateway";

const isPrefetch = (request: Request) =>
request.headers.has("next-router-prefetch") ||
/prefetch/i.test(request.headers.get("sec-purpose") ?? request.headers.get("purpose") ?? "");

export const throttle = createThrottle({
gateway,
exempt: isPrefetch,
credential: { limit: 600, ceiling: 1200 },
rules: [{ path: "/api/", open: true }],
});

/** Resolves to a Response for a caller over the allowance, or undefined. */
export const meter = (request: Request) => throttle.handle(request);
6 changes: 6 additions & 0 deletions src/proxy.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { gate } from "@/lib/crawl-gateway";
import { meter } from "@/lib/throttle";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { updateSession } from "@/lib/supabase/middleware";
Expand All @@ -15,6 +16,11 @@ export async function proxy(request: NextRequest) {
const answer = await gate(request);
if (answer) return answer;

// Then the site-wide allowance on page routes: 100 requests a minute per
// caller, and going over is answered 402 with the same offer (lib/throttle).
const overLimit = await meter(request);
if (overLimit) return overLimit;

const hostname = request.headers.get("host") || "";

// Redirect www to non-www
Expand Down
Loading