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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-17 14:32:11 -07:00
parent b98f09305b
commit b3a860766e
1 changed files with 124 additions and 0 deletions

124
src/lib/space-role.ts Normal file
View File

@ -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<string, { role: ResolvedRole; expires: number }>();
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<ResolvedRole> {
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<string, SpaceRole> = {
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<boolean> {
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();
}
}