103 lines
3.4 KiB
TypeScript
103 lines
3.4 KiB
TypeScript
/**
|
|
* 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<AccessResult> {
|
|
// 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 };
|
|
}
|