@ooky/sdk 0.1.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/express.js CHANGED
@@ -1,20 +1,45 @@
1
1
  /**
2
2
  * Express adapter — `app.use(ookyMiddleware({ apiKey, domain }))`.
3
3
  *
4
- * Intercepts the well-known AI paths and serves the manifest. For every
5
- * request (manifest or not), checks the User-Agent against the bot registry
6
- * and fires a fire-and-forget event when a bot is detected.
4
+ * Intercepts the well-known AI paths and serves the manifest, answers MCP
5
+ * tool invocations on POST /mcp, and for every request checks the
6
+ * User-Agent against the bot registry — firing a fire-and-forget event when
7
+ * a bot is detected, or an ai_referral event when a human arrives from an
8
+ * AI platform (ChatGPT, Perplexity, Claude, …).
7
9
  */
8
10
 
9
- import { createOokyHandler } from "./core.js";
11
+ import {
12
+ createOokyHandler,
13
+ MAX_MCP_BODY_BYTES,
14
+ MAX_UA_LENGTH,
15
+ MAX_PATH_LENGTH,
16
+ clampString,
17
+ } from "./core.js";
10
18
 
11
19
  export function ookyMiddleware(options) {
12
20
  const handler = createOokyHandler(options);
13
21
 
14
22
  return async function ookyHandler(req, res, next) {
15
- const ua = req.headers["user-agent"] || "";
23
+ const rawUa = req.headers["user-agent"] || "";
16
24
  const path = req.path || req.url || "/";
17
- const bot = handler.detectBot(ua);
25
+ // Defensive caps on untrusted strings before they enter the event payload.
26
+ const ua = clampString(rawUa, MAX_UA_LENGTH);
27
+ const pagePath = clampString(path.split("?")[0] || "/", MAX_PATH_LENGTH);
28
+ const method = req.method || "GET";
29
+ const country = countryFromExpress(req);
30
+
31
+ // detectBot / matchPath run on EVERY request. By contract they never
32
+ // throw (a malformed bot registry is sanitised in core), but a middleware
33
+ // must not be able to crash the customer's process under ANY circumstance
34
+ // — so we degrade to pass-through if anything here throws.
35
+ let bot = null;
36
+ let kind = null;
37
+ try {
38
+ bot = handler.detectBot(ua);
39
+ kind = handler.matchPath(path);
40
+ } catch {
41
+ return next();
42
+ }
18
43
 
19
44
  // Fire bot event regardless of whether we serve the manifest. The Ooky
20
45
  // dashboard tracks bot visits across all routes, not just /llms.txt.
@@ -24,24 +49,126 @@ export function ookyMiddleware(options) {
24
49
  handler.recordEvent({
25
50
  bot: { name: bot.name, verified: false, ua_string: ua },
26
51
  request: {
27
- page_path: path.split("?")[0] || "/",
28
- method: req.method || "GET",
52
+ page_path: pagePath,
53
+ method,
54
+ manifest_file: kind || null,
29
55
  },
56
+ geo: country ? { country } : null,
30
57
  });
58
+ } else {
59
+ // Human traffic: attribute visits referred by AI platforms.
60
+ const referral = handler.detectReferral(
61
+ req.headers["referer"] || req.headers["referrer"],
62
+ req.query?.utm_source ?? parseUtmSource(req.url)
63
+ );
64
+ if (referral) {
65
+ handler.recordEvent({
66
+ event_type: "ai_referral",
67
+ referral: {
68
+ source: referral.source,
69
+ referrer_url: referral.referrerUrl,
70
+ detection_method: referral.method,
71
+ },
72
+ request: { page_path: pagePath, method },
73
+ geo: country ? { country } : null,
74
+ });
75
+ }
31
76
  }
32
77
 
33
- const kind = handler.matchPath(path);
34
78
  if (!kind) return next();
35
79
 
36
- const { status, headers, body } = await handler.serveManifest(kind);
37
- res.status(status);
38
- for (const [k, v] of Object.entries(headers)) {
39
- res.setHeader(k, v);
40
- }
41
- if (typeof body === "string") {
42
- res.send(body);
43
- } else {
44
- res.json(body);
80
+ try {
81
+ let result;
82
+ if (kind === "mcp" && req.method === "POST") {
83
+ result = await handler.handleMcpInvocation(await readJsonBody(req));
84
+ } else if (kind === "mcp" && req.method === "OPTIONS") {
85
+ result = { status: 204, headers: corsPreflightHeaders(), body: "" };
86
+ } else {
87
+ result = await handler.serveManifest(kind);
88
+ }
89
+
90
+ const { status, headers, body } = result;
91
+ if (res.headersSent) return;
92
+ res.status(status);
93
+ for (const [k, v] of Object.entries(headers)) {
94
+ res.setHeader(k, v);
95
+ }
96
+ if (body === null || body === undefined) {
97
+ res.end(); // e.g. 202 for MCP notifications
98
+ } else if (typeof body === "string") {
99
+ res.send(body);
100
+ } else {
101
+ res.json(body);
102
+ }
103
+ } catch (err) {
104
+ // serveManifest never throws by contract, but a middleware must not be
105
+ // able to crash the customer's app under any circumstances.
106
+ if (!res.headersSent) next(err);
45
107
  }
46
108
  };
47
109
  }
110
+
111
+ function corsPreflightHeaders() {
112
+ return {
113
+ "Access-Control-Allow-Origin": "*",
114
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
115
+ "Access-Control-Allow-Headers": "Content-Type",
116
+ };
117
+ }
118
+
119
+ /**
120
+ * Best-effort country from edge/CDN headers so the dashboard's geo panel
121
+ * isn't empty for SDK customers. Cloudflare sets `cf-ipcountry`; Vercel sets
122
+ * `x-vercel-ip-country`. "XX"/"T1" are CF placeholders for unknown/Tor — drop
123
+ * them. Returns a 2-letter uppercase code or null.
124
+ */
125
+ function countryFromExpress(req) {
126
+ const raw =
127
+ req.headers["cf-ipcountry"] ||
128
+ req.headers["x-vercel-ip-country"] ||
129
+ req.headers["x-appengine-country"] ||
130
+ "";
131
+ return normalizeCountry(raw);
132
+ }
133
+
134
+ function normalizeCountry(raw) {
135
+ if (!raw || typeof raw !== "string") return null;
136
+ const code = raw.trim().toUpperCase();
137
+ if (code.length !== 2 || code === "XX" || code === "T1") return null;
138
+ return code;
139
+ }
140
+
141
+ /** Fallback utm_source extraction when req.query isn't populated. */
142
+ function parseUtmSource(url) {
143
+ if (!url || !url.includes("utm_source=")) return null;
144
+ try {
145
+ return new URL(url, "http://localhost").searchParams.get("utm_source");
146
+ } catch {
147
+ return null;
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Read the request body as JSON. Uses req.body when a body parser already
153
+ * consumed the stream (e.g. express.json() mounted before us); otherwise
154
+ * reads the raw stream with a size cap. Returns null on any parse failure —
155
+ * handleMcpInvocation turns that into a 400.
156
+ */
157
+ async function readJsonBody(req) {
158
+ if (req.body && typeof req.body === "object" && Object.keys(req.body).length > 0) {
159
+ return req.body;
160
+ }
161
+ try {
162
+ let size = 0;
163
+ const chunks = [];
164
+ for await (const chunk of req) {
165
+ size += chunk.length;
166
+ if (size > MAX_MCP_BODY_BYTES) return null;
167
+ chunks.push(chunk);
168
+ }
169
+ if (chunks.length === 0) return null;
170
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
171
+ } catch {
172
+ return null;
173
+ }
174
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Type declarations for @ooky/sdk. Hand-written — the package ships plain
3
+ * ESM JavaScript with no build step; keep these in sync with src/core.js.
4
+ */
5
+
6
+ export type ManifestKind = "llms" | "llms-full" | "manifest" | "agents" | "mcp";
7
+
8
+ export interface BotEntry {
9
+ name: string;
10
+ /** Case-insensitive substring matched against the User-Agent. */
11
+ pattern: string;
12
+ category?: "ai" | "search" | "social" | "other" | string;
13
+ verified?: boolean;
14
+ }
15
+
16
+ export interface OokyErrorContext {
17
+ op: "recordEvent" | "serveManifest" | "refreshBotRegistry";
18
+ /** HTTP status when the failure was a non-2xx upstream response. */
19
+ status?: number;
20
+ kind?: ManifestKind;
21
+ /** True when events were dropped by the maxEventsPerMinute token bucket. */
22
+ throttled?: boolean;
23
+ }
24
+
25
+ export interface AIReferral {
26
+ source: string;
27
+ referrerUrl: string | null;
28
+ method: "referer_header" | "utm_param";
29
+ }
30
+
31
+ export interface OokyHandlerOptions {
32
+ /** Bearer token from the dashboard (`ooky_sk_*`). */
33
+ apiKey: string;
34
+ /** Domain registered in Ooky, e.g. "acme.com". */
35
+ domain: string;
36
+ /** Override the Ooky API base URL. Default: https://api.ooky.ai/api */
37
+ apiBase?: string;
38
+ /** Override the manifest CDN base URL. Default: `${apiBase}/public/manifest` */
39
+ cdnBase?: string;
40
+ /** Override the bot registry. Default: the built-in list. */
41
+ bots?: BotEntry[];
42
+ /** Periodically refresh the bot registry from /api/public/bots. Default: true */
43
+ autoRefreshBots?: boolean;
44
+ /** Upstream fetch timeout in ms (manifest + registry + events). Default: 10000 */
45
+ fetchTimeoutMs?: number;
46
+ /** In-memory manifest cache TTL in ms. 0 disables caching. Default: 300000 */
47
+ manifestCacheTtlMs?: number;
48
+ /**
49
+ * Called for every failure the SDK swallows (event POST rejections and
50
+ * non-2xx responses — e.g. 401 after a key rotation — manifest fetch
51
+ * failures, registry refresh failures). Default: silent.
52
+ */
53
+ onError?: (error: Error, context: OokyErrorContext) => void;
54
+ /**
55
+ * Token-bucket cap on event POSTs per minute (bot-storm insurance).
56
+ * Drops are reported via onError. Pass Infinity to disable. Default: 300
57
+ */
58
+ maxEventsPerMinute?: number;
59
+ }
60
+
61
+ export interface ManifestResponse {
62
+ status: number;
63
+ headers: Record<string, string>;
64
+ /** String for text kinds (llms, llms-full, agents); object for JSON kinds. */
65
+ body: string | Record<string, unknown>;
66
+ }
67
+
68
+ export interface OokyEventPayload {
69
+ event_id?: string;
70
+ timestamp?: string;
71
+ /** Set to "ai_referral" (with `referral`) for AI-platform referral visits. */
72
+ event_type?: string;
73
+ referral?: {
74
+ source: string;
75
+ referrer_url?: string | null;
76
+ detection_method?: string;
77
+ } | null;
78
+ bot?: { name: string; verified?: boolean; ua_string?: string } | null;
79
+ request?: {
80
+ page_path?: string;
81
+ method?: string;
82
+ manifest_version?: string | null;
83
+ manifest_file?: string | null;
84
+ } | null;
85
+ session?: Record<string, unknown> | null;
86
+ geo?: { country?: string | null } | null;
87
+ serve?: Record<string, unknown> | null;
88
+ }
89
+
90
+ export interface OokyHandler {
91
+ matchPath(path: string): ManifestKind | null;
92
+ detectBot(userAgent: string): BotEntry | null;
93
+ /** Detect a human visit referred from an AI platform (Referer / utm_source). */
94
+ detectReferral(referer?: string | null, utmSource?: string | null): AIReferral | null;
95
+ serveManifest(kind: ManifestKind): Promise<ManifestResponse>;
96
+ /**
97
+ * Answer an MCP request (POST /mcp or /.well-known/mcp). Speaks BOTH
98
+ * protocols:
99
+ * - Standard MCP — JSON-RPC 2.0 over streamable HTTP (`initialize`,
100
+ * `tools/list`, `tools/call`, `ping`). What real MCP clients (Claude,
101
+ * MCP Inspector) use; detected by `jsonrpc: "2.0"` on the body. A `null`
102
+ * body (unparseable JSON) returns a JSON-RPC parse error (-32700).
103
+ * - Legacy Ooky — `{ tool, arguments }` → `{ result }`, kept for
104
+ * Worker-tier compatibility.
105
+ */
106
+ handleMcpInvocation(body: unknown): Promise<ManifestResponse>;
107
+ /** Fire-and-forget; never rejects. Hand to `event.waitUntil()` on edge runtimes. */
108
+ recordEvent(payload: OokyEventPayload): Promise<unknown>;
109
+ refreshBotRegistry(force?: boolean): Promise<BotEntry[]>;
110
+ /** In-flight registry refresh, if any. Hand to `event.waitUntil()` on edge runtimes. */
111
+ pendingBotRefresh(): Promise<BotEntry[]> | null;
112
+ }
113
+
114
+ export const SDK_VERSION: string;
115
+ /** Best-effort runtime tag stamped into the `X-Ooky-Sdk` header ("node" | "edge" | "web"). */
116
+ export const SDK_RUNTIME: string;
117
+ /** Max MCP request body the adapters accept (64KB). */
118
+ export const MAX_MCP_BODY_BYTES: number;
119
+ /** Defensive cap on the bot User-Agent copied into events (1024). */
120
+ export const MAX_UA_LENGTH: number;
121
+ /** Defensive cap on the request path copied into events (2048). */
122
+ export const MAX_PATH_LENGTH: number;
123
+ /** Truncate an untrusted string to `max` chars; non-strings pass through. */
124
+ export function clampString<T>(value: T, max: number): T;
125
+ export function createOokyHandler(options: OokyHandlerOptions): OokyHandler;
package/src/mcp.js ADDED
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Stateless MCP (Model Context Protocol) JSON-RPC 2.0 handler.
3
+ *
4
+ * Real MCP clients (Claude, MCP Inspector, ChatGPT connectors) speak
5
+ * JSON-RPC over streamable HTTP: initialize → notifications/initialized →
6
+ * tools/list → tools/call. This module implements the stateless subset —
7
+ * each POST is handled independently, single JSON response per request (the
8
+ * spec allows servers to answer with plain application/json instead of an
9
+ * SSE stream).
10
+ *
11
+ * Mirrors worker/src/mcp.js — when you change the protocol surface here,
12
+ * change it there too. Runtime-agnostic: no Node-only APIs.
13
+ */
14
+
15
+ export const MCP_PROTOCOL_VERSION = "2025-03-26";
16
+
17
+ // JSON-RPC 2.0 error codes.
18
+ const PARSE_ERROR = -32700;
19
+ const INVALID_REQUEST = -32600;
20
+ const METHOD_NOT_FOUND = -32601;
21
+ const INVALID_PARAMS = -32602;
22
+ const INTERNAL_ERROR = -32603;
23
+
24
+ /**
25
+ * Handle one MCP JSON-RPC message.
26
+ *
27
+ * @param {unknown} message Parsed request body (or null on JSON parse failure).
28
+ * @param {object} server
29
+ * @param {string} server.name serverInfo.name
30
+ * @param {string} server.version serverInfo.version
31
+ * @param {Array} server.tools Tool descriptors ({ name, description, inputSchema }).
32
+ * @param {Function} server.callTool async (name, args) → result object, or throws
33
+ * McpToolError for invalid-params-class failures.
34
+ * @returns {{ status: number, body: object|null }} body null → empty response (notifications).
35
+ */
36
+ export async function handleMcpJsonRpc(message, server) {
37
+ if (message === null || message === undefined) {
38
+ return rpcError(null, PARSE_ERROR, "Parse error: body must be valid JSON");
39
+ }
40
+ // JSON-RPC batches were removed from MCP in the 2025-06-18 revision; we
41
+ // never supported them, so reject explicitly.
42
+ if (Array.isArray(message)) {
43
+ return rpcError(null, INVALID_REQUEST, "Batch requests are not supported");
44
+ }
45
+ if (typeof message !== "object" || message.jsonrpc !== "2.0" || typeof message.method !== "string") {
46
+ return rpcError(message?.id ?? null, INVALID_REQUEST, "Invalid JSON-RPC 2.0 request");
47
+ }
48
+
49
+ const { id, method, params } = message;
50
+ const isNotification = id === undefined || id === null;
51
+
52
+ // Notifications get 202 Accepted with no body (streamable HTTP transport).
53
+ if (method.startsWith("notifications/")) {
54
+ return { status: 202, body: null };
55
+ }
56
+ if (isNotification) {
57
+ // Requests we'd have to answer but can't address — accept and drop.
58
+ return { status: 202, body: null };
59
+ }
60
+
61
+ try {
62
+ switch (method) {
63
+ case "initialize":
64
+ return rpcResult(id, {
65
+ protocolVersion: negotiateVersion(params?.protocolVersion),
66
+ capabilities: { tools: {} },
67
+ serverInfo: { name: server.name, version: server.version },
68
+ });
69
+
70
+ case "ping":
71
+ return rpcResult(id, {});
72
+
73
+ case "tools/list":
74
+ return rpcResult(id, { tools: server.tools });
75
+
76
+ case "tools/call": {
77
+ const name = params?.name;
78
+ if (!name || typeof name !== "string") {
79
+ return rpcError(id, INVALID_PARAMS, "tools/call requires params.name");
80
+ }
81
+ if (!server.tools.some((t) => t.name === name)) {
82
+ return rpcError(id, INVALID_PARAMS, `Unknown tool: ${name}`);
83
+ }
84
+ const data = await server.callTool(name, params?.arguments || {});
85
+ return rpcResult(id, {
86
+ content: [{ type: "text", text: JSON.stringify(data) }],
87
+ isError: false,
88
+ });
89
+ }
90
+
91
+ default:
92
+ return rpcError(id, METHOD_NOT_FOUND, `Method not found: ${method}`);
93
+ }
94
+ } catch (err) {
95
+ if (err instanceof McpToolError) {
96
+ // Tool execution failures are reported in-band per the MCP spec so the
97
+ // LLM can see them, not as protocol errors.
98
+ return rpcResult(id, {
99
+ content: [{ type: "text", text: err.message }],
100
+ isError: true,
101
+ });
102
+ }
103
+ return rpcError(id, INTERNAL_ERROR, "Internal error");
104
+ }
105
+ }
106
+
107
+ /** Throw from callTool to report a tool-level failure in-band (isError: true). */
108
+ export class McpToolError extends Error {}
109
+
110
+ /**
111
+ * Echo the client's requested protocol version when we can speak it,
112
+ * otherwise offer ours (the client disconnects if that's unacceptable).
113
+ */
114
+ function negotiateVersion(requested) {
115
+ if (typeof requested === "string" && requested <= MCP_PROTOCOL_VERSION) {
116
+ return requested;
117
+ }
118
+ return MCP_PROTOCOL_VERSION;
119
+ }
120
+
121
+ function rpcResult(id, result) {
122
+ return { status: 200, body: { jsonrpc: "2.0", id, result } };
123
+ }
124
+
125
+ function rpcError(id, code, message) {
126
+ return { status: 200, body: { jsonrpc: "2.0", id, error: { code, message } } };
127
+ }
package/src/next.d.ts ADDED
@@ -0,0 +1,12 @@
1
+ import type { OokyHandlerOptions } from "./index";
2
+
3
+ /**
4
+ * Next.js middleware (Node or Edge runtime).
5
+ * Returns a Response for the well-known AI paths, undefined to fall through.
6
+ */
7
+ export function ookyMiddleware(
8
+ options: OokyHandlerOptions
9
+ ): (
10
+ request: Request,
11
+ event?: { waitUntil(promise: Promise<unknown>): void }
12
+ ) => Promise<Response | undefined>;
package/src/next.js CHANGED
@@ -8,36 +8,144 @@
8
8
  *
9
9
  * Returns a function with the Next.js middleware signature: it receives a
10
10
  * NextRequest-like object (compatible Web Request) and returns a Response,
11
- * or undefined to fall through to the next middleware/route.
11
+ * or undefined to fall through to the next middleware/route. Serves the
12
+ * well-known AI paths, answers MCP tool invocations on POST /mcp, and fires
13
+ * bot / ai_referral analytics events.
12
14
  */
13
15
 
14
- import { createOokyHandler } from "./core.js";
16
+ import { createOokyHandler, MAX_MCP_BODY_BYTES, MAX_UA_LENGTH, MAX_PATH_LENGTH, clampString } from "./core.js";
15
17
 
16
18
  export function ookyMiddleware(options) {
17
19
  const handler = createOokyHandler(options);
18
20
 
19
- return async function ookyNextMiddleware(request) {
21
+ return async function ookyNextMiddleware(request, event) {
20
22
  const url = new URL(request.url);
21
- const ua = request.headers.get("user-agent") || "";
22
- const bot = handler.detectBot(ua);
23
+ const rawUa = request.headers.get("user-agent") || "";
24
+ const ua = clampString(rawUa, MAX_UA_LENGTH);
25
+ const pagePath = clampString(url.pathname || "/", MAX_PATH_LENGTH);
26
+ const method = request.method || "GET";
27
+ const country = countryFromRequest(request);
28
+
29
+ const waitUntil =
30
+ event && typeof event.waitUntil === "function"
31
+ ? (p) => event.waitUntil(p)
32
+ : () => {};
33
+
34
+ // detectBot / matchPath run on EVERY request. They never throw by
35
+ // contract (the registry is sanitised in core), but a middleware must
36
+ // never be able to crash the customer's app — degrade to pass-through.
37
+ let bot = null;
38
+ let kind = null;
39
+ try {
40
+ bot = handler.detectBot(ua);
41
+ kind = handler.matchPath(url.pathname);
42
+ } catch {
43
+ return undefined;
44
+ }
45
+
46
+ // detectBot may have kicked off the hourly bot-registry refresh; keep it
47
+ // alive past the response on edge runtimes.
48
+ const refresh = handler.pendingBotRefresh();
49
+ if (refresh) waitUntil(refresh);
23
50
 
24
51
  // `verified: false` — UA-only matching can't prove bot identity. The
25
52
  // Worker tier does IP + reverse-DNS verification; the SDK can't.
26
53
  if (bot) {
27
- handler.recordEvent({
28
- bot: { name: bot.name, verified: false, ua_string: ua },
29
- request: {
30
- page_path: url.pathname || "/",
31
- method: request.method || "GET",
32
- },
33
- });
54
+ waitUntil(
55
+ handler.recordEvent({
56
+ bot: { name: bot.name, verified: false, ua_string: ua },
57
+ request: {
58
+ page_path: pagePath,
59
+ method,
60
+ manifest_file: kind || null,
61
+ },
62
+ geo: country ? { country } : null,
63
+ })
64
+ );
65
+ } else {
66
+ // Human traffic: attribute visits referred by AI platforms.
67
+ const referral = handler.detectReferral(
68
+ request.headers.get("referer"),
69
+ url.searchParams.get("utm_source")
70
+ );
71
+ if (referral) {
72
+ waitUntil(
73
+ handler.recordEvent({
74
+ event_type: "ai_referral",
75
+ referral: {
76
+ source: referral.source,
77
+ referrer_url: referral.referrerUrl,
78
+ detection_method: referral.method,
79
+ },
80
+ request: { page_path: pagePath, method },
81
+ geo: country ? { country } : null,
82
+ })
83
+ );
84
+ }
34
85
  }
35
86
 
36
- const kind = handler.matchPath(url.pathname);
37
87
  if (!kind) return undefined;
38
88
 
39
- const { status, headers, body } = await handler.serveManifest(kind);
40
- const responseBody = typeof body === "string" ? body : JSON.stringify(body);
89
+ let result;
90
+ if (kind === "mcp" && request.method === "POST") {
91
+ // Cap the MCP request body (parity with the Express adapter's 64KB
92
+ // streaming cap). Reject oversized bodies via Content-Length before
93
+ // buffering the whole thing into memory with request.json().
94
+ const declaredLength = Number(request.headers.get("content-length"));
95
+ if (Number.isFinite(declaredLength) && declaredLength > MAX_MCP_BODY_BYTES) {
96
+ return new Response(JSON.stringify({ error: "Request body too large" }), {
97
+ status: 413,
98
+ headers: { "Content-Type": "application/json; charset=utf-8" },
99
+ });
100
+ }
101
+ const body = await request.json().catch(() => null);
102
+ result = await handler.handleMcpInvocation(body);
103
+ } else if (kind === "mcp" && request.method === "OPTIONS") {
104
+ return new Response(null, {
105
+ status: 204,
106
+ headers: {
107
+ "Access-Control-Allow-Origin": "*",
108
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
109
+ "Access-Control-Allow-Headers": "Content-Type",
110
+ },
111
+ });
112
+ } else {
113
+ result = await handler.serveManifest(kind);
114
+ }
115
+
116
+ const { status, headers, body } = result;
117
+ const responseBody =
118
+ body === null || body === undefined
119
+ ? null // e.g. 202 for MCP notifications
120
+ : typeof body === "string"
121
+ ? body
122
+ : JSON.stringify(body);
41
123
  return new Response(responseBody, { status, headers });
42
124
  };
43
125
  }
126
+
127
+ /**
128
+ * Best-effort country from edge headers so the dashboard's geo panel isn't
129
+ * empty for SDK customers. Prefers `request.geo?.country` (Vercel populates it
130
+ * on NextRequest), then Cloudflare `cf-ipcountry` / Vercel
131
+ * `x-vercel-ip-country` headers. "XX"/"T1" are CF unknown/Tor placeholders.
132
+ */
133
+ function countryFromRequest(request) {
134
+ const geoCountry = request && request.geo && request.geo.country;
135
+ if (geoCountry) {
136
+ const norm = normalizeCountry(geoCountry);
137
+ if (norm) return norm;
138
+ }
139
+ const raw =
140
+ request.headers.get("cf-ipcountry") ||
141
+ request.headers.get("x-vercel-ip-country") ||
142
+ "";
143
+ return normalizeCountry(raw);
144
+ }
145
+
146
+ function normalizeCountry(raw) {
147
+ if (!raw || typeof raw !== "string") return null;
148
+ const code = raw.trim().toUpperCase();
149
+ if (code.length !== 2 || code === "XX" || code === "T1") return null;
150
+ return code;
151
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * AI referrer detection — identifies humans arriving from AI platforms by
3
+ * Referer header or utm_source. Mirrors worker/src/referrals.js so the SDK
4
+ * and Worker tiers attribute the same visits; when you add a platform to
5
+ * one, add it to the other.
6
+ */
7
+
8
+ export const AI_REFERRERS = [
9
+ { pattern: "chatgpt.com", source: "chatgpt" },
10
+ { pattern: "chat.openai.com", source: "chatgpt" },
11
+ { pattern: "perplexity.ai", source: "perplexity" },
12
+ { pattern: "gemini.google.com", source: "gemini" },
13
+ { pattern: "bard.google.com", source: "gemini" },
14
+ { pattern: "copilot.microsoft.com", source: "copilot" },
15
+ { pattern: "bing.com/chat", source: "copilot" },
16
+ { pattern: "claude.ai", source: "claude" },
17
+ { pattern: "meta.ai", source: "meta_ai" },
18
+ { pattern: "grok.x.ai", source: "grok" },
19
+ { pattern: "you.com", source: "you" },
20
+ { pattern: "phind.com", source: "phind" },
21
+ { pattern: "deepseek.com", source: "deepseek" },
22
+ ];
23
+
24
+ export const UTM_SOURCES = [
25
+ { value: "chatgpt", source: "chatgpt" },
26
+ { value: "openai", source: "chatgpt" },
27
+ { value: "perplexity", source: "perplexity" },
28
+ { value: "gemini", source: "gemini" },
29
+ { value: "copilot", source: "copilot" },
30
+ { value: "claude", source: "claude" },
31
+ { value: "meta_ai", source: "meta_ai" },
32
+ { value: "grok", source: "grok" },
33
+ { value: "you", source: "you" },
34
+ { value: "phind", source: "phind" },
35
+ { value: "deepseek", source: "deepseek" },
36
+ ];
37
+
38
+ /**
39
+ * Detect an AI-platform referral from a Referer header and/or utm_source.
40
+ *
41
+ * @param {string|null|undefined} referer The request's Referer header.
42
+ * @param {string|null|undefined} utmSource The utm_source query param value.
43
+ * @returns {{ source: string, referrerUrl: string|null, method: string } | null}
44
+ */
45
+ export function detectAIReferral(referer, utmSource) {
46
+ if (referer && typeof referer === "string") {
47
+ const refererLower = referer.toLowerCase();
48
+ for (const entry of AI_REFERRERS) {
49
+ if (refererLower.includes(entry.pattern)) {
50
+ return {
51
+ source: entry.source,
52
+ referrerUrl: referer,
53
+ method: "referer_header",
54
+ };
55
+ }
56
+ }
57
+ }
58
+
59
+ if (utmSource && typeof utmSource === "string") {
60
+ const value = utmSource.toLowerCase();
61
+ for (const entry of UTM_SOURCES) {
62
+ if (value === entry.value) {
63
+ return {
64
+ source: entry.source,
65
+ referrerUrl: null,
66
+ method: "utm_param",
67
+ };
68
+ }
69
+ }
70
+ }
71
+
72
+ return null;
73
+ }