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:
parent
b98f09305b
commit
b3a860766e
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue