diff --git a/DECISIONS.md b/DECISIONS.md index 9756c328..12daf160 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1206,3 +1206,5 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-24] Keys minted from the OAuth popup before the first company exists get company_id NULL and are bound lazily in validateApiKey (first validation after a company exists) instead of at company creation: creation happens in a Server Action that knows nothing about keys, and one chokepoint covers every creation path. [2026-08-24] /api/mcp-oauth/authorize now forces TOTP enrollment (not just verification) for password accounts with no factor: the middleware skips enrollment for zero-company users, so a popup signup would otherwise mint an MFA-exempt key for an account with no second factor. BankID-linked accounts stay exempt. [2026-08-24] /auth/callback honours next only when it targets /api/mcp-oauth/authorize (via safeReturnTo): consent handles the zero-company state, an arbitrary deep link would not. +[2026-08-24] MCP lazy authentication (#1814 PR 2) lists the FULL default tool catalog to anonymous clients and only gates tools/call: the agent has to be able to name a protected tool to trigger the 401 challenge that opens the Connect (and signup) prompt; listing only public tools would hide the trigger. Descriptions are public documentation anyway. +[2026-08-24] Public (pre-auth) MCP tools are the three documentation tools only (search_tools, list_skills, load_skill); org-number lookup stays behind the challenge for now because the TIC lookup lives in another extension and cross-extension imports are forbidden. diff --git a/extensions/general/mcp-server/__tests__/company-routing.test.ts b/extensions/general/mcp-server/__tests__/company-routing.test.ts index 8ca5b9ba..15ab132c 100644 --- a/extensions/general/mcp-server/__tests__/company-routing.test.ts +++ b/extensions/general/mcp-server/__tests__/company-routing.test.ts @@ -197,3 +197,22 @@ describe('MCP company routing', () => { }) }) }) + +describe('optional-company tools (issue #1814)', () => { + it('gnubok_list_skills is company-independent but still advertises company_id', () => { + expect(isCompanyDependentTool('gnubok_list_skills')).toBe(false) + const projected = projectToolInputSchema({ + name: 'gnubok_list_skills', + inputSchema: { type: 'object', properties: { tag: { type: 'string' } } }, + }) + expect((projected.properties as Record).company_id).toBeDefined() + }) + + it('purely context-free tools do not advertise company_id', () => { + const projected = projectToolInputSchema({ + name: 'gnubok_search_tools', + inputSchema: { type: 'object', properties: { query: { type: 'string' } } }, + }) + expect((projected.properties as Record).company_id).toBeUndefined() + }) +}) diff --git a/extensions/general/mcp-server/__tests__/lazy-auth.test.ts b/extensions/general/mcp-server/__tests__/lazy-auth.test.ts new file mode 100644 index 00000000..ac79d9ca --- /dev/null +++ b/extensions/general/mcp-server/__tests__/lazy-auth.test.ts @@ -0,0 +1,186 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { eventBus } from '@/lib/events/bus' + +// Lazy authentication (issue #1814 PR 2): a client with no token may connect, +// list the catalog and call the public documentation tools; anything that +// touches a company answers 401 + WWW-Authenticate at the transport level, +// which is what Claude / Claude Code / Codex turn into their Connect prompt. + +const mocks = vi.hoisted(() => ({ + validateApiKey: vi.fn(), + checkRateLimit: vi.fn(), +})) + +vi.mock('@/lib/auth/api-keys', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + validateApiKey: (...args: unknown[]) => mocks.validateApiKey(...args), + createServiceClientNoCookies: vi.fn(() => ({ + from: vi.fn(() => { + throw new Error('anonymous requests must not touch tenant tables') + }), + })), + } +}) + +vi.mock('@/lib/auth/rate-limit-http', () => ({ + checkRateLimit: (...args: unknown[]) => mocks.checkRateLimit(...args), +})) + +// Skills come from the filesystem/registry; keep the list deterministic and +// free of Supabase so the public tools can run under the throwing client. +vi.mock('../skills', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + loadAllSkills: vi.fn().mockResolvedValue([ + { + slug: 'month-end-close', + name: 'Month-end close', + summary: 'Close a month.', + tags: ['close'], + tier: 'workflow', + body: 'Steps…', + applicability: null, + }, + ]), + } +}) + +import { handleMcpRequest } from '../server' + +const ENDPOINT = 'http://localhost:3000/api/extensions/ext/mcp-server/mcp' + +function rpc( + method: string, + params?: Record, + opts: { token?: string; ip?: string } = {} +): Request { + const headers: Record = { 'Content-Type': 'application/json' } + if (opts.token) headers.Authorization = `Bearer ${opts.token}` + if (opts.ip) headers['x-forwarded-for'] = opts.ip + return new Request(ENDPOINT, { + method: 'POST', + headers, + body: JSON.stringify({ jsonrpc: '2.0', id: 7, method, ...(params ? { params } : {}) }), + }) +} + +describe('MCP lazy authentication', () => { + beforeEach(() => { + vi.clearAllMocks() + eventBus.clear() + mocks.checkRateLimit.mockResolvedValue({ ok: true }) + mocks.validateApiKey.mockResolvedValue({ + userId: 'user-1', + companyId: '11111111-1111-4111-8111-111111111111', + scopes: ['companies:read'], + apiKeyId: 'key-1', + apiKeyName: 'Test key', + mode: 'live', + }) + }) + + it('answers initialize without a token and says the client is not connected', async () => { + const response = await handleMcpRequest(rpc('initialize', { protocolVersion: '2025-06-18' })) + expect(response.status).toBe(200) + const body = await response.json() + expect(body.result.instructions).toContain('NOT CONNECTED YET') + expect(body.result.instructions).toContain('gnubok_search_tools') + expect(mocks.validateApiKey).not.toHaveBeenCalled() + }) + + it('lists the full default catalog without a token so protected tools can be called', async () => { + const response = await handleMcpRequest(rpc('tools/list')) + expect(response.status).toBe(200) + const body = await response.json() + const names = (body.result.tools as Array<{ name: string }>).map((t) => t.name) + expect(names).toContain('gnubok_search_tools') + // A company-scoped tool is listed too: calling it is the connect trigger. + expect(names).toContain('gnubok_list_companies') + expect(names.length).toBeGreaterThan(20) + }) + + it('runs a public documentation tool without a token', async () => { + const response = await handleMcpRequest( + rpc('tools/call', { name: 'gnubok_list_skills', arguments: {} }) + ) + expect(response.status).toBe(200) + const body = await response.json() + expect(body.result.isError).not.toBe(true) + const payload = JSON.parse(body.result.content[0].text) + expect(payload.count).toBe(1) + expect(payload.skills[0].slug).toBe('month-end-close') + expect(mocks.validateApiKey).not.toHaveBeenCalled() + }) + + it('ignores a company_id on a public tool when anonymous instead of touching tenant tables', async () => { + const response = await handleMcpRequest( + rpc('tools/call', { + name: 'gnubok_list_skills', + arguments: { company_id: '11111111-1111-4111-8111-111111111111' }, + }) + ) + expect(response.status).toBe(200) + const body = await response.json() + expect(body.result.isError).not.toBe(true) + }) + + it('challenges a protected tool call with a transport-level 401 + WWW-Authenticate', async () => { + const response = await handleMcpRequest( + rpc('tools/call', { name: 'gnubok_list_companies', arguments: {} }) + ) + expect(response.status).toBe(401) + expect(response.headers.get('WWW-Authenticate')).toMatch( + /^Bearer resource_metadata="http:\/\/localhost:3000\/\.well-known\/oauth-protected-resource"$/ + ) + }) + + it('challenges tenant-scoped methods (resources/read, tasks/get) without a token', async () => { + for (const [method, params] of [ + ['resources/read', { uri: 'Accounted://company/current' }], + ['tasks/get', { taskId: 'x' }], + ] as const) { + const response = await handleMcpRequest(rpc(method, params as Record)) + expect(response.status, method).toBe(401) + expect(response.headers.get('WWW-Authenticate'), method).toContain('resource_metadata') + } + }) + + it('keeps a tokenless unparseable body on the pre-lazy-auth answer (401, no detail)', async () => { + const response = await handleMcpRequest( + new Request(ENDPOINT, { method: 'POST', body: 'not json' }) + ) + expect(response.status).toBe(401) + }) + + it('rate-limits anonymous calls per truncated IP', async () => { + mocks.checkRateLimit.mockResolvedValueOnce({ + ok: false, + response: new Response('slow down', { status: 429 }), + }) + const response = await handleMcpRequest( + rpc('tools/list', undefined, { ip: '203.0.113.42, 10.0.0.1' }) + ) + expect(response.status).toBe(429) + expect(mocks.checkRateLimit).toHaveBeenCalledWith( + expect.objectContaining({ prefix: 'mcp:anonymous', identifier: '203.0.113.0/24' }) + ) + }) + + it('never rate-limits or bypasses validation for a token bearer', async () => { + const response = await handleMcpRequest( + rpc('tools/call', { name: 'gnubok_list_skills', arguments: {} }, { token: 'gnubok_sk_x' }) + ) + expect(response.status).toBe(200) + expect(mocks.validateApiKey).toHaveBeenCalledWith('gnubok_sk_x') + expect(mocks.checkRateLimit).not.toHaveBeenCalled() + }) + + it('still rejects an invalid token with 401 even on an anonymous-capable method', async () => { + mocks.validateApiKey.mockResolvedValueOnce({ error: 'Invalid API key', status: 401 }) + const response = await handleMcpRequest(rpc('tools/list', undefined, { token: 'gnubok_sk_bad' })) + expect(response.status).toBe(401) + }) +}) diff --git a/extensions/general/mcp-server/company-routing.ts b/extensions/general/mcp-server/company-routing.ts index a8a645f5..2ad1f592 100644 --- a/extensions/general/mcp-server/company-routing.ts +++ b/extensions/general/mcp-server/company-routing.ts @@ -7,10 +7,24 @@ const UUID_PATTERN = const COMPANY_INDEPENDENT_TOOLS = new Set([ 'gnubok_search_tools', + 'gnubok_list_skills', 'gnubok_load_skill', 'gnubok_list_companies', ]) +/** + * Company-independent tools that still USE a company when one is available: + * they run without one (anonymous or not-yet-onboarded callers, issue #1814) + * but accept an explicit company_id, which is then membership-checked like + * on any company-dependent tool. gnubok_list_skills filters skills by the + * company's entity type, employees and VAT registration. + */ +const OPTIONAL_COMPANY_TOOLS = new Set(['gnubok_list_skills']) + +export function isOptionalCompanyTool(toolName: string): boolean { + return OPTIONAL_COMPANY_TOOLS.has(toolName) +} + export const COMPANY_ID_INPUT_PROPERTY = { type: 'string', format: 'uuid', @@ -68,7 +82,7 @@ export function isTenantWriteScope(scope: ApiKeyScope | undefined): boolean { } export function projectToolInputSchema(tool: ToolSchemaSource): Record { - if (!isCompanyDependentTool(tool.name)) return tool.inputSchema + if (!isCompanyDependentTool(tool.name) && !isOptionalCompanyTool(tool.name)) return tool.inputSchema const properties = tool.inputSchema.properties && typeof tool.inputSchema.properties === 'object' diff --git a/extensions/general/mcp-server/public-tools.ts b/extensions/general/mcp-server/public-tools.ts new file mode 100644 index 00000000..565ba92a --- /dev/null +++ b/extensions/general/mcp-server/public-tools.ts @@ -0,0 +1,56 @@ +import { requestClientIp, truncateIp } from '@/lib/api/ip' + +/** + * Tools an MCP client may call BEFORE the user has connected an account + * (lazy authentication, issue #1814 PR 2). + * + * The server accepts anonymous initialize / tools/list / prompts+resources + * listing and calls to these tools. Every other tools/call answers with a + * transport-level 401 + WWW-Authenticate, which the client turns into its + * Connect prompt (Claude's inline Connect card, Claude Code's /mcp login, + * Codex's `codex mcp login`) and then retries with a token. + * + * Membership rules: a public tool must (1) read no tenant data, (2) need no + * scope (it is absent from TOOL_SCOPE_MAP), and (3) be company-independent. + * Documentation and discovery only; anything that touches a company stays + * behind the challenge so the account is created first. + */ +export const PUBLIC_TOOLS: ReadonlySet = new Set([ + 'gnubok_search_tools', + 'gnubok_list_skills', + 'gnubok_load_skill', +]) + +export function isPublicTool(canonicalToolName: string): boolean { + return PUBLIC_TOOLS.has(canonicalToolName) +} + +/** + * Anonymous calls have no API key to meter on, so they are limited per + * truncated client IP. Generous for a human exploring the catalog, tight + * enough that the documentation tools cannot be farmed. Enforced by + * checkRateLimit (Upstash), which no-ops on deployments without Redis. + */ +export const ANONYMOUS_RATE_LIMIT = { + maxRequests: 60, + windowMs: 60 * 1000, +} as const + +export function anonymousRateLimitIdentifier(request: Request): string { + return truncateIp(requestClientIp(request)) ?? 'unknown' +} + +/** + * JSON-RPC methods that carry no tenant data and are needed for a client to + * connect and orient itself before authentication. + */ +export const ANONYMOUS_METHODS: ReadonlySet = new Set([ + 'initialize', + 'server/discover', + 'ping', + 'notifications/initialized', + 'tools/list', + 'prompts/list', + 'resources/list', + 'resources/templates/list', +]) diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index 67af79eb..d7a45ddd 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -13,7 +13,16 @@ import { createServiceClientNoCookies, hasScope, TOOL_SCOPE_MAP, + type ApiKeyMode, + type ApiKeyScope, } from '@/lib/auth/api-keys' +import { checkRateLimit } from '@/lib/auth/rate-limit-http' +import { + ANONYMOUS_METHODS, + ANONYMOUS_RATE_LIMIT, + anonymousRateLimitIdentifier, + isPublicTool, +} from './public-tools' import { createLogger } from '@/lib/logger' import { roundOre, sumOre } from '@/lib/money' import { @@ -156,6 +165,7 @@ import { codedError, extractRequestedCompany, isCompanyDependentTool, + isOptionalCompanyTool, noCompanyYetError, projectToolInputSchema, resolveMcpCompanyContext, @@ -344,7 +354,9 @@ function resolveInvoiceLineFromArticle( } interface ActorContext { - type: 'user' | 'api_key' | 'mcp_oauth' | 'cron' + // 'anonymous': a client that has not connected an account yet (lazy + // authentication, issue #1814). Only PUBLIC_TOOLS ever run under it. + type: 'user' | 'api_key' | 'mcp_oauth' | 'cron' | 'anonymous' id?: string label?: string /** @@ -3205,19 +3217,23 @@ export const tools: McpTool[] = [ // Resolve company context: read once per call. Failures degrade // gracefully: an unresolved field means "don't filter on it" so a - // misconfigured company still gets the full skill list. - const [settings, employeeCount] = await Promise.all([ - supabase - .from('company_settings') - .select('entity_type, vat_registered') - .eq('company_id', companyId) - .maybeSingle(), - supabase - .from('employees') - .select('id', { count: 'exact', head: true }) - .eq('company_id', companyId) - .eq('is_active', true), - ]) + // misconfigured company still gets the full skill list. No company at + // all (anonymous or not-yet-onboarded caller, issue #1814) means no + // context to filter on: the full list, without the two lookups. + const [settings, employeeCount] = companyId + ? await Promise.all([ + supabase + .from('company_settings') + .select('entity_type, vat_registered') + .eq('company_id', companyId) + .maybeSingle(), + supabase + .from('employees') + .select('id', { count: 'exact', head: true }) + .eq('company_id', companyId) + .eq('is_active', true), + ]) + : [{ data: null }, { count: 0 }] const entityType = (settings.data?.entity_type as string | undefined) ?? null const vatRegistered = Boolean(settings.data?.vat_registered) const hasEmployees = (employeeCount.count ?? 0) > 0 @@ -18142,44 +18158,88 @@ export async function handleMcpRequest(request: Request): Promise { } const wwwAuth = `Bearer resource_metadata="${resourceMetadataUrl.toString()}"` - // ── Pre-auth: handle fire-and-forget notifications before auth check ── - // MCP notifications have no id and don't expect error responses. - // Checking auth on them would return 401 which confuses clients. - const clonedRequest = request.clone() + const unauthorized = () => + new Response('Unauthorized', { + status: 401, + headers: { 'WWW-Authenticate': wwwAuth }, + }) + + // ── Parse JSON-RPC ── + // Parsed before auth: with lazy authentication (issue #1814) the method and + // tool name decide whether a token is required at all. A body that cannot + // be parsed keeps the pre-lazy-auth answer for a tokenless caller (401), so + // probing the endpoint without credentials learns nothing new. + const token = extractBearerToken(request) + let body: JsonRpcRequest try { - const peek = await clonedRequest.json() - if (peek.method === 'notifications/initialized') { - return new Response(null, { status: 202 }) - } + body = await request.json() } catch { - // Not valid JSON: fall through to auth + parse below + if (!token) return unauthorized() + return NextResponse.json( + jsonRpcError(null, -32700, 'Parse error: expected JSON-RPC 2.0 request body'), + { status: 400 } + ) + } + + // Fire-and-forget notification: no id, no response expected. Answering + // 401 here confuses clients, so it is accepted before any auth check. + if (body.method === 'notifications/initialized') { + return new Response(null, { status: 202 }) + } + + if (body.jsonrpc !== '2.0' || !body.method) { + if (!token) return unauthorized() + return NextResponse.json( + jsonRpcError(body.id ?? null, -32600, 'Invalid Request: must include jsonrpc="2.0" and method'), + { status: 400 } + ) } // ── Auth ── - const token = extractBearerToken(request) - if (!token) { - return new Response('Unauthorized', { - status: 401, - headers: { 'WWW-Authenticate': wwwAuth }, - }) - } + // Lazy authentication: a client with no token may connect, list the + // catalog and call the PUBLIC_TOOLS. Every other request answers 401 + + // WWW-Authenticate at the transport level: that challenge is what the + // client turns into its Connect prompt (a 200 with isError never would). + // The account can be created inside that prompt (app/api/mcp-oauth), so + // the first protected tool call is the whole signup trigger. + const requestedTool = + body.method === 'tools/call' + ? toCanonicalToolName(String((body.params as Record | undefined)?.name ?? '')) + : null + const anonymousAllowed = + ANONYMOUS_METHODS.has(body.method) || (requestedTool !== null && isPublicTool(requestedTool)) + if (!token && !anonymousAllowed) return unauthorized() - const authResult = await validateApiKey(token) - if ('error' in authResult) { - const status = authResult.status - if (status === 429) { - return new Response(authResult.error, { - status: 429, - headers: { 'Content-Type': 'text/plain', 'Retry-After': '60' }, - }) + const isAnonymous = !token + let userId = '' + let companyId: string | null = null + let keyScopes: ApiKeyScope[] = [] + let apiKeyId: string | undefined + let apiKeyName: string | undefined + let keyMode: ApiKeyMode = 'live' + if (token) { + const authResult = await validateApiKey(token) + if ('error' in authResult) { + const status = authResult.status + if (status === 429) { + return new Response(authResult.error, { + status: 429, + headers: { 'Content-Type': 'text/plain', 'Retry-After': '60' }, + }) + } + return unauthorized() } - return new Response('Unauthorized', { - status: 401, - headers: { 'WWW-Authenticate': wwwAuth }, + ;({ userId, companyId, scopes: keyScopes, apiKeyId, apiKeyName, mode: keyMode } = authResult) + } else { + // Anonymous traffic has no key to rate-limit on: per truncated IP instead. + // No-op without Upstash (self-hosted), like the OAuth register endpoint. + const rl = await checkRateLimit({ + prefix: 'mcp:anonymous', + identifier: anonymousRateLimitIdentifier(request), + ...ANONYMOUS_RATE_LIMIT, }) + if (!rl.ok) return rl.response! } - - const { userId, companyId, scopes: keyScopes, apiKeyId, apiKeyName, mode: keyMode } = authResult const supabase = createServiceClientNoCookies() // The Mcp-Session-Id header (introduced in spec 2025-06-18) is the canonical // way for an agent to keep a stable identifier across tools/call invocations @@ -18195,31 +18255,15 @@ export async function handleMcpRequest(request: Request): Promise { request.headers.get('x-gnubok-client') ?? new URL(request.url).searchParams.get('client') const client = rawClient && /^[A-Za-z0-9._-]{1,64}$/.test(rawClient) ? rawClient.toLowerCase() : null - const actor: ActorContext = { - type: 'api_key', - id: apiKeyId, - label: apiKeyName ?? 'Unnamed API key', - sessionId, - client, - } - - // ── Parse JSON-RPC ── - let body: JsonRpcRequest - try { - body = await request.json() - } catch { - return NextResponse.json( - jsonRpcError(null, -32700, 'Parse error: expected JSON-RPC 2.0 request body'), - { status: 400 } - ) - } - - if (body.jsonrpc !== '2.0' || !body.method) { - return NextResponse.json( - jsonRpcError(body.id ?? null, -32600, 'Invalid Request: must include jsonrpc="2.0" and method'), - { status: 400 } - ) - } + const actor: ActorContext = isAnonymous + ? { type: 'anonymous', label: 'Not connected', sessionId, client } + : { + type: 'api_key', + id: apiKeyId, + label: apiKeyName ?? 'Unnamed API key', + sessionId, + client, + } // ── Stateless core (spec 2026-07-28) ── // New-style clients carry their protocol version in _meta on every request @@ -18328,6 +18372,12 @@ export async function handleMcpRequest(request: Request): Promise { const instructions = projectToolReferencesInText([ 'Accounted: Swedish double-entry bookkeeping via conversation.', '', + ...(isAnonymous + ? [ + 'NOT CONNECTED YET. Without an account you can call gnubok_search_tools, gnubok_list_skills and gnubok_load_skill. Every other tool needs the user to connect their Accounted account: calling one returns an authentication challenge that your client shows as a Connect prompt. A user who has no account creates one right there (BankID or e-mail, about a minute), and the call is then retried automatically. To start bookkeeping for a company that is not in Accounted yet, call gnubok_list_companies to trigger the connect step, then continue with the setup.', + '', + ] + : []), 'Discovery:', '• tools/list returns common tool schemas. Call gnubok_search_tools(query="…") for specialized tools: it ranks all capabilities; pass detail="name"|"summary"|"full" to control payload size.', '• gnubok_get_agent_briefing returns recommended_tools: ordered per-workflow tool loadouts (categorize_month, close_period, invoice_run, vat_declaration, payroll_month). If your harness defers tool loading, batch-load a whole workflow in one call (e.g. Claude Code ToolSearch select:a,b,c) instead of searching cluster by cluster.', @@ -18395,6 +18445,10 @@ export async function handleMcpRequest(request: Request): Promise { const listStartedAt = Date.now() const allowedTools = tools.filter((t) => { if (!isDefaultCatalogTool(t)) return false + // Not connected yet: the whole default catalog is listed so the agent + // can pick the right tool; calling a protected one is what produces + // the 401 challenge that starts the connect (and signup) flow. + if (isAnonymous) return true const required = TOOL_SCOPE_MAP[t.name] return !required || hasScope(keyScopes, required) }) @@ -18444,6 +18498,9 @@ export async function handleMcpRequest(request: Request): Promise { > const tool = tools.find((t) => t.name === toolName) + // The pre-auth gate already refused anonymous calls to anything outside + // PUBLIC_TOOLS; re-checked here so the dispatcher never depends on it. + if (isAnonymous && !isPublicTool(toolName)) return unauthorized() if (!tool) { emitToolCallTelemetry({ tool: toolName ?? '', @@ -18527,7 +18584,14 @@ export async function handleMcpRequest(request: Request): Promise { ) } - if (isCompanyDependentTool(toolName)) { + + // Optional-company tools resolve (and membership-check) a company only + // when the caller names one; anonymous callers have no memberships to + // check, so a company_id from them is dropped rather than resolved. + const wantsCompanyContext = + isCompanyDependentTool(toolName) || + (isOptionalCompanyTool(toolName) && !isAnonymous && extracted.requestedCompanyId !== undefined) + if (wantsCompanyContext) { const companyContext = await resolveMcpCompanyContext({ supabase, userId, diff --git a/lib/api/ip.ts b/lib/api/ip.ts new file mode 100644 index 00000000..b31bd14d --- /dev/null +++ b/lib/api/ip.ts @@ -0,0 +1,39 @@ +/** + * Truncate a client IP to a privacy-preserving prefix for rate limiting and + * forensic logging: IPv4 to its /24, IPv6 to its /48. Enough for abuse + * correlation, not enough to identify a point of presence. + * + * Honors `x-forwarded-for` when set (Vercel / proxies); behind Vercel the + * leftmost value is rewritten by the edge so we accept it as authoritative. + * + * Lives in its own module (no route or init imports) so both the v1 REST + * wrapper and the MCP server can use it without an import cycle. + */ +export function truncateIp(ip: string | undefined): string | undefined { + if (!ip) return undefined + // IPv4: validate octets are 0-255, then drop last octet → "203.0.113.0/24". + // Out-of-range octets indicate a spoofed or malformed header; refuse to + // log a pseudo-IP that would pollute abuse-pattern analysis. + const v4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(ip) + if (v4) { + const octets = [v4[1], v4[2], v4[3], v4[4]].map((s) => Number.parseInt(s, 10)) + if (octets.every((o) => o >= 0 && o <= 255)) { + return `${octets[0]}.${octets[1]}.${octets[2]}.0/24` + } + return undefined + } + // IPv6: keep first 3 hextets → "2001:db8:abc::/48" + const v6 = /^([0-9a-f]{1,4}:[0-9a-f]{1,4}:[0-9a-f]{1,4}):/i.exec(ip) + if (v6) return `${v6[1]}::/48` + return undefined +} + +/** + * The client IP as the request presents it: leftmost `x-forwarded-for` + * entry, else `x-real-ip`, else undefined. + */ +export function requestClientIp(request: Request): string | undefined { + const forwarded = request.headers.get('x-forwarded-for') + if (forwarded) return forwarded.split(',')[0]?.trim() || undefined + return request.headers.get('x-real-ip') ?? undefined +} diff --git a/lib/api/v1/with-api-v1.ts b/lib/api/v1/with-api-v1.ts index 0dada60f..8e36dc5a 100644 --- a/lib/api/v1/with-api-v1.ts +++ b/lib/api/v1/with-api-v1.ts @@ -38,6 +38,7 @@ import { type SupabaseClient } from '@supabase/supabase-js' import { createServiceRoleClient } from '@/lib/supabase/service-client' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' +import { truncateIp } from '@/lib/api/ip' import { type ApiKeyMode, type ApiKeyScope, @@ -167,24 +168,7 @@ function createAnonClient(): SupabaseClient { * Honors `x-forwarded-for` when set (Vercel / proxies); behind Vercel the * leftmost value is rewritten by the edge so we accept it as authoritative. */ -export function truncateIp(ip: string | undefined): string | undefined { - if (!ip) return undefined - // IPv4: validate octets are 0-255, then drop last octet → "203.0.113.0/24". - // Out-of-range octets indicate a spoofed or malformed header; refuse to - // log a pseudo-IP that would pollute abuse-pattern analysis. - const v4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(ip) - if (v4) { - const octets = [v4[1], v4[2], v4[3], v4[4]].map((s) => Number.parseInt(s, 10)) - if (octets.every((o) => o >= 0 && o <= 255)) { - return `${octets[0]}.${octets[1]}.${octets[2]}.0/24` - } - return undefined - } - // IPv6: keep first 3 hextets → "2001:db8:abc::/48" - const v6 = /^([0-9a-f]{1,4}:[0-9a-f]{1,4}:[0-9a-f]{1,4}):/i.exec(ip) - if (v6) return `${v6[1]}::/48` - return undefined -} +export { truncateIp } function extractForensicContext(request: Request, log: Logger): { ip: string | undefined; userAgent: string | undefined } { const fwd = request.headers.get('x-forwarded-for') diff --git a/lib/events/types.ts b/lib/events/types.ts index 7036ffaa..57db0607 100644 --- a/lib/events/types.ts +++ b/lib/events/types.ts @@ -206,7 +206,7 @@ export type CoreEvent = | { type: 'mcp.tool_called'; payload: { tool: string // e.g. 'gnubok_create_invoice' requiredScope: string | null // from TOOL_SCOPE_MAP, null if unscoped - actorType: 'user' | 'api_key' | 'mcp_oauth' | 'cron' + actorType: 'user' | 'api_key' | 'mcp_oauth' | 'cron' | 'anonymous' actorId: string | null // api_key id, oauth client, etc. actorLabel: string | null // human-readable actor label latencyMs: number // wall-clock time inside execute() @@ -229,7 +229,7 @@ export type CoreEvent = // the caller's scope set. | { type: 'mcp.tools_list_called'; payload: { toolCount: number // tools actually returned (post scope filter) - actorType: 'user' | 'api_key' | 'mcp_oauth' | 'cron' + actorType: 'user' | 'api_key' | 'mcp_oauth' | 'cron' | 'anonymous' actorId: string | null actorLabel: string | null latencyMs: number @@ -248,7 +248,7 @@ export type CoreEvent = success: boolean errorCode: string | null latencyMs: number - actorType: 'user' | 'api_key' | 'mcp_oauth' | 'cron' + actorType: 'user' | 'api_key' | 'mcp_oauth' | 'cron' | 'anonymous' actorId: string | null actorLabel: string | null requestId: string | number | null @@ -264,7 +264,7 @@ export type CoreEvent = | { type: 'mcp.workflow_started'; payload: { slug: string // e.g. 'month-end-close' sessionId: string | null - actorType: 'user' | 'api_key' | 'mcp_oauth' | 'cron' + actorType: 'user' | 'api_key' | 'mcp_oauth' | 'cron' | 'anonymous' actorId: string | null actorLabel: string | null userId: string @@ -276,7 +276,7 @@ export type CoreEvent = outcome: 'success' | 'abandoned' | 'failed' stepsCompleted: number | null // null when not tracked granularly durationMs: number | null - actorType: 'user' | 'api_key' | 'mcp_oauth' | 'cron' + actorType: 'user' | 'api_key' | 'mcp_oauth' | 'cron' | 'anonymous' actorId: string | null actorLabel: string | null userId: string @@ -291,7 +291,7 @@ export type CoreEvent = slug: string // e.g. 'modifier/holding-ab', 'month-end-close' tier: 'workflow' | 'horizontal' | 'vertical' | 'modifier' sessionId: string | null - actorType: 'user' | 'api_key' | 'mcp_oauth' | 'cron' + actorType: 'user' | 'api_key' | 'mcp_oauth' | 'cron' | 'anonymous' actorId: string | null actorLabel: string | null userId: string @@ -305,7 +305,7 @@ export type CoreEvent = fromTool: string toTool: string sessionId: string | null - actorType: 'user' | 'api_key' | 'mcp_oauth' | 'cron' + actorType: 'user' | 'api_key' | 'mcp_oauth' | 'cron' | 'anonymous' actorId: string | null actorLabel: string | null userId: string @@ -320,7 +320,7 @@ export type CoreEvent = toolName: string | null skillSlug: string | null sessionId: string | null - actorType: 'user' | 'api_key' | 'mcp_oauth' | 'cron' + actorType: 'user' | 'api_key' | 'mcp_oauth' | 'cron' | 'anonymous' actorId: string | null actorLabel: string | null userId: string