This page registers the API feedback form with Netlify. It should not be visible to users.
diff --git a/home/modules/ROOT/pages/how-to-use-these-docs.adoc b/home/modules/ROOT/pages/how-to-use-these-docs.adoc
index 95def7c6..66808c10 100644
--- a/home/modules/ROOT/pages/how-to-use-these-docs.adoc
+++ b/home/modules/ROOT/pages/how-to-use-these-docs.adoc
@@ -91,6 +91,33 @@ Redpanda provides a remote link:https://modelcontextprotocol.io[Model Context Pr
The MCP server is hosted at: `https://docs.redpanda.com/mcp`.
You can add this endpoint to any AI agent that supports MCP.
+[#authentication]
+==== Authentication
+
+The MCP server uses OAuth. The first time you connect, your MCP client opens a browser and prompts you to sign in with your link:https://cloud.redpanda.com[Redpanda Cloud account^] (free to create). Your client then obtains an access token automatically and reuses it on future requests, so you only sign in once.
+
+There's no token to copy or paste. Any MCP client that supports the standard MCP OAuth flow (including ChatGPT, Claude, Cursor, and VS Code) handles sign-in for you.
+
+If you don't have a Redpanda Cloud account yet, you can link:https://cloud.redpanda.com/sign-up[create one for free^].
+
+[NOTE]
+====
+*What we collect and why.* When you sign in, we receive your verified email address (and your organization, when available) from Redpanda Cloud, which we use to track documentation usage and attribute it to your organization. This may be shared with our customer and analytics systems and passed to our documentation search provider (Kapa) for usage attribution. For details, see our link:https://www.redpanda.com/legal/privacy-policy[Privacy Policy^].
+====
+
+
+
+You sign in once, and your MCP client keeps you signed in from then on. You don't need to manage, copy, or refresh anything yourself.
+
+Here's what happens behind the scenes:
+
+* When you sign in, your client receives a short-lived access token (valid for one hour) that it sends with each request.
+* Before that hour is up, your client quietly swaps it for a fresh one in the background. This happens automatically, without opening a browser or asking you to sign in again, so you won't notice it.
+* As long as you use the server at least once every 30 days, this renewal keeps going indefinitely and you stay signed in.
+* If you don't use the server for 30 days, the renewal lapses. The next time you connect, your client prompts you to sign in again, which is usually a quick browser redirect because you're often still signed in to Redpanda Cloud.
+
+In short: active users rarely, if ever, sign in again, and there's nothing to do manually. Token renewal is handled entirely by your MCP client.
+
[tabs]
====
Claude Code::
@@ -103,6 +130,8 @@ Run the following command to add the Redpanda MCP server to Claude Code:
claude mcp add --scope user --transport http redpanda https://docs.redpanda.com/mcp
----
+The first time you use the server, Claude Code prompts you to authenticate with your Redpanda Cloud account in the browser.
+
This command:
* Adds the MCP server with the name `redpanda`.
@@ -138,6 +167,8 @@ Add the following to your `.cursor/mcp.json` file:
}
----
+The first time you use the server, Cursor prompts you to sign in with your Redpanda Cloud account.
+
For more information about MCP in Cursor, see the https://docs.cursor.com/context/model-context-protocol[Cursor documentation^].
--
VS Code::
@@ -160,6 +191,8 @@ Create an `mcp.json` file in your workspace `.vscode` folder:
}
----
+The first time you use the server, VS Code prompts you to sign in with your Redpanda Cloud account.
+
To configure globally for all workspaces:
. Open Command Palette (kbd:[Cmd+Shift+P] / kbd:[Ctrl+Shift+P])
@@ -188,7 +221,9 @@ ChatGPT Desktop supports MCP servers in developer mode. To enable:
+
- *Name*: `redpanda`
- *URL*: `https://docs.redpanda.com/mcp`
+- *Authentication*: *OAuth*
+ChatGPT discovers the OAuth flow automatically and prompts you to sign in with your Redpanda Cloud account.
For more information, see the https://platform.openai.com/docs/guides/developer-mode[ChatGPT Desktop MCP documentation^].
--
@@ -205,7 +240,7 @@ This method is available for Pro, Max, Team, or Enterprise plans and works acros
. Navigate to Settings > Connectors.
. Click *Add custom connector*.
. Enter the URL: `https://docs.redpanda.com/mcp`
-. Follow any authentication prompts if required.
+. When prompted, sign in with your Redpanda Cloud account. The connector completes the OAuth flow automatically.
NOTE: When using Connectors, Claude connects to your remote MCP server from Anthropic's cloud infrastructure.
@@ -232,7 +267,7 @@ Add the following configuration to your `claude_desktop_config.json` file:
}
----
-This configuration uses the `mcp-remote` bridge to connect to Redpanda's remote MCP server. Claude Desktop supports only `stdio` and `sse` transports, so `mcp-remote` converts the HTTP endpoint to a compatible format.
+This configuration uses the `mcp-remote` bridge to connect to Redpanda's remote MCP server. On first use, `mcp-remote` opens a browser for you to sign in with your Redpanda Cloud account. Claude Desktop supports only `stdio` and `sse` transports, so `mcp-remote` converts the HTTP endpoint to a compatible format.
Restart Claude Desktop for changes to take effect.
@@ -244,14 +279,14 @@ For more details, see the https://support.anthropic.com/en/articles/9487310-desk
==== Other AI tools
-Any tool that supports MCP servers can connect using the following URL:
+Any tool that supports the MCP OAuth flow can connect using the following URL and will prompt you to sign in with your Redpanda Cloud account on first use:
[source,text]
----
https://docs.redpanda.com/mcp
----
-NOTE: MCP support varies by tool and version. Check the specific tool's documentation for MCP setup instructions.
+NOTE: MCP support varies by tool and version. Check the specific tool's documentation for MCP setup instructions. See <> for details on sign-in.
==== What you can do
@@ -264,13 +299,17 @@ Once connected, you can ask context-aware questions about Redpanda from within y
* "How do I monitor Redpanda cluster performance?"
* "What's the difference between Redpanda Cloud and self-hosted deployment?"
+You can also *send feedback to the Redpanda team* through your AI client. If you hit a bug, a documentation gap, or anything unclear, tell your assistant something like "send feedback that this page is missing X" and it can submit it for us to review. If you're signed in, we can follow up with you.
+
==== Usage limits
-To ensure fair use and performance for all users, the public MCP endpoint enforces the following rate limits per user:
+To ensure fair use and performance for all users, the MCP endpoint enforces the following rate limits:
* *60 requests per 15 minutes*
-As well as this global limit, the `ask_redpanda_question` tool proxies to an MCP server hosted by Kapa.ai, which enforces its own limits. See the link:https://docs.kapa.ai/integrations/mcp/overview#authentication[Kapa documentation^] for details.
+When you're signed in, this limit is applied per user. Unauthenticated requests (during the rollout period) are limited per IP address.
+
+As well as this limit, the `ask_redpanda_question` tool proxies to an MCP server hosted by Kapa.ai, which enforces its own limits. See the link:https://docs.kapa.ai/integrations/mcp/overview#authentication[Kapa documentation^] for details.
These limits are suitable for:
@@ -284,13 +323,25 @@ If you exceed the limit, you receive an HTTP 429 response with rate limit header
[source,text]
----
HTTP 429 Too Many Requests
-RateLimit-Limit: 40
+RateLimit-Limit: 60
RateLimit-Remaining: 0
RateLimit-Reset:
----
==== Troubleshooting
+===== Authentication required (HTTP 401)
+
+If you receive an `HTTP 401` response with an `authentication_required` error:
+
+* Your MCP client should open a browser to sign in with your Redpanda Cloud account. If it doesn't, check that your client supports the MCP OAuth flow (most recent versions do).
+* If you don't have a Redpanda Cloud account, link:https://cloud.redpanda.com/sign-up[create a free one^].
+* If sign-in succeeded previously but you now see `401`, your client's token may have expired. Reconnect or restart the client to trigger a fresh sign-in.
+
+===== Work account required (HTTP 403)
+
+Any verified Redpanda Cloud account works by default, including personal email providers. If your organization has opted to restrict access to work email domains and you receive an `HTTP 403` with a `work_email_required` error, sign in with your work account instead.
+
===== Server not connecting
* Verify the URL is exactly: `https://docs.redpanda.com/mcp`.
diff --git a/netlify/database/migrations/20260622110000_oauth_state.sql b/netlify/database/migrations/20260622110000_oauth_state.sql
new file mode 100644
index 00000000..d8c71c88
--- /dev/null
+++ b/netlify/database/migrations/20260622110000_oauth_state.sql
@@ -0,0 +1,52 @@
+-- OAuth transactional state for the docs MCP authorization server.
+-- Applied to the Neon (Netlify DB) database used when STORE_BACKEND=neon.
+-- Idempotent: safe to run repeatedly.
+--
+-- Scope: the one-time-use / transactional tables only. DCR-registered clients
+-- stay on Netlify Blobs (plain persistence, not one-time-use).
+
+-- In-flight authorization requests (consumed by DELETE … RETURNING).
+CREATE TABLE IF NOT EXISTS auth_requests (
+ id uuid PRIMARY KEY,
+ client_id text NOT NULL,
+ client_redirect_uri text NOT NULL,
+ client_state text,
+ client_code_challenge text,
+ upstream_verifier text,
+ expires_at timestamptz NOT NULL
+);
+CREATE INDEX IF NOT EXISTS idx_auth_requests_expires ON auth_requests (expires_at);
+
+-- Authorization codes (one-time; consumed by atomic UPDATE … WHERE used=false).
+CREATE TABLE IF NOT EXISTS auth_codes (
+ code text PRIMARY KEY,
+ client_id text NOT NULL,
+ client_redirect_uri text NOT NULL,
+ client_code_challenge text,
+ user_data jsonb NOT NULL,
+ used boolean NOT NULL DEFAULT false,
+ expires_at timestamptz NOT NULL
+);
+CREATE INDEX IF NOT EXISTS idx_auth_codes_expires ON auth_codes (expires_at);
+
+-- Refresh tokens, stored by hash (one-time; rotated via atomic UPDATE).
+CREATE TABLE IF NOT EXISTS refresh_tokens (
+ hash text PRIMARY KEY,
+ family_id uuid NOT NULL,
+ client_id text NOT NULL,
+ user_data jsonb NOT NULL,
+ scope text,
+ used boolean NOT NULL DEFAULT false,
+ expires_at timestamptz NOT NULL
+);
+CREATE INDEX IF NOT EXISTS idx_refresh_tokens_expires ON refresh_tokens (expires_at);
+CREATE INDEX IF NOT EXISTS idx_refresh_tokens_family ON refresh_tokens (family_id);
+
+-- Refresh-token families (rotation lineage; revoked on reuse detection).
+CREATE TABLE IF NOT EXISTS refresh_families (
+ id uuid PRIMARY KEY,
+ client_id text,
+ revoked boolean NOT NULL DEFAULT false,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ revoked_at timestamptz
+);
diff --git a/netlify/edge-functions/mcp-oauth-metadata.ts b/netlify/edge-functions/mcp-oauth-metadata.ts
new file mode 100644
index 00000000..750bb242
--- /dev/null
+++ b/netlify/edge-functions/mcp-oauth-metadata.ts
@@ -0,0 +1,42 @@
+// OAuth 2.0 Protected Resource Metadata (RFC 9728) for the MCP server.
+// MCP clients (ChatGPT, Claude, Cursor, …) fetch this to discover the
+// authorization server. That AS is OUR OWN service (which federates the human
+// login to the Redpanda Cloud IdP), so authorization_servers points back at
+// this origin, where /.well-known/oauth-authorization-server lives.
+// https://datatracker.ietf.org/doc/html/rfc9728
+
+const CORS = {
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Allow-Methods": "GET, OPTIONS",
+ "Access-Control-Allow-Headers": "Content-Type, Authorization",
+};
+
+export default async (request: Request) => {
+ // CORS preflight for browser-based MCP clients fetching the metadata.
+ if (request.method === "OPTIONS") {
+ return new Response(null, { status: 204, headers: { ...CORS, "Access-Control-Max-Age": "86400" } });
+ }
+
+ const origin = new URL(request.url).origin;
+
+ const metadata = {
+ resource: `${origin}/mcp`,
+ authorization_servers: [origin],
+ bearer_methods_supported: ["header"],
+ scopes_supported: ["openid", "email", "profile"],
+ resource_documentation: `${origin}/data-platform/how-to-use-these-docs#authentication`,
+ };
+
+ return new Response(JSON.stringify(metadata, null, 2), {
+ status: 200,
+ headers: {
+ "Content-Type": "application/json",
+ "Cache-Control": "public, max-age=3600",
+ ...CORS,
+ },
+ });
+};
+
+export const config = {
+ path: "/.well-known/oauth-protected-resource",
+};
diff --git a/netlify/edge-functions/mcp-server-card.ts b/netlify/edge-functions/mcp-server-card.ts
index c191fbca..50d5cb51 100644
--- a/netlify/edge-functions/mcp-server-card.ts
+++ b/netlify/edge-functions/mcp-server-card.ts
@@ -7,8 +7,8 @@ export default async (request: Request) => {
$schema: "https://modelcontextprotocol.io/schemas/server-card.json",
serverInfo: {
name: "redpanda-doc-tools-assistant",
- version: "1.0.0",
- description: "MCP server for searching Redpanda documentation and querying API references"
+ version: "1.3.0",
+ description: "MCP server for searching Redpanda documentation, querying API references, and sending feedback to the Redpanda team"
},
transport: {
type: "http",
@@ -19,9 +19,17 @@ export default async (request: Request) => {
tools: true,
prompts: false
},
+ authentication: {
+ type: "oauth2",
+ required: false,
+ protected_resource_metadata: `${siteUrl}/.well-known/oauth-protected-resource`,
+ authorization_servers: [siteUrl],
+ description: "Sign in with your Redpanda Cloud account. MCP clients discover the OAuth flow via the protected-resource metadata and obtain a token automatically (this site is the authorization server; it federates login to Redpanda Cloud)."
+ },
metadata: {
homepage: `${siteUrl}`,
documentation: `${siteUrl}/current/home/`,
+ authDocumentation: `${siteUrl}/data-platform/how-to-use-these-docs#authentication`,
repository: "https://github.com/redpanda-data/docs-site",
support: "https://support.redpanda.com",
tags: ["documentation", "redpanda", "kafka", "streaming", "agentic data plane"]
diff --git a/netlify/functions/lib/auth.mjs b/netlify/functions/lib/auth.mjs
new file mode 100644
index 00000000..b61063ad
--- /dev/null
+++ b/netlify/functions/lib/auth.mjs
@@ -0,0 +1,150 @@
+// OAuth resource-server core for the Redpanda Docs MCP server.
+// ------------------------------------------------------------
+// Pure, dependency-light helpers (no network, no Blobs) so they can be
+// unit-tested without the Netlify runtime. Token validation against the
+// Redpanda Cloud IdP lives in idp.mjs; user capture lives in store.mjs.
+//
+// The MCP server acts as an OAuth 2.1 Resource Server (per the MCP authorization
+// spec). MCP clients (ChatGPT, Claude, Cursor, …) discover the authorization
+// server via /.well-known/oauth-protected-resource (RFC 9728), sign in with a
+// Redpanda Cloud account, and send `Authorization: Bearer `. We validate
+// the token and capture the user's verified work email.
+
+import { createHash } from 'node:crypto'
+
+// -------------------- Email classification --------------------
+
+// Free consumer providers — rejected when a work email is required.
+const FREE_EMAIL_DOMAINS = new Set([
+ 'gmail.com', 'googlemail.com', 'yahoo.com', 'yahoo.co.uk', 'yahoo.co.in',
+ 'ymail.com', 'outlook.com', 'hotmail.com', 'hotmail.co.uk', 'live.com',
+ 'msn.com', 'icloud.com', 'me.com', 'mac.com', 'proton.me', 'protonmail.com',
+ 'pm.me', 'aol.com', 'gmx.com', 'gmx.net', 'yandex.com', 'yandex.ru',
+ 'mail.com', 'zoho.com', 'qq.com', '163.com', '126.com',
+])
+
+// Disposable / throwaway providers. Best-effort seed list, not exhaustive.
+const DISPOSABLE_DOMAINS = new Set([
+ 'mailinator.com', '10minutemail.com', 'guerrillamail.com', 'guerrillamail.info',
+ 'sharklasers.com', 'getnada.com', 'nada.email', 'yopmail.com', 'trashmail.com',
+ 'tempmail.com', 'temp-mail.org', 'throwawaymail.com', 'maildrop.cc',
+ 'dispostable.com', 'fakeinbox.com', 'mintemail.com', 'mohmal.com',
+])
+
+export function emailDomain(email) {
+ const e = String(email || '').trim().toLowerCase()
+ const at = e.lastIndexOf('@')
+ return at === -1 ? '' : e.slice(at + 1)
+}
+
+// Returns { ok, reason? }. Rejects free + disposable providers.
+export function isWorkEmail(domain) {
+ const d = String(domain || '').trim().toLowerCase()
+ if (!d) return { ok: false, reason: 'invalid_format' }
+ if (FREE_EMAIL_DOMAINS.has(d)) return { ok: false, reason: 'free_provider' }
+ if (DISPOSABLE_DOMAINS.has(d)) return { ok: false, reason: 'disposable' }
+ return { ok: true }
+}
+
+// -------------------- Request parsing --------------------
+
+// OAuth bearer tokens travel in the Authorization header only. (The MCP/OAuth
+// spec forbids tokens in the query string, and ChatGPT only sends them as a
+// header, so there is no ?token= fallback here.)
+export function extractBearerToken(authHeader) {
+ const m = String(authHeader || '').match(/^\s*Bearer\s+(.+?)\s*$/i)
+ return m ? m[1] : null
+}
+
+// Used as a cache key / opaque reference. Tokens are never logged in the clear.
+export function hashToken(token) {
+ return createHash('sha256').update(String(token)).digest('hex')
+}
+
+// -------------------- Config flags --------------------
+
+// Auth is enforced only when REQUIRE_AUTH === 'true' (default = grace period).
+export function isAuthEnforced() {
+ return process.env.REQUIRE_AUTH === 'true'
+}
+
+// Require a work email (reject free/disposable). Default false — with Cloud
+// login the email is already verified, so we accept any verified Cloud account.
+// Set REQUIRE_WORK_EMAIL=true to reject free/disposable providers.
+export function isWorkEmailRequired() {
+ return process.env.REQUIRE_WORK_EMAIL === 'true'
+}
+
+// -------------------- Responses --------------------
+
+const DOCS_URL = 'https://docs.redpanda.com/data-platform/how-to-use-these-docs#authentication'
+
+// RFC 6750 / RFC 9728 compliant 401. The `resource_metadata` parameter points
+// MCP clients at our protected-resource-metadata document so they can discover
+// the Redpanda Cloud authorization server and start the OAuth flow.
+export function buildUnauthorizedResponse({ resourceMetadataUrl, error = 'invalid_token', description } = {}) {
+ const meta = resourceMetadataUrl || 'https://docs.redpanda.com/.well-known/oauth-protected-resource'
+ const desc = description || 'Sign in with your Redpanda Cloud account to use the Redpanda Docs MCP server.'
+ return {
+ status: 401,
+ headers: {
+ 'WWW-Authenticate': `Bearer realm="redpanda-docs-mcp", error="${error}", error_description="${desc}", resource_metadata="${meta}"`,
+ 'Content-Type': 'application/json',
+ 'Cache-Control': 'no-store',
+ },
+ body: {
+ error: 'authentication_required',
+ message: desc,
+ resource_metadata: meta,
+ docs_url: DOCS_URL,
+ },
+ }
+}
+
+function buildForbiddenWorkEmail(reason) {
+ const message =
+ reason === 'disposable'
+ ? 'Disposable email addresses are not accepted. Sign in with your work account.'
+ : 'Please sign in with your work account. Personal email providers (Gmail, Outlook, etc.) are not accepted.'
+ return {
+ status: 403,
+ headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' },
+ body: { error: 'work_email_required', reason, message, docs_url: DOCS_URL },
+ }
+}
+
+// -------------------- Decision logic (pure, unit-tested) --------------------
+//
+// `claims` is the validated token's userinfo (or null if no/invalid token).
+// Returns { allow, userContext, response } where `response` is a framework-
+// agnostic { status, headers, body } to return on rejection.
+export function decideAuth({ claims, enforced, workEmailRequired, resourceMetadataUrl }) {
+ if (!claims) {
+ if (enforced) {
+ return { allow: false, userContext: null, response: buildUnauthorizedResponse({ resourceMetadataUrl }) }
+ }
+ // Grace period: allow through with no attribution.
+ return { allow: true, userContext: null, response: null }
+ }
+
+ const email = String(claims.email || '').trim().toLowerCase()
+ const domain = emailDomain(email)
+
+ if (workEmailRequired && email) {
+ const work = isWorkEmail(domain)
+ if (!work.ok) {
+ return { allow: false, userContext: null, response: buildForbiddenWorkEmail(work.reason) }
+ }
+ }
+
+ return {
+ allow: true,
+ userContext: {
+ sub: claims.sub || null,
+ email: email || null,
+ domain: domain || null,
+ emailVerified: claims.email_verified === true,
+ },
+ response: null,
+ }
+}
diff --git a/netlify/functions/lib/oauth/clients.mjs b/netlify/functions/lib/oauth/clients.mjs
new file mode 100644
index 00000000..411840dd
--- /dev/null
+++ b/netlify/functions/lib/oauth/clients.mjs
@@ -0,0 +1,178 @@
+// OAuth client identity for the AS: Dynamic Client Registration (RFC 7591) +
+// Client ID Metadata Documents (CIMD). MCP clients (ChatGPT, Claude, …) are
+// third-party apps that identify themselves at runtime — either by registering
+// (DCR) and getting a client_id, or by presenting a URL client_id whose
+// metadata document we fetch (CIMD).
+
+import { randomBytes } from 'node:crypto'
+import { putClient, getStoredClient } from './store.mjs'
+
+const CIMD_FETCH_TIMEOUT_MS = 5_000
+const CIMD_MAX_BYTES = 32_000
+
+// Short in-process cache of resolved CIMD clients (and negatives), so /authorize
+// doesn't re-fetch the same client_id URL on every call. Bounded to cap memory
+// against a flood of distinct URLs; /authorize also rate-limits CIMD resolution.
+const CIMD_CACHE_TTL_MS = 5 * 60 * 1000
+const CIMD_NEG_TTL_MS = 30_000
+const CIMD_CACHE_MAX = 500
+const cimdCache = new Map() // clientId -> { client, exp }
+
+export function isCimdClientId(clientId) {
+ return typeof clientId === 'string' && clientId.startsWith('https://')
+}
+
+// --- redirect_uri matching (OAuth 2.1: exact match, with loopback flexibility) ---
+// Native clients (e.g. Claude Code) use http://127.0.0.1:/cb or
+// http://localhost:/cb, so for loopback we match everything except the port.
+export function redirectUriAllowed(client, redirectUri) {
+ const allowed = client?.redirect_uris || []
+ if (allowed.includes(redirectUri)) return true
+ let u
+ try { u = new URL(redirectUri) } catch { return false }
+ const isLoopback = u.protocol === 'http:' && (u.hostname === '127.0.0.1' || u.hostname === 'localhost' || u.hostname === '::1')
+ if (!isLoopback) return false
+ return allowed.some((a) => {
+ let r
+ try { r = new URL(a) } catch { return false }
+ return r.protocol === u.protocol && r.hostname === u.hostname && r.pathname === u.pathname // port may differ
+ })
+}
+
+// --- validation of submitted/ fetched client metadata ---
+function normalizeClientMetadata(meta) {
+ const redirect_uris = Array.isArray(meta?.redirect_uris) ? meta.redirect_uris : []
+ if (redirect_uris.length === 0) {
+ const err = new Error('redirect_uris is required')
+ err.code = 'invalid_redirect_uri'
+ throw err
+ }
+ for (const uri of redirect_uris) {
+ try { new URL(uri) } catch {
+ const err = new Error(`invalid redirect_uri: ${uri}`)
+ err.code = 'invalid_redirect_uri'
+ throw err
+ }
+ }
+ return {
+ redirect_uris,
+ client_name: typeof meta.client_name === 'string' ? meta.client_name.slice(0, 200) : undefined,
+ token_endpoint_auth_method: 'none', // public clients only
+ grant_types: ['authorization_code'],
+ response_types: ['code'],
+ scope: 'openid email profile',
+ }
+}
+
+// --- DCR (RFC 7591) ---
+export async function registerClient(meta) {
+ const normalized = normalizeClientMetadata(meta)
+ const client_id = `mcp_${randomBytes(24).toString('base64url')}`
+ const record = { client_id, ...normalized, client_id_issued_at: Math.floor(Date.now() / 1000) }
+ await putClient(record)
+ return record // RFC 7591 registration response (public client → no secret)
+}
+
+// Block loopback / private / link-local hosts (IPv4 and IPv6). Best-effort SSRF
+// guard — residual: a DNS name that resolves to a private IP (DNS rebinding); a
+// hardened deploy would also resolve + range-check the IP.
+export function isBlockedHost(rawHost) {
+ // Lowercase and strip IPv6 brackets (URL.hostname returns e.g. "[::1]").
+ let host = String(rawHost || '').toLowerCase().replace(/^\[/, '').replace(/\]$/, '')
+ if (!host) return true
+ if (host === 'localhost' || host.endsWith('.local')) return true
+
+ if (host.includes(':')) {
+ // IPv6 literal
+ if (host === '::1' || host === '::') return true // loopback / unspecified
+ if (host.startsWith('::ffff:')) return true // IPv4-mapped IPv6
+ if (/^f[cd]/.test(host)) return true // fc00::/7 unique-local
+ if (/^fe[89ab]/.test(host)) return true // fe80::/10 link-local
+ return false
+ }
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) {
+ // IPv4 literal
+ return (
+ /^127\./.test(host) || /^10\./.test(host) || /^192\.168\./.test(host) ||
+ /^169\.254\./.test(host) || /^172\.(1[6-9]|2\d|3[01])\./.test(host) ||
+ host === '0.0.0.0'
+ )
+ }
+ return false // DNS name (rebinding residual, see note above)
+}
+
+// --- CIMD: fetch + validate a URL client_id's metadata document ---
+function assertSafeCimdUrl(clientId) {
+ let u
+ try { u = new URL(clientId) } catch { throw new Error('client_id is not a valid URL') }
+ if (u.protocol !== 'https:') throw new Error('CIMD client_id must be https')
+ if (isBlockedHost(u.hostname)) throw new Error('CIMD client_id host not allowed')
+ return u
+}
+
+export function validateCimdDocument(clientId, doc) {
+ if (!doc || typeof doc !== 'object') throw new Error('CIMD metadata not an object')
+ if (doc.client_id !== clientId) throw new Error('CIMD client_id must equal the document URL')
+ return normalizeClientMetadata(doc)
+}
+
+// Read a response body with a hard byte cap, streaming so we never buffer a
+// huge/slow body. Falls back to text() for mocked responses without a stream.
+async function readCapped(res, maxBytes) {
+ const declared = Number(res.headers?.get?.('content-length') || 0)
+ if (declared > maxBytes) throw new Error('CIMD metadata too large')
+ if (res.body && typeof res.body.getReader === 'function') {
+ const reader = res.body.getReader()
+ const chunks = []
+ let total = 0
+ for (;;) {
+ const { done, value } = await reader.read()
+ if (done) break
+ total += value.byteLength
+ if (total > maxBytes) {
+ try { await reader.cancel() } catch { /* ignore */ }
+ throw new Error('CIMD metadata too large')
+ }
+ chunks.push(Buffer.from(value))
+ }
+ return Buffer.concat(chunks).toString('utf8')
+ }
+ const text = await res.text()
+ if (Buffer.byteLength(text, 'utf8') > maxBytes) throw new Error('CIMD metadata too large')
+ return text
+}
+
+async function fetchCimdClient(clientId, fetchImpl = fetch) {
+ assertSafeCimdUrl(clientId)
+ const controller = new AbortController()
+ const t = setTimeout(() => controller.abort(), CIMD_FETCH_TIMEOUT_MS)
+ try {
+ const res = await fetchImpl(clientId, {
+ headers: { Accept: 'application/json' },
+ signal: controller.signal,
+ // SSRF: do NOT follow redirects — the initial host passed assertSafeCimdUrl,
+ // but a redirect Location is unvalidated and could point at an internal host.
+ redirect: 'error',
+ })
+ if (!res.ok) throw new Error(`CIMD fetch failed: ${res.status}`)
+ const doc = JSON.parse(await readCapped(res, CIMD_MAX_BYTES))
+ return { client_id: clientId, ...validateCimdDocument(clientId, doc) }
+ } finally {
+ clearTimeout(t)
+ }
+}
+
+// Resolve a client_id to a client record (DCR-stored or CIMD-fetched), or null.
+export async function getClient(clientId, { fetchImpl } = {}) {
+ if (!clientId) return null
+ if (isCimdClientId(clientId)) {
+ const hit = cimdCache.get(clientId)
+ if (hit && hit.exp > Date.now()) return hit.client
+ let client = null
+ try { client = await fetchCimdClient(clientId, fetchImpl) } catch { client = null }
+ if (cimdCache.size > CIMD_CACHE_MAX) cimdCache.clear()
+ cimdCache.set(clientId, { client, exp: Date.now() + (client ? CIMD_CACHE_TTL_MS : CIMD_NEG_TTL_MS) })
+ return client
+ }
+ return getStoredClient(clientId)
+}
diff --git a/netlify/functions/lib/oauth/config.mjs b/netlify/functions/lib/oauth/config.mjs
new file mode 100644
index 00000000..f390da6c
--- /dev/null
+++ b/netlify/functions/lib/oauth/config.mjs
@@ -0,0 +1,71 @@
+// Central config for the docs MCP OAuth 2.1 Authorization Server.
+// The issuer is derived per-request from the origin (works across prod +
+// deploy previews); everything else is env-tunable.
+
+export const PATHS = {
+ metadata: '/.well-known/oauth-authorization-server',
+ jwks: '/.well-known/jwks.json',
+ authorize: '/oauth/authorize',
+ token: '/oauth/token',
+ register: '/oauth/register', // RFC 7591 Dynamic Client Registration
+ callback: '/mcp/callback', // matches the redirect URI registered in Auth0
+ mockIdp: '/oauth/mock-idp/authorize', // dev only
+}
+
+export const SCOPES = ['openid', 'email', 'profile']
+export const ACCESS_TOKEN_TTL_SEC = Number(process.env.MCP_OAUTH_ACCESS_TTL || 3600)
+export const REFRESH_TOKEN_TTL_SEC = Number(process.env.MCP_OAUTH_REFRESH_TTL || 30 * 24 * 3600) // 30d
+export const AUTH_REQUEST_TTL_SEC = 600
+export const AUTH_CODE_TTL_SEC = 60
+
+// Upstream IdP (Redpanda Cloud Auth0) mode resolution — FAIL-CLOSED.
+//
+// The dev mock issues canned identities, so it must NEVER be reachable in a
+// real deployment by accident. Mock is only allowed under an explicit dev
+// signal (NETLIFY_DEV, or MCP_OAUTH_ALLOW_MOCK=true). Anything that would
+// otherwise fall back to mock (e.g. a prod deploy missing the client_id)
+// resolves to `null` = misconfigured, and the AS refuses the flow rather than
+// handing out mock tokens.
+export function resolveUpstreamMode(env = process.env) {
+ const allowMock = env.NETLIFY_DEV === 'true' || env.MCP_OAUTH_ALLOW_MOCK === 'true'
+ const hasClientId = !!env.REDPANDA_OAUTH_CLIENT_ID
+ const explicit = env.MCP_OAUTH_UPSTREAM
+ if (explicit === 'auth0') return hasClientId ? 'auth0' : null // explicit auth0 needs a client_id
+ if (explicit === 'mock') return allowMock ? 'mock' : null // mock only when explicitly allowed
+ if (hasClientId) return 'auth0'
+ if (allowMock) return 'mock'
+ return null // unconfigured (e.g. prod without client_id) → fail closed
+}
+
+export const UPSTREAM_MODE = resolveUpstreamMode()
+export const UPSTREAM_MISCONFIGURED = UPSTREAM_MODE === null
+export const AUTH0_ISSUER = process.env.REDPANDA_OAUTH_ISSUER || 'https://auth.prd.cloud.redpanda.com/'
+export const AUTH0_CLIENT_ID = process.env.REDPANDA_OAUTH_CLIENT_ID // public client, no secret
+// Default OFF: with Cloud login the email is already verified by Auth0, so we
+// don't block personal-domain Cloud accounts — we capture every verified login
+// (the domain is still recorded for attribution). Set REQUIRE_WORK_EMAIL=true
+// to reject free/disposable providers with a 403.
+export const REQUIRE_WORK_EMAIL = process.env.REQUIRE_WORK_EMAIL === 'true'
+
+// Login interstitial (shown at /authorize before redirecting to the IdP). It
+// carries the "Sign up at cloud.redpanda.com" link for users without an account.
+// Set MCP_OAUTH_INTERSTITIAL=off to redirect straight to the IdP (e.g. once the
+// signup link lives on the Auth0 login page instead).
+export const SIGNUP_URL = process.env.MCP_OAUTH_SIGNUP_URL || 'https://cloud.redpanda.com/sign-up'
+export const PRIVACY_URL = process.env.MCP_OAUTH_PRIVACY_URL || 'https://www.redpanda.com/legal/privacy-policy'
+export const LOGIN_INTERSTITIAL = process.env.MCP_OAUTH_INTERSTITIAL !== 'off'
+
+// AS issuer = the public origin of the request (e.g. https://docs.redpanda.com).
+export function issuerFor(origin) {
+ return origin
+}
+export function endpoints(origin) {
+ return {
+ issuer: origin,
+ authorization_endpoint: `${origin}${PATHS.authorize}`,
+ token_endpoint: `${origin}${PATHS.token}`,
+ registration_endpoint: `${origin}${PATHS.register}`,
+ jwks_uri: `${origin}${PATHS.jwks}`,
+ callback_uri: `${origin}${PATHS.callback}`,
+ }
+}
diff --git a/netlify/functions/lib/oauth/db/blobs.mjs b/netlify/functions/lib/oauth/db/blobs.mjs
new file mode 100644
index 00000000..a7312cbd
--- /dev/null
+++ b/netlify/functions/lib/oauth/db/blobs.mjs
@@ -0,0 +1,108 @@
+// OAuth state storage — Netlify Blobs backend.
+//
+// Extracted from store.mjs so the backend can be selected at runtime (see
+// store.mjs). This is the default backend. Keyed lookups by id/hash fit Blobs.
+//
+// KNOWN LIMITATION (the reason the Neon backend exists): one-time-use here is
+// read-then-delete / read-then-mark, and Blobs has no compare-and-swap, so two
+// *simultaneous* requests with the same auth code (or refresh token) can both
+// observe it as unused before either consumes it. For refresh tokens a
+// concurrent legit+stolen use inside that window would both rotate without
+// tripping family revocation. The Neon backend fixes this with a single atomic
+// `UPDATE … WHERE used = false RETURNING`.
+
+import { getStore } from '@netlify/blobs'
+import { randomUUID, randomBytes } from 'node:crypto'
+import { AUTH_REQUEST_TTL_SEC, AUTH_CODE_TTL_SEC } from '../config.mjs'
+
+const STORE = 'mcp-oauth'
+const AR = (id) => `ar:${id}` // auth request
+const AC = (code) => `ac:${code}` // authorization code
+const CL = (id) => `client:${id}` // DCR-registered client
+const RT = (h) => `rt:${h}` // refresh token (by hash)
+const RTF = (id) => `rtf:${id}` // refresh-token family
+
+function store() {
+ // STRONG consistency: auth codes and refresh tokens are one-time use, and
+ // Blobs' default eventual consistency propagates deletes/updates over up to
+ // 60s — long enough for a consumed code/token to be replayed in that window.
+ return getStore({ name: STORE, consistency: 'strong' })
+}
+const expired = (rec) => !rec || Date.now() > rec.expiresAt
+
+export async function putAuthRequest(data) {
+ const id = randomUUID()
+ await store().setJSON(AR(id), { ...data, expiresAt: Date.now() + AUTH_REQUEST_TTL_SEC * 1000 })
+ return id
+}
+
+export async function takeAuthRequest(id) {
+ if (!id) return null
+ const key = AR(id)
+ const rec = await store().get(key, { type: 'json' }).catch(() => null)
+ await store().delete(key).catch(() => {}) // one-time
+ return expired(rec) ? null : rec
+}
+
+export async function putAuthCode(data) {
+ const code = randomBytes(32).toString('base64url')
+ await store().setJSON(AC(code), { ...data, used: false, expiresAt: Date.now() + AUTH_CODE_TTL_SEC * 1000 })
+ return code
+}
+
+export async function takeAuthCode(code) {
+ if (!code) return null
+ const key = AC(code)
+ const rec = await store().get(key, { type: 'json' }).catch(() => null)
+ if (expired(rec) || rec.used) return null
+ await store().delete(key).catch(() => {}) // one-time use
+ return rec
+}
+
+// --- DCR-registered clients (persistent; no TTL) ---
+export async function putClient(client) {
+ await store().setJSON(CL(client.client_id), client)
+ return client
+}
+
+export async function getStoredClient(clientId) {
+ if (!clientId) return null
+ // Resilient: a store error (e.g. Blobs unavailable) resolves to "unknown
+ // client" rather than crashing the /authorize handler.
+ try {
+ return await store().get(CL(clientId), { type: 'json' })
+ } catch {
+ return null
+ }
+}
+
+// --- refresh tokens (by hash) + families ---
+export async function putRefresh(hash, rec) {
+ await store().setJSON(RT(hash), rec)
+}
+export async function getRefresh(hash) {
+ if (!hash) return null
+ return store().get(RT(hash), { type: 'json' }).catch(() => null)
+}
+
+// Best-effort "consume": mark the token used and return it, or null if it was
+// already used / missing. NOT atomic (no CAS on Blobs) — a concurrent caller
+// can also observe it unused. The Neon backend makes this atomic.
+export async function consumeRefresh(hash) {
+ const rec = await getRefresh(hash)
+ if (!rec || rec.used) return null
+ await store().setJSON(RT(hash), { ...rec, used: true })
+ return rec
+}
+
+export async function putFamily(id, rec) {
+ await store().setJSON(RTF(id), rec)
+}
+export async function getFamily(id) {
+ if (!id) return null
+ return store().get(RTF(id), { type: 'json' }).catch(() => null)
+}
+export async function revokeFamily(id) {
+ const fam = (await getFamily(id)) || {}
+ await store().setJSON(RTF(id), { ...fam, revoked: true, revokedAt: Date.now() })
+}
diff --git a/netlify/functions/lib/oauth/db/neon.mjs b/netlify/functions/lib/oauth/db/neon.mjs
new file mode 100644
index 00000000..6592c839
--- /dev/null
+++ b/netlify/functions/lib/oauth/db/neon.mjs
@@ -0,0 +1,183 @@
+// OAuth state storage — Neon Postgres (Netlify DB) backend.
+//
+// Selected when STORE_BACKEND=neon (see store.mjs). Exists to make one-time-use
+// of auth codes and refresh tokens ATOMIC: each consume is a single
+// `UPDATE … WHERE used = false RETURNING *` (or `DELETE … RETURNING *`), so two
+// concurrent requests with the same code/token cannot both succeed — exactly
+// one wins the row, the loser sees zero rows. This closes the replay/reuse race
+// that the Blobs backend documents.
+//
+// Scope: the four one-time-use / transactional tables only. DCR clients stay on
+// Blobs (they are plain persistence, not one-time-use — no atomicity benefit).
+//
+// Uses @netlify/database's zero-config client (getDatabase().httpClient), which
+// reads NETLIFY_DATABASE_URL automatically and is the HTTP Neon query function
+// (tagged-template SQL). Imported lazily so this module can be loaded without
+// the dependency or a database URL present (e.g. STORE_BACKEND=blobs in tests).
+
+import { randomUUID, randomBytes } from 'node:crypto'
+import { AUTH_REQUEST_TTL_SEC, AUTH_CODE_TTL_SEC } from '../config.mjs'
+
+let _sql = null
+async function db() {
+ if (_sql) return _sql
+ const { getDatabase } = await import('@netlify/database')
+ // Throws MissingDatabaseConnectionError if no URL is configured (fail-closed).
+ _sql = getDatabase().httpClient
+ return _sql
+}
+
+const toMs = (ts) => (ts ? new Date(ts).getTime() : 0)
+
+// --- auth requests (one-time; consumed by delete) ---
+export async function putAuthRequest(data) {
+ const sql = await db()
+ const id = randomUUID()
+ const expiresMs = Date.now() + AUTH_REQUEST_TTL_SEC * 1000
+ await sql`
+ INSERT INTO auth_requests (id, client_id, client_redirect_uri, client_state, client_code_challenge, upstream_verifier, expires_at)
+ VALUES (${id}, ${data.clientId}, ${data.clientRedirectUri}, ${data.clientState ?? null}, ${data.clientCodeChallenge ?? null}, ${data.upstreamVerifier ?? null}, to_timestamp(${expiresMs} / 1000.0))
+ `
+ return id
+}
+
+export async function takeAuthRequest(id) {
+ if (!id) return null
+ const sql = await db()
+ const rows = await sql`DELETE FROM auth_requests WHERE id = ${id} RETURNING *`
+ const row = rows[0]
+ if (!row || toMs(row.expires_at) < Date.now()) return null
+ return {
+ clientId: row.client_id,
+ clientRedirectUri: row.client_redirect_uri,
+ clientState: row.client_state,
+ clientCodeChallenge: row.client_code_challenge,
+ upstreamVerifier: row.upstream_verifier,
+ expiresAt: toMs(row.expires_at),
+ }
+}
+
+// --- authorization codes (one-time; atomic check+consume) ---
+export async function putAuthCode(data) {
+ const sql = await db()
+ const code = randomBytes(32).toString('base64url')
+ const expiresMs = Date.now() + AUTH_CODE_TTL_SEC * 1000
+ await sql`
+ INSERT INTO auth_codes (code, client_id, client_redirect_uri, client_code_challenge, user_data, used, expires_at)
+ VALUES (${code}, ${data.clientId}, ${data.clientRedirectUri}, ${data.clientCodeChallenge ?? null}, ${JSON.stringify(data.user)}::jsonb, false, to_timestamp(${expiresMs} / 1000.0))
+ `
+ return code
+}
+
+export async function takeAuthCode(code) {
+ if (!code) return null
+ const sql = await db()
+ // Atomic: only the first caller flips used=false→true and gets the row.
+ const rows = await sql`
+ UPDATE auth_codes SET used = true
+ WHERE code = ${code} AND used = false AND expires_at > now()
+ RETURNING *
+ `
+ const row = rows[0]
+ if (!row) return null
+ return {
+ clientId: row.client_id,
+ clientRedirectUri: row.client_redirect_uri,
+ clientCodeChallenge: row.client_code_challenge,
+ user: row.user_data,
+ expiresAt: toMs(row.expires_at),
+ }
+}
+
+// --- refresh tokens (by hash) ---
+export async function putRefresh(hash, rec) {
+ const sql = await db()
+ await sql`
+ INSERT INTO refresh_tokens (hash, family_id, client_id, user_data, scope, used, expires_at)
+ VALUES (${hash}, ${rec.familyId}, ${rec.clientId}, ${JSON.stringify(rec.user)}::jsonb, ${rec.scope ?? null}, ${rec.used ?? false}, to_timestamp(${rec.expiresAt} / 1000.0))
+ `
+}
+
+export async function getRefresh(hash) {
+ if (!hash) return null
+ const sql = await db()
+ const rows = await sql`SELECT * FROM refresh_tokens WHERE hash = ${hash}`
+ const row = rows[0]
+ if (!row) return null
+ return {
+ familyId: row.family_id,
+ clientId: row.client_id,
+ user: row.user_data,
+ scope: row.scope,
+ used: row.used,
+ expiresAt: toMs(row.expires_at),
+ }
+}
+
+// Atomic consume: exactly one concurrent caller flips used=false→true and gets
+// the row back. A loser (already used / missing) gets null → caller trips reuse.
+export async function consumeRefresh(hash) {
+ if (!hash) return null
+ const sql = await db()
+ const rows = await sql`
+ UPDATE refresh_tokens SET used = true
+ WHERE hash = ${hash} AND used = false
+ RETURNING *
+ `
+ const row = rows[0]
+ if (!row) return null
+ return {
+ familyId: row.family_id,
+ clientId: row.client_id,
+ user: row.user_data,
+ scope: row.scope,
+ used: true,
+ expiresAt: toMs(row.expires_at),
+ }
+}
+
+// --- refresh-token families ---
+export async function putFamily(id, rec) {
+ const sql = await db()
+ await sql`
+ INSERT INTO refresh_families (id, client_id, revoked, created_at)
+ VALUES (${id}, ${rec.clientId ?? null}, ${rec.revoked ?? false}, to_timestamp(${rec.createdAt ?? Date.now()} / 1000.0))
+ ON CONFLICT (id) DO NOTHING
+ `
+}
+
+export async function getFamily(id) {
+ if (!id) return null
+ const sql = await db()
+ const rows = await sql`SELECT * FROM refresh_families WHERE id = ${id}`
+ const row = rows[0]
+ if (!row) return null
+ return {
+ clientId: row.client_id,
+ revoked: row.revoked,
+ createdAt: toMs(row.created_at),
+ revokedAt: row.revoked_at ? toMs(row.revoked_at) : null,
+ }
+}
+
+export async function revokeFamily(id) {
+ const sql = await db()
+ await sql`UPDATE refresh_families SET revoked = true, revoked_at = now() WHERE id = ${id}`
+}
+
+// TTL cleanup (run on a schedule). Deletes expired one-time-use rows. Only
+// past-expiry refresh tokens are removed, so reuse detection still works for
+// every token within its lifetime; families with no remaining tokens are then
+// swept. Reads already filter on expires_at, so this is purely housekeeping.
+export async function cleanupExpired() {
+ const sql = await db()
+ const reqs = await sql`DELETE FROM auth_requests WHERE expires_at < now() RETURNING id`
+ const codes = await sql`DELETE FROM auth_codes WHERE expires_at < now() RETURNING code`
+ const toks = await sql`DELETE FROM refresh_tokens WHERE expires_at < now() RETURNING hash`
+ const fams = await sql`
+ DELETE FROM refresh_families f
+ WHERE NOT EXISTS (SELECT 1 FROM refresh_tokens t WHERE t.family_id = f.id)
+ RETURNING id
+ `
+ return { authRequests: reqs.length, authCodes: codes.length, refreshTokens: toks.length, families: fams.length }
+}
diff --git a/netlify/functions/lib/oauth/keys.mjs b/netlify/functions/lib/oauth/keys.mjs
new file mode 100644
index 00000000..a68c9ee1
--- /dev/null
+++ b/netlify/functions/lib/oauth/keys.mjs
@@ -0,0 +1,85 @@
+// Signing-key management + JWT issue/verify for our AS, using `jose`.
+//
+// Key source:
+// - Production: load the RS256 keypair from env `MCP_OAUTH_SIGNING_JWK`
+// (a JSON object {privateJwk, publicJwk, kid}).
+// - Dev/no-env: generate once and persist to Netlify Blobs, so the key is
+// stable across invocations (the spike proved an in-memory key breaks the
+// flow — tokens signed at /token wouldn't verify at /mcp).
+//
+// PRODUCTION TODO: key rotation (publish multiple JWKS entries; sign with newest).
+
+import { SignJWT, jwtVerify, importJWK, exportJWK, generateKeyPair, calculateJwkThumbprint } from 'jose'
+import { getStore } from '@netlify/blobs'
+
+const ALG = 'RS256'
+const KEY_STORE = 'mcp-oauth-keys'
+const KEY_NAME = 'active'
+
+let cache = null // { privateKey, publicJwk, kid }
+
+async function materialize({ privateJwk, publicJwk, kid }) {
+ const privateKey = await importJWK(privateJwk, ALG)
+ return { privateKey, publicJwk: { ...publicJwk, kid, alg: ALG, use: 'sig' }, kid }
+}
+
+async function generate() {
+ const { publicKey, privateKey } = await generateKeyPair(ALG, { extractable: true })
+ const publicJwk = await exportJWK(publicKey)
+ const privateJwk = await exportJWK(privateKey)
+ const kid = await calculateJwkThumbprint(publicJwk)
+ return { privateJwk, publicJwk, kid }
+}
+
+async function loadKeys() {
+ if (cache) return cache
+
+ const fromEnv = process.env.MCP_OAUTH_SIGNING_JWK
+ if (fromEnv) {
+ cache = await materialize(JSON.parse(fromEnv))
+ return cache
+ }
+
+ // Dev: persist a generated key in Blobs so it survives warm invocations.
+ // Strong consistency so a second function (e.g. the resource server reading
+ // the key the AS just wrote) sees it immediately rather than regenerating.
+ const store = getStore({ name: KEY_STORE, consistency: 'strong' })
+ let stored = await store.get(KEY_NAME, { type: 'json' }).catch(() => null)
+ if (!stored) {
+ stored = await generate()
+ await store.setJSON(KEY_NAME, stored).catch(() => {})
+ }
+ cache = await materialize(stored)
+ return cache
+}
+
+export async function getJwks() {
+ const { publicJwk } = await loadKeys()
+ return { keys: [publicJwk] }
+}
+
+export async function signAccessToken(claims, { issuer, audience, ttlSec }) {
+ const { privateKey, kid } = await loadKeys()
+ return new SignJWT(claims)
+ .setProtectedHeader({ alg: ALG, kid, typ: 'JWT' })
+ .setIssuedAt()
+ .setIssuer(issuer)
+ .setAudience(audience)
+ .setExpirationTime(`${ttlSec}s`)
+ .sign(privateKey)
+}
+
+export async function verifyAccessToken(token, { issuer, audience }) {
+ const { privateKey } = await loadKeys()
+ // For a single local key we can verify with the private key's public half;
+ // importing the public JWK keeps it explicit.
+ const { publicJwk } = await loadKeys()
+ const publicKey = await importJWK(publicJwk, ALG)
+ try {
+ // Pin RS256 (defense-in-depth against alg-confusion, on top of the RSA key).
+ const { payload } = await jwtVerify(token, publicKey, { issuer, audience, algorithms: [ALG] })
+ return { valid: true, claims: payload }
+ } catch (e) {
+ return { valid: false, error: e?.code || e?.message || 'invalid_token' }
+ }
+}
diff --git a/netlify/functions/lib/oauth/pages.mjs b/netlify/functions/lib/oauth/pages.mjs
new file mode 100644
index 00000000..233f5122
--- /dev/null
+++ b/netlify/functions/lib/oauth/pages.mjs
@@ -0,0 +1,74 @@
+// HTML for the login interstitial shown at /authorize before redirecting to the
+// upstream IdP. Pure (returns a string) so it's unit-testable.
+
+function escapeAttr(s) {
+ return String(s)
+ .replace(/&/g, '&')
+ .replace(/"/g, '"')
+ .replace(//g, '>')
+}
+
+// `continueUrl` is the upstream (Auth0) authorize URL; `signupUrl` points users
+// without a Redpanda Cloud account at the Cloud signup page; `privacyUrl` links
+// the privacy policy so users know what we collect before they sign in.
+export function loginInterstitialHtml({ continueUrl, signupUrl, privacyUrl }) {
+ const c = escapeAttr(continueUrl)
+ const s = escapeAttr(signupUrl)
+ const p = escapeAttr(privacyUrl)
+ return `
+
+
+
+
+Sign in — Redpanda Docs MCP
+
+
+
+
+
+
+
Connect to Redpanda Docs
+
Sign in with your Redpanda Cloud account to use the Redpanda documentation tools in your AI client.
When you sign in, we collect your verified work email to track documentation usage and attribute it to your organization, and we share it with service providers that help us run and analyze the service. See our Privacy Policy for details.
+
+
+`
+}
diff --git a/netlify/functions/lib/oauth/pkce.mjs b/netlify/functions/lib/oauth/pkce.mjs
new file mode 100644
index 00000000..cf1fee20
--- /dev/null
+++ b/netlify/functions/lib/oauth/pkce.mjs
@@ -0,0 +1,15 @@
+// PKCE helpers (RFC 7636), S256 only.
+import { createHash, randomBytes } from 'node:crypto'
+
+export const s256 = (verifier) => createHash('sha256').update(verifier).digest('base64url')
+
+export function verifyChallenge(verifier, challenge) {
+ if (!verifier || !challenge) return false
+ return s256(verifier) === challenge
+}
+
+// Generate a verifier/challenge pair for our own (upstream) leg of the flow.
+export function generatePair() {
+ const verifier = randomBytes(32).toString('base64url')
+ return { verifier, challenge: s256(verifier) }
+}
diff --git a/netlify/functions/lib/oauth/ratelimit.mjs b/netlify/functions/lib/oauth/ratelimit.mjs
new file mode 100644
index 00000000..c091b190
--- /dev/null
+++ b/netlify/functions/lib/oauth/ratelimit.mjs
@@ -0,0 +1,52 @@
+// Per-IP fixed-window rate limiter (for /oauth/register and CIMD resolution),
+// backed by Netlify Blobs.
+//
+// In-memory counters don't survive across serverless invocations (the same
+// finding that drove strong-consistency storage), so this is Blobs-backed with
+// strong consistency. Best-effort: there's no atomic CAS, so a burst can
+// slightly undercount — fine for blunting abuse.
+
+import { getStore } from '@netlify/blobs'
+
+const REGISTER_WINDOW_SEC = Number(process.env.MCP_OAUTH_REGISTER_WINDOW_SEC || 3600)
+const REGISTER_LIMIT = Number(process.env.MCP_OAUTH_REGISTER_LIMIT || 20)
+// CIMD resolution triggers an outbound fetch from /authorize, so cap it per IP.
+const CIMD_WINDOW_SEC = Number(process.env.MCP_OAUTH_CIMD_WINDOW_SEC || 600)
+const CIMD_LIMIT = Number(process.env.MCP_OAUTH_CIMD_LIMIT || 30)
+
+function store() {
+ return getStore({ name: 'mcp-oauth-rl', consistency: 'strong' })
+}
+
+export function clientIp(request) {
+ return (
+ request.headers.get('x-nf-client-connection-ip') ||
+ request.headers.get('cf-connecting-ip') ||
+ (request.headers.get('x-forwarded-for') || '').split(',')[0]?.trim() ||
+ request.headers.get('x-real-ip') ||
+ 'unknown'
+ )
+}
+
+// Generic per-IP fixed-window check. Returns { allowed, count, limit };
+// increments the current window's counter. Fails open if the store is down.
+async function allow(bucket, ip, limit, windowSec) {
+ const id = ip || 'unknown'
+ const key = `${bucket}:${id}:${Math.floor(Date.now() / 1000 / windowSec)}`
+ let count = 0
+ try {
+ count = (await store().get(key, { type: 'json' }))?.n || 0
+ } catch {
+ return { allowed: true, count: 0, limit }
+ }
+ if (count >= limit) return { allowed: false, count, limit }
+ try {
+ await store().setJSON(key, { n: count + 1 })
+ } catch {
+ /* best-effort */
+ }
+ return { allowed: true, count: count + 1, limit }
+}
+
+export const allowRegister = (ip) => allow('reg', ip, REGISTER_LIMIT, REGISTER_WINDOW_SEC)
+export const allowCimd = (ip) => allow('cimd', ip, CIMD_LIMIT, CIMD_WINDOW_SEC)
diff --git a/netlify/functions/lib/oauth/refresh.mjs b/netlify/functions/lib/oauth/refresh.mjs
new file mode 100644
index 00000000..adb1af6e
--- /dev/null
+++ b/netlify/functions/lib/oauth/refresh.mjs
@@ -0,0 +1,31 @@
+// Refresh-token rotation + reuse detection (OAuth 2.1 / RFC 6819 BCP).
+//
+// Each refresh issues a NEW refresh token and supersedes the old one (rotation).
+// Tokens belong to a "family"; if a *superseded* (already-used) token is ever
+// replayed, the whole family is revoked (reuse detection) — a theft signal that
+// forces re-authentication. We only store hashes of refresh tokens.
+
+import { randomBytes, createHash, randomUUID } from 'node:crypto'
+
+export function hashRefresh(token) {
+ return createHash('sha256').update(String(token)).digest('hex')
+}
+
+export function newRefreshToken() {
+ const token = `rt_${randomBytes(32).toString('base64url')}`
+ return { token, hash: hashRefresh(token) }
+}
+
+export function newFamilyId() {
+ return randomUUID()
+}
+
+// Pure decision (unit-tested). `record` = the refresh token's stored record,
+// `family` = its family doc, both possibly null. Times in ms.
+export function decideRefresh({ record, family, nowMs }) {
+ if (!record) return { action: 'invalid', reason: 'unknown_token' }
+ if (!family || family.revoked) return { action: 'invalid', reason: 'family_revoked' }
+ if (record.expiresAt && record.expiresAt < nowMs) return { action: 'invalid', reason: 'expired' }
+ if (record.used) return { action: 'reuse', reason: 'token_reuse' } // caller revokes the family
+ return { action: 'rotate' }
+}
diff --git a/netlify/functions/lib/oauth/store.mjs b/netlify/functions/lib/oauth/store.mjs
new file mode 100644
index 00000000..6cf3d481
--- /dev/null
+++ b/netlify/functions/lib/oauth/store.mjs
@@ -0,0 +1,38 @@
+// OAuth state storage — backend selector.
+//
+// The public interface here is stable; callers (mcp-oauth.mjs, clients.mjs)
+// don't know or care which backend is active. The one-time-use / transactional
+// state (auth requests, auth codes, refresh tokens, families) is served by
+// either the Blobs backend (default) or the Neon Postgres backend, chosen by
+// STORE_BACKEND. The Neon backend exists because Blobs has no compare-and-swap,
+// so its one-time-use is best-effort; Neon makes consume atomic.
+//
+// Rollout: deploy with STORE_BACKEND=blobs (default), flip a preview to neon,
+// verify, then flip prod. Roll back by resetting the env var — no code revert.
+//
+// NOTE: flipping blobs→neon does not migrate existing rows, so any live refresh
+// tokens (in Blobs) won't exist in Neon — users re-authenticate once at cutover.
+// Auth codes (60s TTL) are unaffected in practice. Flip during low traffic.
+
+import * as blobs from './db/blobs.mjs'
+import * as neon from './db/neon.mjs'
+
+const backend = (process.env.STORE_BACKEND || 'blobs').toLowerCase() === 'neon' ? neon : blobs
+
+// One-time-use / transactional OAuth state — backend-selectable.
+export const putAuthRequest = (...a) => backend.putAuthRequest(...a)
+export const takeAuthRequest = (...a) => backend.takeAuthRequest(...a)
+export const putAuthCode = (...a) => backend.putAuthCode(...a)
+export const takeAuthCode = (...a) => backend.takeAuthCode(...a)
+export const putRefresh = (...a) => backend.putRefresh(...a)
+export const getRefresh = (...a) => backend.getRefresh(...a)
+export const consumeRefresh = (...a) => backend.consumeRefresh(...a)
+export const putFamily = (...a) => backend.putFamily(...a)
+export const getFamily = (...a) => backend.getFamily(...a)
+export const revokeFamily = (...a) => backend.revokeFamily(...a)
+
+// DCR-registered clients always live on Blobs: they are plain persistence (not
+// one-time-use), so the Neon migration's atomicity buys nothing here. Keeping
+// them on Blobs keeps the migration surface small.
+export const putClient = blobs.putClient
+export const getStoredClient = blobs.getStoredClient
diff --git a/netlify/functions/lib/oauth/upstream.mjs b/netlify/functions/lib/oauth/upstream.mjs
new file mode 100644
index 00000000..a611ba11
--- /dev/null
+++ b/netlify/functions/lib/oauth/upstream.mjs
@@ -0,0 +1,74 @@
+// Upstream IdP federation (the human login leg).
+// mode 'auth0' — real Redpanda Cloud Auth0 (public client + PKCE).
+// mode 'mock' — dev stand-in so the flow runs with no client_id yet.
+
+import { jwtVerify, createRemoteJWKSet, decodeJwt } from 'jose'
+import { UPSTREAM_MODE, AUTH0_ISSUER, AUTH0_CLIENT_ID, PATHS } from './config.mjs'
+
+let jwksCache = null
+function auth0Jwks() {
+ if (!jwksCache) jwksCache = createRemoteJWKSet(new URL('.well-known/jwks.json', AUTH0_ISSUER))
+ return jwksCache
+}
+
+// URL we redirect the user to in order to authenticate upstream.
+export function buildAuthorizeUrl({ origin, state, redirectUri, codeChallenge }) {
+ if (UPSTREAM_MODE === 'auth0') {
+ if (!AUTH0_CLIENT_ID) throw new Error('REDPANDA_OAUTH_CLIENT_ID required for auth0 mode')
+ const u = new URL('authorize', AUTH0_ISSUER)
+ u.searchParams.set('response_type', 'code')
+ u.searchParams.set('client_id', AUTH0_CLIENT_ID)
+ u.searchParams.set('redirect_uri', redirectUri)
+ u.searchParams.set('scope', 'openid email profile')
+ u.searchParams.set('state', state)
+ u.searchParams.set('code_challenge', codeChallenge)
+ u.searchParams.set('code_challenge_method', 'S256')
+ return u.toString()
+ }
+ if (UPSTREAM_MODE === 'mock') {
+ const u = new URL(PATHS.mockIdp, origin)
+ u.searchParams.set('state', state)
+ u.searchParams.set('redirect_uri', redirectUri)
+ return u.toString()
+ }
+ throw new Error('upstream IdP not configured') // fail-closed (see config.resolveUpstreamMode)
+}
+
+// Exchange the upstream code for the user's verified identity claims.
+export async function exchangeCode({ code, codeVerifier, redirectUri }) {
+ if (UPSTREAM_MODE === 'auth0') {
+ const res = await fetch(new URL('oauth/token', AUTH0_ISSUER), {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: new URLSearchParams({
+ grant_type: 'authorization_code',
+ client_id: AUTH0_CLIENT_ID,
+ code,
+ code_verifier: codeVerifier,
+ redirect_uri: redirectUri,
+ }),
+ })
+ if (!res.ok) throw new Error(`upstream token exchange failed: ${res.status}`)
+ const tok = await res.json()
+ // Validate the ID token against Auth0's JWKS (sig/iss/aud/exp).
+ const { payload } = await jwtVerify(tok.id_token, auth0Jwks(), {
+ issuer: AUTH0_ISSUER,
+ audience: AUTH0_CLIENT_ID,
+ algorithms: ['RS256'], // Auth0 ID tokens are RS256; pin it (defense-in-depth)
+ })
+ return {
+ sub: payload.sub,
+ email: payload.email,
+ email_verified: payload.email_verified === true,
+ org_id: payload.org_id || null,
+ org_name: payload.org_name || null,
+ }
+ }
+ if (UPSTREAM_MODE === 'mock') {
+ // canned verified identity (dev only)
+ return { sub: 'mock|123', email: 'spike@redpanda.com', email_verified: true, org_id: 'org_mock', org_name: 'Mock Org' }
+ }
+ throw new Error('upstream IdP not configured') // fail-closed
+}
+
+export { UPSTREAM_MODE }
diff --git a/netlify/functions/lib/store.mjs b/netlify/functions/lib/store.mjs
new file mode 100644
index 00000000..3492bc59
--- /dev/null
+++ b/netlify/functions/lib/store.mjs
@@ -0,0 +1,46 @@
+// User capture for the MCP server, backed by Netlify Blobs.
+// ----------------------------------------------------------
+// Sole consumer of @netlify/blobs. Records each authenticated user (their
+// verified work email + org/domain) for lead capture and usage attribution.
+// On first sight of a user, optionally forwards the lead to a CRM webhook.
+
+import { getStore } from '@netlify/blobs'
+
+const STORE_NAME = 'mcp-users'
+
+function store() {
+ return getStore(STORE_NAME)
+}
+
+// Record an authenticated user. Best-effort and idempotent: dedupes by `sub`
+// (falling back to email). Call fire-and-forget — never block the request.
+// We record (don't block on) emailVerified: enterprise/SSO logins often omit it,
+// so blocking would lock out legitimate users. Capturing the flag lets downstream
+// (CRM / lead scoring) distinguish verified emails when needed.
+export async function recordUser({ sub, email, domain, emailVerified = false }) {
+ const key = sub || email
+ if (!key) return
+
+ const now = new Date().toISOString()
+ const existing = await store().get(key, { type: 'json' }).catch(() => null)
+ const isNew = !existing
+
+ const record = isNew
+ ? { sub, email, domain, emailVerified, firstSeenAt: now, lastSeenAt: now, requestCount: 1 }
+ : { ...existing, email, domain, emailVerified, lastSeenAt: now, requestCount: (existing.requestCount || 0) + 1 }
+
+ await store().setJSON(key, record).catch((e) =>
+ console.warn('[store] recordUser write failed', { error: e?.message })
+ )
+
+ if (isNew) {
+ console.log(JSON.stringify({ event: 'mcp_user_captured', domain, sub, emailVerified, ts: now }))
+ if (process.env.CRM_WEBHOOK_URL) {
+ fetch(process.env.CRM_WEBHOOK_URL, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ email, domain, sub, emailVerified, source: 'mcp', timestamp: now }),
+ }).catch((e) => console.warn('[store] CRM webhook failed', { error: e?.message }))
+ }
+ }
+}
diff --git a/netlify/functions/mcp-oauth.mjs b/netlify/functions/mcp-oauth.mjs
new file mode 100644
index 00000000..39438ea8
--- /dev/null
+++ b/netlify/functions/mcp-oauth.mjs
@@ -0,0 +1,296 @@
+// Docs MCP OAuth 2.1 Authorization Server.
+// ----------------------------------------
+// Our service is the AS for MCP clients (ChatGPT, Claude, …) and federates the
+// human login upstream to the Redpanda Cloud IdP (Auth0). It issues our own
+// signed access tokens; the /mcp resource server validates them.
+//
+// Implemented (Milestone 1): discovery, JWKS, /authorize (PKCE), /callback
+// (federation), /token (authorization_code + PKCE).
+// Deferred: DCR/CIMD client registration (M2), refresh_token grant (M3),
+// consent screen, revocation.
+
+import { getJwks, signAccessToken } from './lib/oauth/keys.mjs'
+import {
+ putAuthRequest, takeAuthRequest, putAuthCode, takeAuthCode,
+ putRefresh, getRefresh, consumeRefresh, putFamily, getFamily, revokeFamily,
+} from './lib/oauth/store.mjs'
+import { buildAuthorizeUrl, exchangeCode, UPSTREAM_MODE } from './lib/oauth/upstream.mjs'
+import { verifyChallenge, generatePair } from './lib/oauth/pkce.mjs'
+import { registerClient, getClient, redirectUriAllowed, isCimdClientId } from './lib/oauth/clients.mjs'
+import { hashRefresh, newRefreshToken, newFamilyId, decideRefresh } from './lib/oauth/refresh.mjs'
+import { loginInterstitialHtml } from './lib/oauth/pages.mjs'
+import { allowRegister, allowCimd, clientIp } from './lib/oauth/ratelimit.mjs'
+import { PATHS, SCOPES, ACCESS_TOKEN_TTL_SEC, REFRESH_TOKEN_TTL_SEC, REQUIRE_WORK_EMAIL, UPSTREAM_MISCONFIGURED, SIGNUP_URL, PRIVACY_URL, LOGIN_INTERSTITIAL, endpoints } from './lib/oauth/config.mjs'
+import { isWorkEmail, emailDomain } from './lib/auth.mjs'
+import { recordUser } from './lib/store.mjs'
+
+// CORS for the endpoints browser-based OAuth clients fetch cross-origin
+// (discovery, JWKS, /token, /register). Public discovery + public-client token
+// exchange, so a wildcard origin is appropriate.
+const CORS = {
+ 'Access-Control-Allow-Origin': '*',
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization',
+}
+
+const json = (body, status = 200) =>
+ new Response(JSON.stringify(body, null, 2), {
+ status,
+ headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', ...CORS },
+ })
+const html = (body, status = 200) =>
+ new Response(body, { status, headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' } })
+const redirect = (location) => new Response(null, { status: 302, headers: { Location: location } })
+
+// Send an OAuth error back to the downstream client's redirect_uri.
+function clientError(redirectUri, state, error, description) {
+ const u = new URL(redirectUri)
+ u.searchParams.set('error', error)
+ if (description) u.searchParams.set('error_description', description)
+ if (state) u.searchParams.set('state', state)
+ return redirect(u.toString())
+}
+
+export default async (request) => {
+ const url = new URL(request.url)
+ const origin = url.origin
+ const path = url.pathname
+ const q = url.searchParams
+ const ep = endpoints(origin)
+
+ // CORS preflight for browser-based clients (discovery, JWKS, /token, /register).
+ // /authorize and /callback are top-level browser navigations, not fetches, so
+ // they don't need it — but a blanket 204 here is harmless.
+ if (request.method === 'OPTIONS') {
+ return new Response(null, { status: 204, headers: { ...CORS, 'Access-Control-Max-Age': '86400' } })
+ }
+
+ // -------- Discovery (RFC 8414) --------
+ if (path === PATHS.metadata) {
+ return json({
+ issuer: ep.issuer,
+ authorization_endpoint: ep.authorization_endpoint,
+ token_endpoint: ep.token_endpoint,
+ jwks_uri: ep.jwks_uri,
+ registration_endpoint: ep.registration_endpoint,
+ response_types_supported: ['code'],
+ grant_types_supported: ['authorization_code', 'refresh_token'],
+ code_challenge_methods_supported: ['S256'],
+ token_endpoint_auth_methods_supported: ['none'],
+ scopes_supported: SCOPES,
+ client_id_metadata_document_supported: true, // CIMD (clients may use a URL client_id)
+ })
+ }
+
+ if (path === PATHS.jwks) return json(await getJwks())
+
+ // -------- /register: Dynamic Client Registration (RFC 7591) --------
+ if (path === PATHS.register && request.method === 'POST') {
+ const rl = await allowRegister(clientIp(request))
+ if (!rl.allowed) {
+ return json({ error: 'rate_limited', error_description: 'too many registrations; try again later' }, 429)
+ }
+ const meta = await request.json().catch(() => null)
+ if (!meta) return json({ error: 'invalid_client_metadata', error_description: 'JSON body required' }, 400)
+ try {
+ return json(await registerClient(meta), 201)
+ } catch (e) {
+ return json({ error: e.code || 'invalid_client_metadata', error_description: e.message }, 400)
+ }
+ }
+
+ // Fail closed: if no real upstream is configured and dev-mock isn't explicitly
+ // allowed (e.g. a prod deploy missing the client_id), refuse the flow rather
+ // than issuing mock identities. Discovery + JWKS above stay available.
+ if (UPSTREAM_MISCONFIGURED) {
+ return json({ error: 'server_error', error_description: 'authorization server upstream not configured' }, 503)
+ }
+
+ // -------- /authorize: downstream client starts the flow --------
+ if (path === PATHS.authorize) {
+ const clientId = q.get('client_id')
+ const redirectUri = q.get('redirect_uri')
+ const codeChallenge = q.get('code_challenge')
+ if (!clientId || !redirectUri) return json({ error: 'invalid_request', error_description: 'client_id and redirect_uri required' }, 400)
+
+ // Resolve + validate the client and redirect_uri BEFORE any redirect — never
+ // redirect to an unvalidated URI (open-redirect / code-injection guard).
+ // CIMD resolution makes an outbound fetch, so rate-limit it per IP (the
+ // resolved result is also cached in getClient).
+ if (isCimdClientId(clientId) && !(await allowCimd(clientIp(request))).allowed) {
+ return json({ error: 'rate_limited', error_description: 'too many client-resolution requests; try again later' }, 429)
+ }
+ const client = await getClient(clientId)
+ if (!client) return json({ error: 'invalid_client', error_description: 'unknown client_id (register via DCR or use a CIMD URL)' }, 400)
+ if (!redirectUriAllowed(client, redirectUri)) {
+ return json({ error: 'invalid_request', error_description: 'redirect_uri not registered for this client' }, 400)
+ }
+
+ // redirect_uri is now trusted, so PKCE errors may be returned to it.
+ if (!codeChallenge || q.get('code_challenge_method') !== 'S256') {
+ return clientError(redirectUri, q.get('state'), 'invalid_request', 'PKCE S256 required')
+ }
+
+ const upstream = generatePair() // our PKCE for the upstream leg
+ const reqId = await putAuthRequest({
+ clientId,
+ clientRedirectUri: redirectUri,
+ clientState: q.get('state') || '',
+ clientCodeChallenge: codeChallenge,
+ upstreamVerifier: upstream.verifier,
+ })
+
+ const upstreamUrl = buildAuthorizeUrl({ origin, state: reqId, redirectUri: ep.callback_uri, codeChallenge: upstream.challenge })
+
+ // Interstitial: show a "Continue / Sign up" page (so users without a Cloud
+ // account get a signup link) before bouncing to the IdP. Disable with
+ // MCP_OAUTH_INTERSTITIAL=off to redirect straight through.
+ if (LOGIN_INTERSTITIAL) {
+ return html(loginInterstitialHtml({ continueUrl: upstreamUrl, signupUrl: SIGNUP_URL, privacyUrl: PRIVACY_URL }))
+ }
+ return redirect(upstreamUrl)
+ }
+
+ // -------- Dev-only mock upstream --------
+ if (path === PATHS.mockIdp) {
+ if (UPSTREAM_MODE !== 'mock') return json({ error: 'not_found' }, 404)
+ const back = new URL(q.get('redirect_uri'))
+ back.searchParams.set('code', 'mock-upstream-code')
+ back.searchParams.set('state', q.get('state'))
+ return redirect(back.toString())
+ }
+
+ // -------- /callback: upstream returns; we mint our own code --------
+ if (path === PATHS.callback) {
+ const authReq = await takeAuthRequest(q.get('state'))
+ if (!q.get('code') || !authReq) return json({ error: 'invalid_request', error_description: 'unknown or expired state' }, 400)
+
+ let user
+ try {
+ user = await exchangeCode({ code: q.get('code'), codeVerifier: authReq.upstreamVerifier, redirectUri: ep.callback_uri })
+ } catch (e) {
+ return clientError(authReq.clientRedirectUri, authReq.clientState, 'server_error', 'upstream login failed')
+ }
+
+ const domain = emailDomain(user.email)
+ if (REQUIRE_WORK_EMAIL && user.email && !isWorkEmail(domain).ok) {
+ return clientError(authReq.clientRedirectUri, authReq.clientState, 'access_denied', 'A work account is required')
+ }
+
+ // Lead capture (best-effort, non-blocking). We capture email_verified rather
+ // than blocking on it — SSO logins often omit it (see recordUser).
+ recordUser({ sub: user.sub, email: user.email, domain, emailVerified: user.email_verified === true }).catch(() => {})
+
+ const code = await putAuthCode({
+ clientId: authReq.clientId,
+ clientRedirectUri: authReq.clientRedirectUri,
+ clientCodeChallenge: authReq.clientCodeChallenge,
+ user: { sub: user.sub, email: user.email, email_verified: user.email_verified, org_id: user.org_id, org_name: user.org_name, domain },
+ })
+
+ const back = new URL(authReq.clientRedirectUri)
+ back.searchParams.set('code', code)
+ if (authReq.clientState) back.searchParams.set('state', authReq.clientState)
+ return redirect(back.toString())
+ }
+
+ // -------- /token: exchange our code (+ PKCE) for an access token --------
+ if (path === PATHS.token && request.method === 'POST') {
+ const ct = request.headers.get('content-type') || ''
+ const body = ct.includes('application/json')
+ ? await request.json().catch(() => ({}))
+ : Object.fromEntries(new URLSearchParams(await request.text()))
+
+ const audience = `${origin}/mcp`
+ const mintAccess = (u, scope) =>
+ signAccessToken(
+ { sub: u.sub, email: u.email, email_verified: u.email_verified, org_id: u.org_id, org_name: u.org_name, scope },
+ { issuer: ep.issuer, audience, ttlSec: ACCESS_TOKEN_TTL_SEC }
+ )
+ const issueRefresh = async (familyId, clientId, user, scope) => {
+ const { token, hash } = newRefreshToken()
+ await putRefresh(hash, { familyId, clientId, user, scope, used: false, expiresAt: Date.now() + REFRESH_TOKEN_TTL_SEC * 1000 })
+ return token
+ }
+
+ // ---- authorization_code grant ----
+ if (body.grant_type === 'authorization_code') {
+ const rec = await takeAuthCode(body.code)
+ if (!rec) return json({ error: 'invalid_grant', error_description: 'invalid or used code' }, 400)
+ // Public clients don't authenticate, so client_id is REQUIRED in the token
+ // request and must match the client the code was issued to (RFC 6749 §3.2.1).
+ if (!body.client_id) {
+ return json({ error: 'invalid_request', error_description: 'client_id is required' }, 400)
+ }
+ if (body.client_id !== rec.clientId) {
+ return json({ error: 'invalid_grant', error_description: 'client_id does not match the authorization request' }, 400)
+ }
+ if (body.redirect_uri !== rec.clientRedirectUri) return json({ error: 'invalid_grant', error_description: 'redirect_uri mismatch' }, 400)
+ if (!verifyChallenge(body.code_verifier, rec.clientCodeChallenge)) {
+ return json({ error: 'invalid_grant', error_description: 'PKCE verification failed' }, 400)
+ }
+
+ const scope = SCOPES.join(' ')
+ const access_token = await mintAccess(rec.user, scope)
+ const familyId = newFamilyId()
+ await putFamily(familyId, { revoked: false, clientId: rec.clientId, createdAt: Date.now() })
+ const refresh_token = await issueRefresh(familyId, rec.clientId, rec.user, scope)
+ return json({ access_token, token_type: 'Bearer', expires_in: ACCESS_TOKEN_TTL_SEC, scope, refresh_token })
+ }
+
+ // ---- refresh_token grant (rotation + reuse detection) ----
+ if (body.grant_type === 'refresh_token') {
+ if (!body.refresh_token) return json({ error: 'invalid_request', error_description: 'refresh_token required' }, 400)
+ const oldHash = hashRefresh(body.refresh_token)
+ const record = await getRefresh(oldHash)
+ const family = record ? await getFamily(record.familyId) : null
+ const decision = decideRefresh({ record, family, nowMs: Date.now() })
+
+ if (decision.action === 'reuse') {
+ await revokeFamily(record.familyId) // theft signal — kill the whole session
+ return json({ error: 'invalid_grant', error_description: 'refresh token reuse detected; session revoked' }, 400)
+ }
+ if (decision.action === 'invalid') return json({ error: 'invalid_grant', error_description: decision.reason }, 400)
+ if (!body.client_id) {
+ return json({ error: 'invalid_request', error_description: 'client_id is required' }, 400)
+ }
+ if (body.client_id !== record.clientId) {
+ return json({ error: 'invalid_grant', error_description: 'client_id does not match the refresh token' }, 400)
+ }
+
+ // Atomically consume (supersede) the presented token. On the Neon backend
+ // this is a single UPDATE…WHERE used=false RETURNING, so only one of two
+ // concurrent requests wins; the loser gets null and is treated as reuse
+ // (theft signal), closing the rotation race. On Blobs this is best-effort.
+ const consumed = await consumeRefresh(oldHash)
+ if (!consumed) {
+ await revokeFamily(record.familyId)
+ return json({ error: 'invalid_grant', error_description: 'refresh token reuse detected; session revoked' }, 400)
+ }
+
+ const refresh_token = await issueRefresh(record.familyId, record.clientId, record.user, record.scope)
+ const access_token = await mintAccess(record.user, record.scope)
+ return json({ access_token, token_type: 'Bearer', expires_in: ACCESS_TOKEN_TTL_SEC, scope: record.scope, refresh_token })
+ }
+
+ return json({ error: 'unsupported_grant_type' }, 400)
+ }
+
+ return json({ error: 'not_found', path }, 404)
+}
+
+// NOTE: Netlify statically analyzes `config.path` at bundle time, so these MUST
+// be literal strings (not imported constants). Keep in sync with PATHS in
+// lib/oauth/config.mjs.
+export const config = {
+ path: [
+ '/.well-known/oauth-authorization-server',
+ '/.well-known/jwks.json',
+ '/oauth/authorize',
+ '/oauth/token',
+ '/oauth/register',
+ '/mcp/callback',
+ '/oauth/mock-idp/authorize',
+ ],
+ preferStatic: false,
+}
diff --git a/netlify/functions/mcp.mjs b/netlify/functions/mcp.mjs
index a5af7768..83da0be3 100644
--- a/netlify/functions/mcp.mjs
+++ b/netlify/functions/mcp.mjs
@@ -1,10 +1,14 @@
// Redpanda Docs MCP Server on Netlify Functions
// -----------------------------------------------
-// This serverless function implements an authless MCP (Model Context Protocol) server
+// This serverless function implements the MCP (Model Context Protocol) server
// that proxies requests to Kapa AI's chat and search APIs for Redpanda documentation.
// It uses the official MCP SDK plus the Netlify adapter (modelfetch) to support
// JSON-RPC over HTTP and SSE streaming.
//
+// Auth: acts as an OAuth 2.0 resource server. The auth middleware below validates
+// our own access tokens (issued by the AS in mcp-oauth.mjs); enforcement is gated
+// by REQUIRE_AUTH. Tokens are issued via the separate authorization-server function.
+//
// For background and reference implementations, see:
// - Kapa AI blog: Build an MCP Server with Kapa AI
// https://www.kapa.ai/blog/build-an-mcp-server-with-kapa-ai
@@ -17,6 +21,10 @@ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/
import { z } from 'zod'
import handle from '@modelfetch/netlify'
+import { extractBearerToken, decideAuth, isAuthEnforced, isWorkEmailRequired } from './lib/auth.mjs'
+import { verifyAccessToken } from './lib/oauth/keys.mjs'
+import { recordUser } from './lib/store.mjs'
+
import rateLimiterModule from 'hono-rate-limiter'
const makeRateLimiter =
rateLimiterModule.rateLimiter ||
@@ -25,7 +33,7 @@ const makeRateLimiter =
// -------------------- Config --------------------
-const SERVER_VERSION = '1.1.3'
+const SERVER_VERSION = '1.3.0'
// Hardcoded upstream
const KAPA_MCP_SERVER_URL = 'https://redpanda.mcp.kapa.ai'
@@ -51,6 +59,20 @@ function apiToUrl(api) {
const CONNECT_TIMEOUT_MS = 8_000
const CALL_TIMEOUT_MS = 22_000
const MAX_QUERY_CHARS = 2_000
+const MAX_FEEDBACK_CHARS = 5_000
+
+// Feedback is submitted to the existing `api-feedback` Netlify form — the same
+// store our docs feedback uses. Registered fields live in
+// home/modules/ROOT/attachments/api-feedback-registration.html (keep in sync).
+//
+// IMPORTANT: Netlify only processes a form POST if it reaches a static 200 page;
+// the site root `/` 301-redirects to `/home/`, and a redirect drops the POST
+// body (so the form is never recorded). We therefore POST to a non-redirecting
+// page (`/home/`) and use `redirect: 'error'` below so a redirect surfaces as a
+// failure instead of a false success. Override the path with MCP_FEEDBACK_FORM_PATH.
+const FEEDBACK_FORM_NAME = 'api-feedback'
+const FEEDBACK_FORM_PATH = process.env.MCP_FEEDBACK_FORM_PATH || '/home/'
+const SITE_URL = process.env.URL || process.env.DEPLOY_PRIME_URL || ''
// -------------------- Helpers --------------------
@@ -64,11 +86,33 @@ function withTimeout(promise, ms, label) {
)
}
+// Submit a field map to the `api-feedback` Netlify form (URL-encoded POST to a
+// non-redirecting page, with the required form-name). `redirect: 'error'` means
+// any 3xx (e.g. hitting a redirect that would drop the POST body) throws rather
+// than following it to a 200 and falsely reporting success. Throws on a missing
+// site URL or a non-2xx so the caller surfaces a clear failure to the agent.
+async function submitFeedback(fields) {
+ if (!SITE_URL) throw new Error('site URL not configured')
+ const body = new URLSearchParams({ 'form-name': FEEDBACK_FORM_NAME, ...fields })
+ const res = await fetch(`${SITE_URL}${FEEDBACK_FORM_PATH}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: body.toString(),
+ redirect: 'error',
+ })
+ if (!res.ok) throw new Error(`feedback submission failed: ${res.status}`)
+}
+
// -------------------- Rate limiting --------------------
const computeLimiterKey = (c) => {
const h = (name) => c.req.header(name) || ''
+ // Authenticated requests get per-user limits (set by the auth middleware).
+ const auth = c.get('auth')
+ if (auth?.sub) return `sub:${auth.sub}`
+ if (auth?.email) return `email:${auth.email}`
+
const clientKey = h('x-client-key')
if (clientKey) return `ck:${clientKey}`
@@ -155,12 +199,22 @@ function ensureKapaConnected() {
return kapaConnectPromise
}
-// Kapa Hosted MCP search tool only accepts `query`
-function callKapaSearch(query) {
- return kapaClient.callTool({
+// Kapa Hosted MCP search tool accepts `query`. When we have an authenticated
+// user we also attach `_meta.user` for Kapa-side usage attribution.
+function callKapaSearch(query, user = null) {
+ const toolCall = {
name: KAPA_TOOL_NAME,
arguments: { query },
- })
+ }
+ if (user?.email) {
+ toolCall._meta = {
+ user: {
+ email: user.email,
+ company_name: user.domain || undefined,
+ },
+ }
+ }
+ return kapaClient.callTool(toolCall)
}
// -------------------- Bump.sh API Docs MCP client --------------------
@@ -213,24 +267,12 @@ const server = new McpServer({
version: SERVER_VERSION,
})
-// -------------------- MCPcat Analytics --------------------
-// Initialize MCPcat tracking (if MCPCAT_PROJECT is set)
-// MCPcat is an open-source analytics platform for MCP usage tracking.
-// See https://www.mcpcat.com/ for details.
-
+// MCPcat analytics is initialized AFTER all tools are registered — see the
+// track() call near the end of this file. (MCPcat wraps the SDK's tools/list
+// and tools/call handlers, which only exist once the first tool is registered;
+// initializing it before registration silently no-ops — "no user intent".)
const MCPCAT_PROJECT = process.env.MCPCAT_PROJECT
-if (MCPCAT_PROJECT) {
- try {
- // Dynamic import to avoid bundler issues
- const { track } = await import('mcpcat')
- track(server, MCPCAT_PROJECT)
- } catch (e) {
- // Don't crash the MCP server if analytics fail to load.
- console.warn('[mcpcat] disabled due to import error:', e)
- }
-}
-
server.registerTool(
'ask_redpanda_question',
{
@@ -244,9 +286,12 @@ server.registerTool(
top_k: z.number().optional(),
},
},
- async (args) => {
+ async (args, extra) => {
const start = Date.now()
+ // Authenticated user context, attached by the auth middleware via c.set('auth').
+ const user = extra?.authInfo || null
+
const q = String(args?.question || '').trim()
if (!q) {
return {
@@ -284,7 +329,7 @@ server.registerTool(
)
return await withTimeout(
- callKapaSearch(q),
+ callKapaSearch(q, user),
CALL_TIMEOUT_MS,
'kapa_callTool'
)
@@ -296,6 +341,15 @@ server.registerTool(
upstream: 'kapa-mcp',
})
+ // If Kapa rejected the request because of our user metadata, retry once
+ // without it (attribution is best-effort; never block the answer).
+ if (/_meta|metadata/i.test(msg) && user) {
+ try {
+ return await withTimeout(callKapaSearch(q, null), CALL_TIMEOUT_MS, 'kapa_callTool_no_meta')
+ } catch {
+ // fall through to the normal transient-retry path below
+ }
+ }
if (isTransientError(msg)) {
// retry once
@@ -307,7 +361,7 @@ server.registerTool(
'kapa_reconnect'
)
return await withTimeout(
- callKapaSearch(q),
+ callKapaSearch(q, user),
CALL_TIMEOUT_MS,
'kapa_callTool_retry'
)
@@ -614,6 +668,138 @@ Returns up to 10 pages per request. URLs must be from docs.redpanda.com/api/doc/
}
)
+// -------------------- Feedback tool --------------------
+// Lets agents forward user feedback (bugs, doc gaps, frustrations, feature
+// requests) straight to the Redpanda team — the docs/DX team's MCP feedback
+// channel. Goes to the same `api-feedback` Netlify form as our docs feedback.
+
+server.registerTool(
+ 'submit_documentation_feedback',
+ {
+ title: 'Submit Documentation Feedback',
+ description:
+ `Send feedback about the Redpanda documentation or products directly to the Redpanda team.
+
+If the user hits a bug, a documentation gap, incorrect or missing information, or expresses frustration while using Redpanda, ASK whether they'd like to send feedback to the Redpanda team. Only call this tool once the user agrees — never submit feedback without their consent. Summarize their feedback clearly and include the relevant documentation page URL or context when you know it.`,
+ inputSchema: {
+ feedback: z
+ .string()
+ .min(1)
+ .max(MAX_FEEDBACK_CHARS)
+ .describe('The user feedback to submit, in clear prose. Summarize the bug, gap, or request.'),
+ category: z
+ .enum(['bug', 'documentation_gap', 'feature_request', 'other'])
+ .optional()
+ .describe('The type of feedback.'),
+ page_url: z
+ .string()
+ .optional()
+ .describe('The documentation page URL the feedback relates to, if known.'),
+ },
+ },
+ async (args, extra) => {
+ const start = Date.now()
+
+ const feedback = String(args?.feedback || '').trim()
+ if (!feedback) {
+ return {
+ content: [{
+ type: 'text',
+ text: JSON.stringify({ error: 'missing_feedback', message: 'Provide non-empty feedback text.' }),
+ }],
+ }
+ }
+
+ // Attach the authenticated user (set by the auth middleware) so the team can
+ // follow up; anonymous when unauthenticated. Never logs the raw email.
+ const user = extra?.authInfo || null
+ const category = args?.category || 'other'
+ const pageUrl = String(args?.page_url || '')
+ const timestamp = new Date().toISOString()
+
+ try {
+ await withTimeout(
+ submitFeedback({
+ feedback: feedback.slice(0, MAX_FEEDBACK_CHARS),
+ category,
+ 'page-path': pageUrl,
+ 'user-email': user?.email || '',
+ 'user-domain': user?.domain || '',
+ 'bot-field': '',
+ }),
+ CALL_TIMEOUT_MS,
+ 'feedback_submit'
+ )
+
+ console.log(JSON.stringify({
+ event: 'mcp_feedback_submitted',
+ category,
+ domain: user?.domain || null,
+ authed: !!user,
+ ts: timestamp,
+ }))
+
+ return {
+ content: [{
+ type: 'text',
+ text: JSON.stringify({ status: 'submitted', message: 'Thanks — your feedback has been sent to the Redpanda team.' }),
+ }],
+ }
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : String(err)
+ console.warn('Feedback submission failed', { error: msg, duration_ms: Date.now() - start })
+ return {
+ content: [{
+ type: 'text',
+ text: JSON.stringify({
+ error: 'submission_failed',
+ message: 'Could not submit feedback right now. Please try again later.',
+ detail: msg,
+ }),
+ }],
+ }
+ }
+ }
+)
+
+// -------------------- MCPcat Analytics --------------------
+// Initialized HERE — after every server.registerTool above — because MCPcat
+// wraps the SDK's tools/list and tools/call handlers, and those only exist once
+// the first tool is registered. (Calling track() before registration grabs no
+// handlers and silently no-ops, which shows up as "no user intent provided".)
+//
+// identify attaches the authenticated user (from our verified OAuth context,
+// extra.authInfo) so usage is per-user/per-org instead of anonymous; returns
+// null when unauthenticated so grace-period sessions stay anonymous. NOTE: this
+// forwards the user's email to MCPcat (a third-party analytics provider); the
+// login notice + Privacy Policy disclose sharing with service providers.
+// Pending legal sign-off pre-launch.
+if (MCPCAT_PROJECT) {
+ try {
+ const { track } = await import('mcpcat')
+ track(server, MCPCAT_PROJECT, {
+ identify: async (_request, extra) => {
+ const u = extra?.authInfo
+ if (!u?.sub) return null
+ return {
+ userId: u.sub,
+ userName: u.email || u.domain || undefined,
+ userData: { domain: u.domain || null, emailVerified: u.emailVerified === true },
+ }
+ },
+ // Prompt the agent for its intent per call (captured as "agent intent"),
+ // while steering it away from sensitive data in that free-text field.
+ customContextDescription:
+ 'In one concise sentence (third person), describe what the user is trying to ' +
+ 'accomplish with this request. Do not include credentials, tokens, personal ' +
+ 'data, or verbatim secrets.',
+ })
+ } catch (e) {
+ // Don't crash the MCP server if analytics fail to load.
+ console.warn('[mcpcat] disabled due to import error:', e)
+ }
+}
+
// -------------------- Netlify handler --------------------
// Safety net: even with transport onerror/onclose handlers, a stray background
@@ -670,6 +856,52 @@ const baseHandler = handle({
legacyHeaders: true, // also send X-RateLimit-* headers
})
+ // OAuth resource-server middleware. Runs BEFORE the limiter so authenticated
+ // requests can be keyed per-user. Never gates OPTIONS or the GET/SSE stream.
+ // Grace period (REQUIRE_AUTH != 'true'): unauthenticated requests pass
+ // through. Enforced: a 401 points MCP clients at our protected-resource
+ // metadata so they can sign in with a Redpanda Cloud account.
+ app.use('/mcp', async (c, next) => {
+ const method = c.req.method
+ // Don't gate GET/OPTIONS here: OPTIONS is preflight, and GET is declined
+ // with 405 by the rate-limit middleware below (we don't offer the optional
+ // SSE stream). Tool calls are POST and are gated here, so an enforced
+ // deployment can't yield a usable unauthenticated channel.
+ if (method === 'OPTIONS' || method === 'GET') return next()
+
+ // Validate OUR OWN access token (issued by our AS), not the upstream IdP.
+ const origin = new URL(c.req.url).origin
+ const token = extractBearerToken(c.req.header('authorization'))
+ const verified = token
+ ? await verifyAccessToken(token, { issuer: origin, audience: `${origin}/mcp` })
+ : { valid: false }
+ const claims = verified.valid ? verified.claims : null
+
+ const resourceMetadataUrl = new URL('/.well-known/oauth-protected-resource', c.req.url).toString()
+ const { allow, userContext, response } = decideAuth({
+ claims,
+ enforced: isAuthEnforced(),
+ workEmailRequired: isWorkEmailRequired(),
+ resourceMetadataUrl,
+ })
+
+ if (userContext) {
+ c.set('auth', userContext)
+ recordUser(userContext).catch(() => {}) // fire-and-forget lead capture
+ }
+
+ if (!allow && response) {
+ return c.json(response.body, response.status, response.headers)
+ }
+
+ if (!userContext) {
+ // Grace period, unauthenticated: log for adoption tracking.
+ console.log(JSON.stringify({ event: 'mcp_unauthenticated', enforced: false, ua: c.req.header('user-agent') || '' }))
+ }
+
+ return next()
+ })
+
app.use('/mcp', async (c, next) => {
const method = c.req.method
if (method === 'GET') {
diff --git a/netlify/functions/oauth-cleanup.mjs b/netlify/functions/oauth-cleanup.mjs
new file mode 100644
index 00000000..41480446
--- /dev/null
+++ b/netlify/functions/oauth-cleanup.mjs
@@ -0,0 +1,25 @@
+// Scheduled cleanup of expired OAuth state (Neon backend only).
+//
+// Runs daily; deletes expired auth requests/codes and past-expiry refresh
+// tokens, then sweeps empty token families. No-op unless STORE_BACKEND=neon and
+// a database URL is configured, so it's safe to keep deployed during rollout.
+
+import { cleanupExpired } from './lib/oauth/db/neon.mjs'
+
+export default async () => {
+ const backend = (process.env.STORE_BACKEND || 'blobs').toLowerCase()
+ if (backend !== 'neon' || !process.env.NETLIFY_DATABASE_URL) {
+ console.log(JSON.stringify({ event: 'oauth_cleanup_skipped', reason: 'not_neon_backend' }))
+ return
+ }
+ try {
+ const deleted = await cleanupExpired()
+ console.log(JSON.stringify({ event: 'oauth_cleanup_ran', deleted }))
+ } catch (e) {
+ console.warn('[oauth-cleanup] failed', { error: e?.message })
+ }
+}
+
+export const config = {
+ schedule: '@daily',
+}
diff --git a/package-lock.json b/package-lock.json
index 30e00c14..d6024173 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,10 +12,13 @@
"@asciidoctor/tabs": "^1.0.0-beta.5",
"@modelcontextprotocol/sdk": "1.17.0",
"@modelfetch/netlify": "0.15.2",
+ "@netlify/blobs": "^9.1.6",
+ "@netlify/database": "^1.0.0",
"@redpanda-data/docs-extensions-and-macros": "^5.0.0",
"@sntke/antora-mermaid-extension": "^0.0.6",
"algoliasearch": "^5.35.0",
"hono-rate-limiter": "0.1.0",
+ "jose": "^5.9.6",
"mcpcat": "^0.1.15",
"puppeteer": "^24.15.0",
"zod": "3.22.4"
@@ -1022,6 +1025,19 @@
"integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==",
"license": "MIT"
},
+ "node_modules/@envelop/instrumentation": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@envelop/instrumentation/-/instrumentation-1.0.0.tgz",
+ "integrity": "sha512-cxgkB66RQB95H3X27jlnxCRNTmPuSTgmBAq6/4n2Dtv4hsk4yz8FadA1ggmd0uZzvKqWD6CR+WFgTjhDqg7eyw==",
+ "license": "MIT",
+ "dependencies": {
+ "@whatwg-node/promise-helpers": "^1.2.1",
+ "tslib": "^2.5.0"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
"node_modules/@exodus/bytes": {
"version": "1.15.1",
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz",
@@ -1056,6 +1072,12 @@
"npm": ">=6.0.0"
}
},
+ "node_modules/@fastify/busboy": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.0.tgz",
+ "integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==",
+ "license": "MIT"
+ },
"node_modules/@hono/mcp": {
"version": "0.1.5",
"resolved": "https://registry.npmjs.org/@hono/mcp/-/mcp-0.1.5.tgz",
@@ -1219,6 +1241,173 @@
"@emnapi/runtime": "^1.7.1"
}
},
+ "node_modules/@neondatabase/serverless": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@neondatabase/serverless/-/serverless-1.1.0.tgz",
+ "integrity": "sha512-r3ZZhRjEcfEdKIZnoB1RusNgvHuaBRqfCzV4Gi+5A9yUX0S4HTws/ASWqt13wL4y4I+0rqsWGdA2w7EQXHi3+Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=19.0.0"
+ }
+ },
+ "node_modules/@netlify/blobs": {
+ "version": "9.1.6",
+ "resolved": "https://registry.npmjs.org/@netlify/blobs/-/blobs-9.1.6.tgz",
+ "integrity": "sha512-RR3t5fv7CkGVJiylOTLf/YaHPyrcWmhHW3zX3EK/9UQsnTi8jPxP7B2nyjgRjAx5S4YTzJQP+FmbQlGKdogALQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@netlify/dev-utils": "3.2.0",
+ "@netlify/runtime-utils": "2.1.0"
+ },
+ "engines": {
+ "node": "^14.16.0 || >=16.0.0"
+ }
+ },
+ "node_modules/@netlify/database": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@netlify/database/-/database-1.0.0.tgz",
+ "integrity": "sha512-k4rft5pkJEXp3Ps1HeuaK2F4a5wuFMeTlMr8PHfRVAdXjit+OsWAqcEqM9mk0lFyyh4zAWNZLXinrQhlZHpUBQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@neondatabase/serverless": "^1.1.0",
+ "@netlify/runtime-utils": "2.3.0",
+ "pg": "^8.13.0",
+ "waddler": "^0.1.1",
+ "ws": "^8.18.0"
+ },
+ "engines": {
+ "node": ">=20.6.1"
+ }
+ },
+ "node_modules/@netlify/database/node_modules/@netlify/runtime-utils": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/@netlify/runtime-utils/-/runtime-utils-2.3.0.tgz",
+ "integrity": "sha512-cW8weDvsKV7zfia2m5EcBy6KILGoPD+eYZ3qWNGnIo05DGF28goPES0xKSDkNYgAF/2rRSIhie2qcBhbGVgSRg==",
+ "license": "MIT",
+ "engines": {
+ "node": "^18.14.0 || >=20"
+ }
+ },
+ "node_modules/@netlify/database/node_modules/ws": {
+ "version": "8.21.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
+ "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@netlify/dev-utils": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/@netlify/dev-utils/-/dev-utils-3.2.0.tgz",
+ "integrity": "sha512-5QPVts2j7RHMNVPVB7E28TC564TarS2JDTfMzKGzCrAY35bvOcfJ60Hhp8DOVjI13+BJgN37srUJP4OBDIXCfg==",
+ "license": "MIT",
+ "dependencies": {
+ "@whatwg-node/server": "^0.10.0",
+ "ansis": "^4.1.0",
+ "chokidar": "^4.0.1",
+ "decache": "^4.6.2",
+ "dot-prop": "9.0.0",
+ "env-paths": "^3.0.0",
+ "find-up": "7.0.0",
+ "image-size": "^2.0.2",
+ "js-image-generator": "^1.0.4",
+ "lodash.debounce": "^4.0.8",
+ "parse-gitignore": "^2.0.0",
+ "uuid": "^11.1.0",
+ "write-file-atomic": "^5.0.1"
+ },
+ "engines": {
+ "node": "^18.14.0 || >=20"
+ }
+ },
+ "node_modules/@netlify/dev-utils/node_modules/chokidar": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
+ "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
+ "license": "MIT",
+ "dependencies": {
+ "readdirp": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 14.16.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/@netlify/dev-utils/node_modules/env-paths": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz",
+ "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==",
+ "license": "MIT",
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@netlify/dev-utils/node_modules/find-up": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-7.0.0.tgz",
+ "integrity": "sha512-YyZM99iHrqLKjmt4LJDj58KI+fYyufRLBSYcqycxf//KpBk9FoewoGX0450m9nB44qrZnovzC2oeP5hUibxc/g==",
+ "license": "MIT",
+ "dependencies": {
+ "locate-path": "^7.2.0",
+ "path-exists": "^5.0.0",
+ "unicorn-magic": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@netlify/dev-utils/node_modules/path-exists": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz",
+ "integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==",
+ "license": "MIT",
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ }
+ },
+ "node_modules/@netlify/dev-utils/node_modules/readdirp": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
+ "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14.18.0"
+ },
+ "funding": {
+ "type": "individual",
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/@netlify/runtime-utils": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/@netlify/runtime-utils/-/runtime-utils-2.1.0.tgz",
+ "integrity": "sha512-z1h+wjB7IVYUsFZsuIYyNxiw5WWuylseY+eXaUDHBxNeLTlqziy+lz03QkR67CUR4Y790xGIhaHV00aOR2KAtw==",
+ "license": "MIT",
+ "engines": {
+ "node": "^18.14.0 || >=20"
+ }
+ },
"node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
@@ -3153,6 +3342,75 @@
"node": ">=18.0.0"
}
},
+ "node_modules/@whatwg-node/disposablestack": {
+ "version": "0.0.6",
+ "resolved": "https://registry.npmjs.org/@whatwg-node/disposablestack/-/disposablestack-0.0.6.tgz",
+ "integrity": "sha512-LOtTn+JgJvX8WfBVJtF08TGrdjuFzGJc4mkP8EdDI8ADbvO7kiexYep1o8dwnt0okb0jYclCDXF13xU7Ge4zSw==",
+ "license": "MIT",
+ "dependencies": {
+ "@whatwg-node/promise-helpers": "^1.0.0",
+ "tslib": "^2.6.3"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@whatwg-node/fetch": {
+ "version": "0.10.13",
+ "resolved": "https://registry.npmjs.org/@whatwg-node/fetch/-/fetch-0.10.13.tgz",
+ "integrity": "sha512-b4PhJ+zYj4357zwk4TTuF2nEe0vVtOrwdsrNo5hL+u1ojXNhh1FgJ6pg1jzDlwlT4oBdzfSwaBwMCtFCsIWg8Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@whatwg-node/node-fetch": "^0.8.3",
+ "urlpattern-polyfill": "^10.0.0"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@whatwg-node/node-fetch": {
+ "version": "0.8.6",
+ "resolved": "https://registry.npmjs.org/@whatwg-node/node-fetch/-/node-fetch-0.8.6.tgz",
+ "integrity": "sha512-BDMdYFcerLQkwA2RTldxOqRCs6ZQD1S7UgP3pUdGUkcbgTrP/V5ko77ZkCww9DHmC4lpoYuwigGfQYj285gMvA==",
+ "license": "MIT",
+ "dependencies": {
+ "@fastify/busboy": "^3.1.1",
+ "@whatwg-node/disposablestack": "^0.0.6",
+ "@whatwg-node/promise-helpers": "^1.3.2",
+ "tslib": "^2.6.3"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@whatwg-node/promise-helpers": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@whatwg-node/promise-helpers/-/promise-helpers-1.3.2.tgz",
+ "integrity": "sha512-Nst5JdK47VIl9UcGwtv2Rcgyn5lWtZ0/mhRQ4G8NN2isxpq2TO30iqHzmwoJycjWuyUfg3GFXqP/gFHXeV57IA==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.6.3"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/@whatwg-node/server": {
+ "version": "0.10.18",
+ "resolved": "https://registry.npmjs.org/@whatwg-node/server/-/server-0.10.18.tgz",
+ "integrity": "sha512-kMwLlxUbduttIgaPdSkmEarFpP+mSY8FEm+QWMBRJwxOHWkri+cxd8KZHO9EMrB9vgUuz+5WEaCawaL5wGVoXg==",
+ "license": "MIT",
+ "dependencies": {
+ "@envelop/instrumentation": "^1.0.0",
+ "@whatwg-node/disposablestack": "^0.0.6",
+ "@whatwg-node/fetch": "^0.10.13",
+ "@whatwg-node/promise-helpers": "^1.3.2",
+ "tslib": "^2.6.3"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
"node_modules/abort-controller": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
@@ -3403,6 +3661,15 @@
"node": ">=0.10.0"
}
},
+ "node_modules/ansis": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.3.1.tgz",
+ "integrity": "sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=14"
+ }
+ },
"node_modules/anymatch": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
@@ -4358,6 +4625,14 @@
"integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==",
"license": "MIT"
},
+ "node_modules/callsite": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/callsite/-/callsite-1.0.0.tgz",
+ "integrity": "sha512-0vdNRFXn5q+dtOqjfFtmtlI9N2eVZ7LMyEV2iKC5mEEFvSg/69Ml6b/WU2qF8W1nLRa0wiSrDT3Y5jOHZCwKPQ==",
+ "engines": {
+ "node": "*"
+ }
+ },
"node_modules/callsites": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
@@ -5333,6 +5608,15 @@
}
}
},
+ "node_modules/decache": {
+ "version": "4.6.2",
+ "resolved": "https://registry.npmjs.org/decache/-/decache-4.6.2.tgz",
+ "integrity": "sha512-2LPqkLeu8XWHU8qNCS3kcF6sCcb5zIzvWaAHYSvPfwhdd7mHuah29NssMzrTYyHN4F5oFy2ko9OBYxegtU0FEw==",
+ "license": "MIT",
+ "dependencies": {
+ "callsite": "^1.0.0"
+ }
+ },
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
@@ -5657,6 +5941,21 @@
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
+ "node_modules/dot-prop": {
+ "version": "9.0.0",
+ "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-9.0.0.tgz",
+ "integrity": "sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ==",
+ "license": "MIT",
+ "dependencies": {
+ "type-fest": "^4.18.2"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
@@ -8706,6 +9005,18 @@
"node": ">= 4"
}
},
+ "node_modules/image-size": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/image-size/-/image-size-2.0.2.tgz",
+ "integrity": "sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w==",
+ "license": "MIT",
+ "bin": {
+ "image-size": "bin/image-size.js"
+ },
+ "engines": {
+ "node": ">=16.x"
+ }
+ },
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -8722,6 +9033,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/imurmurhash": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
+ "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.8.19"
+ }
+ },
"node_modules/inflight": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
@@ -9268,6 +9588,15 @@
"url": "https://github.com/sponsors/isaacs"
}
},
+ "node_modules/jose": {
+ "version": "5.10.0",
+ "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz",
+ "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
"node_modules/joycon": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz",
@@ -9277,6 +9606,21 @@
"node": ">=10"
}
},
+ "node_modules/jpeg-js": {
+ "version": "0.4.4",
+ "resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz",
+ "integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/js-image-generator": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/js-image-generator/-/js-image-generator-1.0.4.tgz",
+ "integrity": "sha512-ckb7kyVojGAnArouVR+5lBIuwU1fcrn7E/YYSd0FK7oIngAkMmRvHASLro9Zt5SQdWToaI66NybG+OGxPw/HlQ==",
+ "license": "ISC",
+ "dependencies": {
+ "jpeg-js": "^0.4.2"
+ }
+ },
"node_modules/js-levenshtein": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz",
@@ -10245,6 +10589,21 @@
"node": ">=0.10.0"
}
},
+ "node_modules/locate-path": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-7.2.0.tgz",
+ "integrity": "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==",
+ "license": "MIT",
+ "dependencies": {
+ "p-locate": "^6.0.0"
+ },
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
@@ -10264,6 +10623,12 @@
"integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==",
"license": "MIT"
},
+ "node_modules/lodash.debounce": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
+ "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
+ "license": "MIT"
+ },
"node_modules/long": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
@@ -11612,6 +11977,36 @@
"integrity": "sha512-KiOAIsdpUTcAXuykya5fnVVT+/5uS0Q1mrkRHcF89tpieSmY33O/tmc54CqwA+bfhbtEfZUNLHaPUiB9X3jt1A==",
"license": "MIT"
},
+ "node_modules/p-limit": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz",
+ "integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==",
+ "license": "MIT",
+ "dependencies": {
+ "yocto-queue": "^1.0.0"
+ },
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-locate": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-6.0.0.tgz",
+ "integrity": "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==",
+ "license": "MIT",
+ "dependencies": {
+ "p-limit": "^4.0.0"
+ },
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/pa11y": {
"version": "9.1.1",
"resolved": "https://registry.npmjs.org/pa11y/-/pa11y-9.1.1.tgz",
@@ -11895,6 +12290,15 @@
"node": ">=0.8"
}
},
+ "node_modules/parse-gitignore": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/parse-gitignore/-/parse-gitignore-2.0.0.tgz",
+ "integrity": "sha512-RmVuCHWsfu0QPNW+mraxh/xjQVw/lhUCUru8Zni3Ctq3AoMhpDTq0OVdKS6iesd6Kqb7viCV3isAL43dciOSog==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14"
+ }
+ },
"node_modules/parse-json": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
@@ -12190,6 +12594,95 @@
"integrity": "sha512-rixgxw3SxyJbCaSpo1n35A/fwI1r2rdwMKOTCg/AcG+xOEyZcE8UHVjpZMFCVImzsFoCZeJTT+M/rdEIQYO2nw==",
"license": "MIT"
},
+ "node_modules/pg": {
+ "version": "8.22.0",
+ "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
+ "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-connection-string": "^2.14.0",
+ "pg-pool": "^3.14.0",
+ "pg-protocol": "^1.15.0",
+ "pg-types": "2.2.0",
+ "pgpass": "1.0.5"
+ },
+ "engines": {
+ "node": ">= 16.0.0"
+ },
+ "optionalDependencies": {
+ "pg-cloudflare": "^1.4.0"
+ },
+ "peerDependencies": {
+ "pg-native": ">=3.0.1"
+ },
+ "peerDependenciesMeta": {
+ "pg-native": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/pg-cloudflare": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
+ "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/pg-connection-string": {
+ "version": "2.14.0",
+ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
+ "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
+ "license": "MIT"
+ },
+ "node_modules/pg-int8": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/pg-pool": {
+ "version": "3.14.0",
+ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
+ "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "pg": ">=8.0"
+ }
+ },
+ "node_modules/pg-protocol": {
+ "version": "1.15.0",
+ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz",
+ "integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==",
+ "license": "MIT"
+ },
+ "node_modules/pg-types": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
+ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-int8": "1.0.1",
+ "postgres-array": "~2.0.0",
+ "postgres-bytea": "~1.0.0",
+ "postgres-date": "~1.0.4",
+ "postgres-interval": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/pgpass": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
+ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
+ "license": "MIT",
+ "dependencies": {
+ "split2": "^4.1.0"
+ }
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -12494,6 +12987,45 @@
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"license": "MIT"
},
+ "node_modules/postgres-array": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
+ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postgres-bytea": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
+ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-date": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
+ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-interval": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
+ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "xtend": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/prepend-http": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/prepend-http/-/prepend-http-3.0.1.tgz",
@@ -15335,6 +15867,18 @@
"integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==",
"license": "ISC"
},
+ "node_modules/type-fest": {
+ "version": "4.41.0",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
+ "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
+ "license": "(MIT OR CC0-1.0)",
+ "engines": {
+ "node": ">=16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/type-is": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
@@ -15503,6 +16047,18 @@
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
"license": "MIT"
},
+ "node_modules/unicorn-magic": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz",
+ "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/union-value": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/union-value/-/union-value-1.0.1.tgz",
@@ -15657,6 +16213,12 @@
"integrity": "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==",
"license": "BSD"
},
+ "node_modules/urlpattern-polyfill": {
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-10.1.0.tgz",
+ "integrity": "sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==",
+ "license": "MIT"
+ },
"node_modules/use": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz",
@@ -15690,6 +16252,19 @@
"node": ">= 0.4.0"
}
},
+ "node_modules/uuid": {
+ "version": "11.1.1",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz",
+ "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==",
+ "funding": [
+ "https://github.com/sponsors/broofa",
+ "https://github.com/sponsors/ctavan"
+ ],
+ "license": "MIT",
+ "bin": {
+ "uuid": "dist/esm/bin/uuid"
+ }
+ },
"node_modules/v8flags": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/v8flags/-/v8flags-3.2.0.tgz",
@@ -16043,6 +16618,94 @@
"node": ">=18"
}
},
+ "node_modules/waddler": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/waddler/-/waddler-0.1.1.tgz",
+ "integrity": "sha512-lBJXYFBLEpYe+scAeCJmLj6Iqweuq1whM6Am3I9WfopOCFxvKz8Nq5hXoy8/b3zwJqHIQMglFIvM4skRydSpZg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@clickhouse/client": "^1.11.2",
+ "@duckdb/node-api": "^1.1.2-alpha.4",
+ "@electric-sql/pglite": "^0.2.17",
+ "@libsql/client": "^0.15.4",
+ "@libsql/client-wasm": "^0.15.4",
+ "@neondatabase/serverless": "^1.0.0",
+ "@planetscale/database": "^1.19.0",
+ "@tidbcloud/serverless": "^0.2.0",
+ "@vercel/postgres": "^0.10.0",
+ "@xata.io/client": "^0.30.1",
+ "better-sqlite3": "^11.9.1",
+ "bun-types": "*",
+ "duckdb": "^1.2.1",
+ "gel": "^2.0.2",
+ "mysql2": "^3.14.0",
+ "pg": "^8.14.0",
+ "pg-query-stream": "^4.8.0",
+ "postgres": "^3.4.5"
+ },
+ "peerDependenciesMeta": {
+ "@clickhouse/client": {
+ "optional": true
+ },
+ "@cloudflare/workers-types": {
+ "optional": true
+ },
+ "@duckdb/node-api": {
+ "optional": true
+ },
+ "@electric-sql/pglite": {
+ "optional": true
+ },
+ "@libsql/client": {
+ "optional": true
+ },
+ "@libsql/client-wasm": {
+ "optional": true
+ },
+ "@neondatabase/serverless": {
+ "optional": true
+ },
+ "@op-engineering/op-sqlite": {
+ "optional": true
+ },
+ "@planetscale/database": {
+ "optional": true
+ },
+ "@tidbcloud/serverless": {
+ "optional": true
+ },
+ "@vercel/postgres": {
+ "optional": true
+ },
+ "@xata.io/client": {
+ "optional": true
+ },
+ "better-sqlite3": {
+ "optional": true
+ },
+ "bun-types": {
+ "optional": true
+ },
+ "duckdb": {
+ "optional": true
+ },
+ "gel": {
+ "optional": true
+ },
+ "mysql2": {
+ "optional": true
+ },
+ "pg": {
+ "optional": true
+ },
+ "pg-query-stream": {
+ "optional": true
+ },
+ "postgres": {
+ "optional": true
+ }
+ }
+ },
"node_modules/web-streams-polyfill": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
@@ -16235,6 +16898,19 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
+ "node_modules/write-file-atomic": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz",
+ "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==",
+ "license": "ISC",
+ "dependencies": {
+ "imurmurhash": "^0.1.4",
+ "signal-exit": "^4.0.1"
+ },
+ "engines": {
+ "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+ }
+ },
"node_modules/ws": {
"version": "7.5.11",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz",
@@ -16435,6 +17111,18 @@
"node": ">= 4.0.0"
}
},
+ "node_modules/yocto-queue": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz",
+ "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/zod": {
"version": "3.22.4",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz",
diff --git a/package.json b/package.json
index 7fbe626f..c1ef42ec 100644
--- a/package.json
+++ b/package.json
@@ -9,7 +9,8 @@
"serve": "wds --node-resolve --open / --watch --root-dir docs",
"afdocs": "vitest run agent-docs.test.ts",
"afdocs:verbose": "vitest run agent-docs.test.ts --reporter=verbose",
- "afdocs:cli": "afdocs check \"${DEPLOY_URL:-https://docs.redpanda.com}\" -v"
+ "afdocs:cli": "afdocs check \"${DEPLOY_URL:-https://docs.redpanda.com}\" -v",
+ "test:mcp": "vitest run tests/mcp-auth.test.ts"
},
"dependencies": {
"@antora/cli": "^3.1.14",
@@ -17,10 +18,13 @@
"@asciidoctor/tabs": "^1.0.0-beta.5",
"@modelcontextprotocol/sdk": "1.17.0",
"@modelfetch/netlify": "0.15.2",
+ "@netlify/blobs": "^9.1.6",
+ "@netlify/database": "^1.0.0",
"@redpanda-data/docs-extensions-and-macros": "^5.0.0",
"@sntke/antora-mermaid-extension": "^0.0.6",
"algoliasearch": "^5.35.0",
"hono-rate-limiter": "0.1.0",
+ "jose": "^5.9.6",
"mcpcat": "^0.1.15",
"puppeteer": "^24.15.0",
"zod": "3.22.4"
diff --git a/server.json b/server.json
index d68f3c62..6c6d58d1 100644
--- a/server.json
+++ b/server.json
@@ -1,13 +1,13 @@
{
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-10-17/server.schema.json",
"name": "com.redpanda/docs-mcp",
- "description": "Get authoritative answers about Redpanda documentation and search API references.",
+ "description": "Get authoritative answers about Redpanda documentation, search API references, and send feedback (bugs, doc gaps, feature requests) to the Redpanda team. Sign in with your Redpanda Cloud account (OAuth) when prompted by your MCP client.",
"repository": {
"url": "https://github.com/redpanda-data/docs-site",
"source": "github",
"subfolder": "netlify"
},
- "version": "2026.06.13+pr180-328acad",
+ "version": "2026.06.22+pr181-b375857",
"remotes": [
{
"type": "streamable-http",
diff --git a/tests/mcp-auth.test.ts b/tests/mcp-auth.test.ts
new file mode 100644
index 00000000..783b8cab
--- /dev/null
+++ b/tests/mcp-auth.test.ts
@@ -0,0 +1,146 @@
+import { describe, it, expect } from 'vitest'
+import {
+ emailDomain,
+ isWorkEmail,
+ extractBearerToken,
+ hashToken,
+ buildUnauthorizedResponse,
+ isAuthEnforced,
+ isWorkEmailRequired,
+ decideAuth,
+} from '../netlify/functions/lib/auth.mjs'
+
+describe('emailDomain', () => {
+ it('extracts and lowercases the domain', () => {
+ expect(emailDomain('Jake@Redpanda.COM')).toBe('redpanda.com')
+ expect(emailDomain('no-at')).toBe('')
+ expect(emailDomain('')).toBe('')
+ })
+})
+
+describe('isWorkEmail', () => {
+ it('accepts work domains', () => {
+ expect(isWorkEmail('redpanda.com')).toEqual({ ok: true })
+ expect(isWorkEmail('acme-corp.io')).toEqual({ ok: true })
+ })
+ it('rejects free providers', () => {
+ for (const d of ['gmail.com', 'outlook.com', 'hotmail.com', 'proton.me', 'yahoo.com', 'icloud.com']) {
+ expect(isWorkEmail(d)).toEqual({ ok: false, reason: 'free_provider' })
+ }
+ })
+ it('rejects disposable providers', () => {
+ expect(isWorkEmail('mailinator.com')).toEqual({ ok: false, reason: 'disposable' })
+ expect(isWorkEmail('yopmail.com')).toEqual({ ok: false, reason: 'disposable' })
+ })
+})
+
+describe('extractBearerToken', () => {
+ it('parses the Authorization header (case-insensitive)', () => {
+ expect(extractBearerToken('Bearer abc123')).toBe('abc123')
+ expect(extractBearerToken('bearer xyz ')).toBe('xyz')
+ })
+ it('returns null when absent or non-bearer', () => {
+ expect(extractBearerToken('')).toBeNull()
+ expect(extractBearerToken(null)).toBeNull()
+ expect(extractBearerToken('Basic abc')).toBeNull()
+ })
+ it('does NOT accept a query token (spec forbids tokens in the URL)', () => {
+ // single-arg signature only — no query fallback
+ expect(extractBearerToken(undefined)).toBeNull()
+ })
+})
+
+describe('hashToken', () => {
+ it('is deterministic 64-hex and never returns the raw token', () => {
+ const h = hashToken('rp-secret-token')
+ expect(h).toMatch(/^[0-9a-f]{64}$/)
+ expect(hashToken('rp-secret-token')).toBe(h)
+ expect(h).not.toContain('rp-secret-token')
+ })
+})
+
+describe('buildUnauthorizedResponse', () => {
+ it('returns a 401 with WWW-Authenticate + resource_metadata', () => {
+ const r = buildUnauthorizedResponse({ resourceMetadataUrl: 'https://x.test/.well-known/oauth-protected-resource' })
+ expect(r.status).toBe(401)
+ expect(r.headers['WWW-Authenticate']).toContain('Bearer')
+ expect(r.headers['WWW-Authenticate']).toContain('resource_metadata="https://x.test/.well-known/oauth-protected-resource"')
+ expect(r.body.error).toBe('authentication_required')
+ expect(r.body.resource_metadata).toBe('https://x.test/.well-known/oauth-protected-resource')
+ })
+})
+
+describe('config flags', () => {
+ it('isAuthEnforced only true when REQUIRE_AUTH === "true"', () => {
+ const orig = process.env.REQUIRE_AUTH
+ process.env.REQUIRE_AUTH = 'true'
+ expect(isAuthEnforced()).toBe(true)
+ for (const v of ['false', '', '1', 'yes']) {
+ process.env.REQUIRE_AUTH = v
+ expect(isAuthEnforced()).toBe(false)
+ }
+ delete process.env.REQUIRE_AUTH
+ expect(isAuthEnforced()).toBe(false)
+ if (orig !== undefined) process.env.REQUIRE_AUTH = orig
+ })
+ it('isWorkEmailRequired defaults false, true only when explicitly "true"', () => {
+ const orig = process.env.REQUIRE_WORK_EMAIL
+ delete process.env.REQUIRE_WORK_EMAIL
+ expect(isWorkEmailRequired()).toBe(false)
+ process.env.REQUIRE_WORK_EMAIL = 'true'
+ expect(isWorkEmailRequired()).toBe(true)
+ for (const v of ['false', '', '1', 'yes']) {
+ process.env.REQUIRE_WORK_EMAIL = v
+ expect(isWorkEmailRequired()).toBe(false)
+ }
+ if (orig === undefined) delete process.env.REQUIRE_WORK_EMAIL
+ else process.env.REQUIRE_WORK_EMAIL = orig
+ })
+})
+
+describe('decideAuth matrix', () => {
+ const workClaims = { sub: 'auth0|123', email: 'jake@redpanda.com', email_verified: true }
+ const freeClaims = { sub: 'auth0|456', email: 'someone@gmail.com', email_verified: true }
+
+ it('no token + grace -> allow with null context', () => {
+ const r = decideAuth({ claims: null, enforced: false, workEmailRequired: true })
+ expect(r.allow).toBe(true)
+ expect(r.userContext).toBeNull()
+ expect(r.response).toBeNull()
+ })
+ it('no token + enforced -> 401', () => {
+ const r = decideAuth({ claims: null, enforced: true, workEmailRequired: true })
+ expect(r.allow).toBe(false)
+ expect(r.response.status).toBe(401)
+ })
+ it('valid work email -> allow + context', () => {
+ const r = decideAuth({ claims: workClaims, enforced: true, workEmailRequired: true })
+ expect(r.allow).toBe(true)
+ expect(r.userContext).toEqual({ sub: 'auth0|123', email: 'jake@redpanda.com', domain: 'redpanda.com', emailVerified: true })
+ })
+ it('email_verified=false -> still allowed (SSO logins often omit it), but flagged unverified', () => {
+ // We don't block on email_verified: the user authenticated via the IdP, and
+ // enterprise/SSO logins frequently leave it false. We capture the flag so
+ // downstream (CRM/attribution) can distinguish if needed.
+ const r = decideAuth({ claims: { sub: 'auth0|9', email: 'jake@redpanda.com', email_verified: false }, enforced: true, workEmailRequired: false })
+ expect(r.allow).toBe(true)
+ expect(r.userContext.emailVerified).toBe(false)
+ expect(r.userContext.email).toBe('jake@redpanda.com')
+ })
+ it('email_verified absent -> treated as not verified (false)', () => {
+ const r = decideAuth({ claims: { sub: 'auth0|10', email: 'jake@redpanda.com' }, enforced: true, workEmailRequired: false })
+ expect(r.allow).toBe(true)
+ expect(r.userContext.emailVerified).toBe(false)
+ })
+ it('free email + work required -> 403 forbidden', () => {
+ const r = decideAuth({ claims: freeClaims, enforced: true, workEmailRequired: true })
+ expect(r.allow).toBe(false)
+ expect(r.response.status).toBe(403)
+ expect(r.response.body.error).toBe('work_email_required')
+ })
+ it('free email + work NOT required -> allow + context', () => {
+ const r = decideAuth({ claims: freeClaims, enforced: true, workEmailRequired: false })
+ expect(r.allow).toBe(true)
+ expect(r.userContext.email).toBe('someone@gmail.com')
+ })
+})
diff --git a/tests/mcp-oauth-clients.test.ts b/tests/mcp-oauth-clients.test.ts
new file mode 100644
index 00000000..38336b09
--- /dev/null
+++ b/tests/mcp-oauth-clients.test.ts
@@ -0,0 +1,97 @@
+import { describe, it, expect, vi } from 'vitest'
+import { isCimdClientId, redirectUriAllowed, validateCimdDocument, getClient, isBlockedHost } from '../netlify/functions/lib/oauth/clients.mjs'
+
+describe('isBlockedHost (SSRF guard)', () => {
+ it('blocks IPv4 private / loopback / link-local + localhost', () => {
+ for (const h of ['127.0.0.1', '10.0.0.5', '192.168.1.1', '169.254.1.1', '172.16.0.1', '172.31.255.255', '0.0.0.0', 'localhost', 'foo.local']) {
+ expect(isBlockedHost(h), h).toBe(true)
+ }
+ })
+ it('blocks IPv6 loopback / ULA / link-local / mapped — including bracketed form', () => {
+ for (const h of ['::1', '[::1]', '::', 'fc00::1', 'fd12:3456::1', '[fd00::1]', 'fe80::1', '[fe80::1]', '::ffff:10.0.0.1']) {
+ expect(isBlockedHost(h), h).toBe(true)
+ }
+ })
+ it('allows public hosts (incl. public IPv6 and fc-prefixed DNS names)', () => {
+ for (const h of ['claude.ai', 'chatgpt.com', 'example.com', '8.8.8.8', '2606:4700::1111', 'fc.example.com']) {
+ expect(isBlockedHost(h), h).toBe(false)
+ }
+ })
+})
+
+describe('isCimdClientId', () => {
+ it('treats https URLs as CIMD client_ids', () => {
+ expect(isCimdClientId('https://claude.ai/.well-known/oauth-client')).toBe(true)
+ expect(isCimdClientId('mcp_abc123')).toBe(false)
+ expect(isCimdClientId('http://insecure/doc')).toBe(false) // not https
+ })
+})
+
+describe('redirectUriAllowed', () => {
+ const client = { redirect_uris: ['https://chatgpt.com/cb', 'http://127.0.0.1:0/callback'] }
+ it('exact match', () => {
+ expect(redirectUriAllowed(client, 'https://chatgpt.com/cb')).toBe(true)
+ expect(redirectUriAllowed(client, 'https://evil.com/cb')).toBe(false)
+ })
+ it('loopback matches ignoring the port (native clients)', () => {
+ expect(redirectUriAllowed(client, 'http://127.0.0.1:52345/callback')).toBe(true)
+ expect(redirectUriAllowed(client, 'http://127.0.0.1:9999/other')).toBe(false) // path must match
+ })
+ it('non-loopback http is not port-flexible', () => {
+ expect(redirectUriAllowed({ redirect_uris: ['http://example.com:1/cb'] }, 'http://example.com:2/cb')).toBe(false)
+ })
+})
+
+describe('validateCimdDocument', () => {
+ const url = 'https://claude.ai/oauth-client.json'
+ it('accepts a doc whose client_id equals its URL', () => {
+ const c = validateCimdDocument(url, { client_id: url, redirect_uris: ['https://claude.ai/cb'] })
+ expect(c.redirect_uris).toEqual(['https://claude.ai/cb'])
+ expect(c.token_endpoint_auth_method).toBe('none')
+ })
+ it('rejects client_id != URL, or missing redirect_uris', () => {
+ expect(() => validateCimdDocument(url, { client_id: 'https://x/', redirect_uris: ['https://x/cb'] })).toThrow()
+ expect(() => validateCimdDocument(url, { client_id: url })).toThrow()
+ })
+})
+
+describe('getClient (CIMD fetch with injected fetch)', () => {
+ it('fetches + validates a CIMD URL client_id', async () => {
+ const url = 'https://claude.ai/oauth-client.json'
+ const fetchImpl = vi.fn(async () => ({ ok: true, text: async () => JSON.stringify({ client_id: url, redirect_uris: ['https://claude.ai/cb'] }) }))
+ const c = await getClient(url, { fetchImpl })
+ expect(c.client_id).toBe(url)
+ expect(fetchImpl).toHaveBeenCalledOnce()
+ })
+ it('unknown DCR client_id -> null (store miss, no crash)', async () => {
+ expect(await getClient('mcp_unknown', { fetchImpl: vi.fn() })).toBeNull()
+ })
+ it('blocks loopback/private CIMD hosts (SSRF guard) -> null, including IPv6', async () => {
+ const spy = vi.fn()
+ for (const url of ['https://127.0.0.1/doc', 'https://localhost/doc', 'https://[::1]/doc', 'https://[fd00::1]/doc', 'https://[fe80::1]/doc']) {
+ expect(await getClient(url, { fetchImpl: spy }), url).toBeNull()
+ }
+ expect(spy).not.toHaveBeenCalled() // never even fetched
+ })
+ it('rejects an oversized CIMD doc declared via content-length -> null', async () => {
+ const url = 'https://big.example/oauth-client.json'
+ const fetchImpl = vi.fn(async () => ({
+ ok: true,
+ headers: { get: (h: string) => (h === 'content-length' ? '999999999' : null) },
+ text: async () => '{}',
+ }))
+ expect(await getClient(url, { fetchImpl })).toBeNull()
+ })
+ it('rejects an oversized CIMD doc with no content-length (streamed cap) -> null', async () => {
+ const url = 'https://big2.example/oauth-client.json'
+ const huge = 'x'.repeat(200_000)
+ const fetchImpl = vi.fn(async () => ({ ok: true, headers: { get: () => null }, text: async () => huge }))
+ expect(await getClient(url, { fetchImpl })).toBeNull()
+ })
+ it('a CIMD fetch that errors (e.g. blocked redirect) -> null, not a throw', async () => {
+ const fetchImpl = vi.fn(async () => {
+ throw new Error('redirect blocked')
+ })
+ expect(await getClient('https://redir.example/oauth-client.json', { fetchImpl })).toBeNull()
+ })
+})
diff --git a/tests/mcp-oauth-neon.test.ts b/tests/mcp-oauth-neon.test.ts
new file mode 100644
index 00000000..62f3f18c
--- /dev/null
+++ b/tests/mcp-oauth-neon.test.ts
@@ -0,0 +1,80 @@
+import { describe, it, expect, beforeAll } from 'vitest'
+import { readFileSync } from 'node:fs'
+import { fileURLToPath } from 'node:url'
+
+// Atomicity tests for the Neon backend. These MUST run against a real Postgres
+// (a fake honoring atomic semantics would prove nothing), so they're skipped
+// unless TEST_NEON_URL points at a disposable test database, e.g.:
+// TEST_NEON_URL=postgres://... npx vitest run tests/mcp-oauth-neon.test.ts
+const TEST_DB = process.env.TEST_NEON_URL
+
+const migrationPath = fileURLToPath(
+ new URL('../netlify/database/migrations/20260622110000_oauth_state.sql', import.meta.url)
+)
+
+describe.skipIf(!TEST_DB)('Neon backend — atomic one-time-use', () => {
+ let store: typeof import('../netlify/functions/lib/oauth/db/neon.mjs')
+
+ beforeAll(async () => {
+ process.env.NETLIFY_DATABASE_URL = TEST_DB
+ process.env.STORE_BACKEND = 'neon'
+
+ const { getDatabase } = await import('@netlify/database')
+ const sql = getDatabase().httpClient // zero-config, reads NETLIFY_DATABASE_URL
+ // Apply schema, then clear any leftover rows from a previous run.
+ for (const stmt of readFileSync(migrationPath, 'utf8').split(';').map((s) => s.trim()).filter(Boolean)) {
+ await sql.query(stmt)
+ }
+ await sql.query('TRUNCATE auth_requests, auth_codes, refresh_tokens, refresh_families')
+
+ store = await import('../netlify/functions/lib/oauth/db/neon.mjs')
+ })
+
+ it('auth code: two concurrent consumes -> exactly one succeeds', async () => {
+ const code = await store.putAuthCode({
+ clientId: 'c1',
+ clientRedirectUri: 'https://c1/cb',
+ clientCodeChallenge: 'chal',
+ user: { sub: 'u1', email: 'a@b.com' },
+ })
+ const results = await Promise.all([store.takeAuthCode(code), store.takeAuthCode(code)])
+ expect(results.filter(Boolean)).toHaveLength(1)
+ // A third attempt always fails (already consumed).
+ expect(await store.takeAuthCode(code)).toBeNull()
+ })
+
+ it('refresh token: two concurrent rotations -> exactly one wins (loser = reuse)', async () => {
+ const familyId = crypto.randomUUID()
+ const hash = 'hash_' + crypto.randomUUID()
+ await store.putFamily(familyId, { revoked: false, clientId: 'c1', createdAt: Date.now() })
+ await store.putRefresh(hash, {
+ familyId,
+ clientId: 'c1',
+ user: { sub: 'u1' },
+ scope: 'openid',
+ used: false,
+ expiresAt: Date.now() + 60_000,
+ })
+
+ const results = await Promise.all([store.consumeRefresh(hash), store.consumeRefresh(hash)])
+ expect(results.filter(Boolean)).toHaveLength(1) // only one rotation wins
+ expect(await store.consumeRefresh(hash)).toBeNull() // already consumed -> reuse signal
+ })
+
+ it('cleanupExpired removes past-expiry rows and empty families', async () => {
+ const familyId = crypto.randomUUID()
+ const hash = 'expired_' + crypto.randomUUID()
+ await store.putFamily(familyId, { revoked: false, clientId: 'c1', createdAt: Date.now() - 120_000 })
+ await store.putRefresh(hash, {
+ familyId,
+ clientId: 'c1',
+ user: { sub: 'u1' },
+ scope: 'openid',
+ used: true,
+ expiresAt: Date.now() - 1_000, // already expired
+ })
+ const deleted = await store.cleanupExpired()
+ expect(deleted.refreshTokens).toBeGreaterThanOrEqual(1)
+ expect(await store.getRefresh(hash)).toBeNull()
+ })
+})
diff --git a/tests/mcp-oauth-pages.test.ts b/tests/mcp-oauth-pages.test.ts
new file mode 100644
index 00000000..961d7752
--- /dev/null
+++ b/tests/mcp-oauth-pages.test.ts
@@ -0,0 +1,29 @@
+import { describe, it, expect } from 'vitest'
+import { loginInterstitialHtml } from '../netlify/functions/lib/oauth/pages.mjs'
+
+describe('loginInterstitialHtml', () => {
+ const continueUrl = 'https://auth.prd.cloud.redpanda.com/authorize?client_id=docs&state=abc&scope=openid+email'
+ const signupUrl = 'https://cloud.redpanda.com'
+ const privacyUrl = 'https://www.redpanda.com/legal/privacy-policy'
+ const out = loginInterstitialHtml({ continueUrl, signupUrl, privacyUrl })
+
+ it('renders a Continue link to the upstream URL and a free-signup link', () => {
+ expect(out).toContain('Continue with Redpanda Cloud')
+ expect(out).toContain('Create a free account')
+ expect(out).toContain('href="https://cloud.redpanda.com"')
+ })
+ it('discloses what we collect and links the privacy policy', () => {
+ expect(out).toContain('verified work email')
+ expect(out).toContain('Privacy Policy')
+ expect(out).toContain('href="https://www.redpanda.com/legal/privacy-policy"')
+ })
+ it('escapes & in the continue URL href (valid HTML attribute)', () => {
+ expect(out).toContain('client_id=docs&state=abc') // & -> &
+ expect(out).not.toContain('client_id=docs&state=abc') // raw & should not appear
+ })
+ it('escapes quotes to prevent attribute breakout', () => {
+ const evil = loginInterstitialHtml({ continueUrl: 'https://x/?a=">