From b3a860766e3d43968ab5e874af14b049285041ee Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Tue, 17 Feb 2026 14:32:11 -0700 Subject: [PATCH] feat: add SpaceRole bridge for cross-module membership sync Resolves user's effective SpaceRole by checking local rVote membership first, then falling back to EncryptID server for cross-space membership. Includes 5-minute in-memory cache and hasCapability helper. Co-Authored-By: Claude Opus 4.6 --- src/lib/space-role.ts | 124 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 src/lib/space-role.ts diff --git a/src/lib/space-role.ts b/src/lib/space-role.ts new file mode 100644 index 0000000..17ebb09 --- /dev/null +++ b/src/lib/space-role.ts @@ -0,0 +1,124 @@ +/** + * Space Role bridge for rVote + * + * Bridges NextAuth session + EncryptID SDK SpaceRole system. + * Resolves the user's effective SpaceRole in the current space + * by querying the EncryptID membership server. + */ + +import { auth } from './auth'; +import { prisma } from './prisma'; +import { + SpaceRole, + hasCapability, + type ResolvedRole, +} from '@encryptid/sdk/types'; +import { RVOTE_PERMISSIONS } from '@encryptid/sdk/types/modules'; +import type { Session } from 'next-auth'; + +const ENCRYPTID_SERVER = process.env.ENCRYPTID_SERVER_URL || 'https://encryptid.jeffemmett.com'; + +// In-memory cache (5 minute TTL) +const roleCache = new Map(); +const CACHE_TTL = 5 * 60 * 1000; + +/** + * Resolve a user's SpaceRole in a given rVote space. + * First checks local rVote membership, then falls back to EncryptID server. + */ +export async function resolveUserSpaceRole( + userId: string, + spaceSlug: string, +): Promise { + const cacheKey = `${userId}:${spaceSlug}`; + const cached = roleCache.get(cacheKey); + if (cached && cached.expires > Date.now()) { + return cached.role; + } + + // Check local rVote space membership first + const space = await prisma.space.findUnique({ + where: { slug: spaceSlug }, + include: { + members: { where: { userId }, take: 1 }, + }, + }); + + if (!space) { + const result: ResolvedRole = { role: SpaceRole.VIEWER, source: 'default' }; + roleCache.set(cacheKey, { role: result, expires: Date.now() + CACHE_TTL }); + return result; + } + + // Check if owner + if (space.creatorId === userId) { + const result: ResolvedRole = { role: SpaceRole.ADMIN, source: 'owner' }; + roleCache.set(cacheKey, { role: result, expires: Date.now() + CACHE_TTL }); + return result; + } + + // Check local membership + const membership = space.members[0]; + if (membership) { + const roleMap: Record = { + ADMIN: SpaceRole.ADMIN, + MODERATOR: SpaceRole.MODERATOR, + MEMBER: SpaceRole.PARTICIPANT, + VIEWER: SpaceRole.VIEWER, + }; + const role = roleMap[membership.role] || SpaceRole.PARTICIPANT; + const result: ResolvedRole = { role, source: 'membership' }; + roleCache.set(cacheKey, { role: result, expires: Date.now() + CACHE_TTL }); + return result; + } + + // Fall back to EncryptID server for cross-module membership + try { + const user = await prisma.user.findUnique({ where: { id: userId } }); + if (user?.did) { + const url = `${ENCRYPTID_SERVER}/api/spaces/${encodeURIComponent(spaceSlug)}/members/${encodeURIComponent(user.did)}`; + const res = await fetch(url); + if (res.ok) { + const data = await res.json() as { role: string }; + const result: ResolvedRole = { role: data.role as SpaceRole, source: 'membership' }; + roleCache.set(cacheKey, { role: result, expires: Date.now() + CACHE_TTL }); + return result; + } + } + } catch { + // Network error — use visibility default + } + + // Default based on space visibility + const isPublic = space.visibility === 'PUBLIC' || space.visibility === 'public'; + const result: ResolvedRole = { + role: isPublic ? SpaceRole.PARTICIPANT : SpaceRole.VIEWER, + source: 'default', + }; + roleCache.set(cacheKey, { role: result, expires: Date.now() + CACHE_TTL }); + return result; +} + +/** + * Check if the current session user has a specific rVote capability. + */ +export async function checkVoteCapability( + session: Session | null, + spaceSlug: string, + capability: keyof typeof RVOTE_PERMISSIONS.capabilities, +): Promise { + if (!session?.user?.id) return false; + const resolved = await resolveUserSpaceRole(session.user.id, spaceSlug); + return hasCapability(resolved.role, capability, RVOTE_PERMISSIONS); +} + +/** + * Invalidate cached role for a user in a space. + */ +export function invalidateSpaceRoleCache(userId?: string, spaceSlug?: string): void { + if (userId && spaceSlug) { + roleCache.delete(`${userId}:${spaceSlug}`); + } else { + roleCache.clear(); + } +}