/** * MCP auth helper — centralised access control for all MCP tools. * * Every tool calls resolveAccess() before touching data. * * Access matrix: * public → read open (unless forceAuth), write requires token+member * permissioned → read requires any token, write requires token+member * private → read+write require token+member */ import { verifyToken } from "../auth"; import type { EncryptIDClaims } from "../auth"; import { resolveCallerRole, roleAtLeast } from "../spaces"; import type { SpaceRoleString } from "../spaces"; import { loadCommunity, getDocumentData, normalizeVisibility } from "../community-store"; export interface AccessResult { allowed: boolean; claims: EncryptIDClaims | null; role: SpaceRoleString; reason?: string; } /** * Resolve access for an MCP tool call. * * @param token JWT string (may be undefined for unauthenticated callers) * @param space Space slug * @param forWrite true for mutating operations * @param forceAuth true to always require token+member (e.g. rinbox) */ export async function resolveAccess( token: string | undefined, space: string, forWrite = false, forceAuth = false, ): Promise { // Load space doc await loadCommunity(space); const data = getDocumentData(space); if (!data) { return { allowed: false, claims: null, role: "viewer", reason: "Space not found or access denied" }; } const visibility = normalizeVisibility(data.meta.visibility || "private"); // Verify token if provided let claims: EncryptIDClaims | null = null; if (token) { try { claims = await verifyToken(token); } catch { return { allowed: false, claims: null, role: "viewer", reason: "Invalid or expired token" }; } } // Resolve caller's role in this space const resolved = claims ? await resolveCallerRole(space, claims) : null; const role: SpaceRoleString = resolved?.role ?? "viewer"; const isMember = roleAtLeast(role, "member"); // Write always requires token + member if (forWrite) { if (!claims) return { allowed: false, claims, role, reason: "Authentication required" }; if (!isMember) return { allowed: false, claims, role, reason: "Space membership required" }; return { allowed: true, claims, role }; } // forceAuth → always requires token + member (e.g. email/inbox) if (forceAuth) { if (!claims) { // Don't reveal that the space exists for private spaces return { allowed: false, claims, role, reason: "Space not found or access denied" }; } if (!isMember) return { allowed: false, claims, role, reason: "Space membership required" }; return { allowed: true, claims, role }; } // Read access by visibility switch (visibility) { case "public": return { allowed: true, claims, role }; case "permissioned": if (!claims) return { allowed: false, claims, role, reason: "Authentication required" }; return { allowed: true, claims, role }; case "private": if (!claims) return { allowed: false, claims, role, reason: "Space not found or access denied" }; if (!isMember) return { allowed: false, claims, role, reason: "Space not found or access denied" }; return { allowed: true, claims, role }; default: return { allowed: false, claims, role, reason: "Space not found or access denied" }; } } /** Standard MCP error response for denied access. */ export function accessDeniedResponse(reason: string) { return { content: [{ type: "text" as const, text: JSON.stringify({ error: reason }) }], isError: true }; }