f929b4b1d2
* feat(mcp): lazy authentication so a client can connect before an account exists Second PR of agent-first onboarding (#1814). A client with no token may now initialize, list the default catalog and call the three documentation tools (search_tools, list_skills, load_skill). Every other request keeps the transport-level 401 + WWW-Authenticate, which is what Claude, Claude Code and Codex turn into their Connect prompt; with #1855 the account is created inside that prompt, so the first protected tool call is the whole signup trigger. - The JSON-RPC body is parsed before auth so the method and tool name can decide whether a token is required. A tokenless unparseable body keeps the old 401 answer. - Anonymous callers get an 'anonymous' actor, an empty scope set, a not-connected variant of the initialize instructions, and the full default catalog from tools/list (the agent has to be able to name a protected tool to trigger the challenge). - Anonymous traffic is rate-limited per truncated IP via checkRateLimit; truncateIp moves to lib/api/ip.ts so the MCP server can use it without importing the v1 wrapper (which pulls lib/init and would cycle). - gnubok_list_skills is now company-independent and skips its two context lookups when there is no company (anonymous or not yet onboarded). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018wCdzRTatKiDByKB8hCNT6 * fix(mcp): gnubok_list_skills keeps its company_id argument as an optional-company tool Making list_skills company-independent (so anonymous callers can run it) silently dropped its company_id argument: a multi-company user asking for another company's skill list got the key default instead. Optional- company tools now advertise company_id and resolve (membership-checked) it when an authenticated caller names one; anonymous callers cannot. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018wCdzRTatKiDByKB8hCNT6 --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
40 lines
1.7 KiB
TypeScript
40 lines
1.7 KiB
TypeScript
/**
|
|
* 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
|
|
}
|