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
35 changes: 35 additions & 0 deletions app/npm/[...path]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// Same-origin mirror of @profullstack/webmcp for the stats.js WebMCP plugin.
// GET /npm/@profullstack/webmcp@0/src/<module>.js
//
// Sites that run stats.js allow crawlproof.com in their CSP but often not
// cdn.jsdelivr.net, so the plugin loads the embed from here. Only the
// package's own modules are served, fetched from jsDelivr's @0 alias so a
// new 0.x release reaches every site without a crawlproof deploy. The embed
// imports its siblings relatively, so they resolve back to this route.

import { NextRequest, NextResponse } from "next/server";

export const runtime = "nodejs";

const MODULES = new Set(["embed.js", "core.js", "site.js", "forms.js", "mcp-bridge.js", "index.js"]);
const PREFIX = ["@profullstack", "webmcp@0", "src"];
const UPSTREAM = "https://cdn.jsdelivr.net/npm/@profullstack/webmcp@0/src/";

export async function GET(_request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
const { path } = await params;
const file = path?.[3];
if (!path || path.length !== 4 || PREFIX.some((p, i) => path[i] !== p) || !file || !MODULES.has(file)) {
return new NextResponse("not found", { status: 404 });
}
const upstream = await fetch(UPSTREAM + file, { next: { revalidate: 900 } }).catch(() => null);
if (!upstream?.ok) return new NextResponse("upstream unavailable", { status: 502 });
return new NextResponse(await upstream.text(), {
headers: {
"content-type": "application/javascript; charset=utf-8",
// Module scripts are fetched in CORS mode from the customer's origin.
"access-control-allow-origin": "*",
"cache-control": "public, max-age=900, s-maxage=900",
"x-webmcp-version": upstream.headers.get("x-jsd-version") ?? "",
},
});
}
9 changes: 6 additions & 3 deletions app/stats.js/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,12 @@
import { env } from "@/lib/env";
import { VISITOR_SNIPPET } from "@/lib/tracker/visitorSnippet";

// The opt-in WebMCP plugin (data-webmcp) loads this. @0 follows every 0.x
// release of github.com/profullstack/webmcp, the same way sites tag it.
const WEBMCP_EMBED = "https://cdn.jsdelivr.net/npm/@profullstack/webmcp@0/src/embed.js";
// The WebMCP plugin loads this. It is served from crawlproof.com itself
// (app/npm/[...path], proxying jsDelivr @0) because every site that runs
// stats.js already allows crawlproof.com in its CSP, while many do not allow
// cdn.jsdelivr.net. The path keeps "@profullstack/webmcp" so the load-once
// check below still recognises it.
const WEBMCP_EMBED = `${env.siteUrl}/npm/@profullstack/webmcp@0/src/embed.js`;

const snippet = `(function(){
try {
Expand Down
11 changes: 11 additions & 0 deletions lib/crawl-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,14 @@ export const isAdClickPath = (path: string) => path.startsWith("/a/") || path ==
/** Email tracking endpoints: /t/<id>/(o.png|c|u) and /api/v1/tracking/<id>/events. */
export const isEmailTrackingPath = (path: string) =>
path.startsWith("/t/") || path.startsWith("/api/v1/tracking/");
/**
* The tracker's own embeddable scripts and the WebMCP plugin they load:
* /stats.js, /careers.js, /npm/@profullstack/webmcp@…, /api/tracker/v1/webmcp.
* Static, public, and fetched by customers' pages (including cloud-hosted
* agent browsers, the visitors WebMCP is for), so never the crawl gateway's 402.
*/
export const isTrackerAssetPath = (path: string) =>
path === "/stats.js" ||
path === "/careers.js" ||
path.startsWith("/npm/@profullstack/webmcp@") ||
path === "/api/tracker/v1/webmcp";
5 changes: 4 additions & 1 deletion proxy.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { gate } from "@/lib/crawl-gateway";
import { isAdClickPath, isEmailTrackingPath } from "@/lib/crawl-policy";
import { isAdClickPath, isEmailTrackingPath, isTrackerAssetPath } from "@/lib/crawl-policy";
import { NextResponse, type NextRequest } from "next/server";
import { createServerClient, type CookieOptions } from "@supabase/ssr";
import { trackReferralCode } from "@profullstack/stack/referrals";
Expand All @@ -17,6 +17,9 @@ export async function proxy(request: NextRequest) {
// proxies, link scanners and the sender's CLI. None of them should meet the
// crawl gateway's 402, and none of them carry a session worth refreshing.
if (isEmailTrackingPath(request.nextUrl.pathname)) return NextResponse.next();
// Tracker scripts embedded on customers' pages (stats.js, careers.js, the
// WebMCP plugin): public static assets, no session, never a 402.
if (isTrackerAssetPath(request.nextUrl.pathname)) return NextResponse.next();
// Crawl gateway first: AI training crawlers get 402 Payment Required (or the
// sales page at /crawl) unless they present a paid pass. People, Googlebot
// and retrieval crawlers fall through to everything below.
Expand Down
4 changes: 3 additions & 1 deletion tests/contract/stats-js.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,9 @@ describe("/stats.js", () => {
// Dashboard opt-in is only asked for when the tag says nothing.
expect(script).toContain("/api/tracker/v1/webmcp?site=");
expect(script).toContain("window.__crawlproofWebMCP");
expect(script).toContain("https://cdn.jsdelivr.net/npm/@profullstack/webmcp@0/src/embed.js");
// Same-origin mirror: sites already allow crawlproof.com in their CSP.
expect(script).toContain("/npm/@profullstack/webmcp@0/src/embed.js");
expect(script).not.toContain("cdn.jsdelivr.net");
expect(script).toContain("ws.type = 'module'");
// Never loaded twice when the page carries its own embed.
expect(script).toContain("window.ProfullstackWebMCP");
Expand Down