diff --git a/src/lib/space-role.ts b/src/lib/space-role.ts new file mode 100644 index 0000000..45bb5cc --- /dev/null +++ b/src/lib/space-role.ts @@ -0,0 +1,106 @@ +/** + * Space Role bridge for rMaps + * + * rMaps uses anonymous-first access (rooms are open by default), + * but when a room is linked to a space, roles are resolved from + * the EncryptID server. This module provides: + * + * - Server-side role resolution for API routes + * - Client-side role resolution from the auth store + * - Capability checks using RMAPS_PERMISSIONS + */ + +import { + SpaceRole, + hasCapability, + type ResolvedRole, +} from '@encryptid/sdk/types'; +import { RMAPS_PERMISSIONS } from '@encryptid/sdk/types/modules'; + +const ENCRYPTID_SERVER = process.env.NEXT_PUBLIC_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 for a room linked to a space. + * + * @param userDID - The user's DID (from EncryptID auth store) + * @param spaceSlug - The space slug this room is linked to + * @returns Resolved role with source + */ +export async function resolveMapSpaceRole( + userDID: string | null, + spaceSlug: string, +): Promise { + // Anonymous users get PARTICIPANT on public rooms (rMaps default) + if (!userDID) { + return { role: SpaceRole.PARTICIPANT, source: 'anonymous' }; + } + + const cacheKey = `${userDID}:${spaceSlug}`; + const cached = roleCache.get(cacheKey); + if (cached && cached.expires > Date.now()) { + return cached.role; + } + + // Query EncryptID server for membership + try { + const url = `${ENCRYPTID_SERVER}/api/spaces/${encodeURIComponent(spaceSlug)}/members/${encodeURIComponent(userDID)}`; + 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 — fall through to default + } + + // Authenticated but no membership → PARTICIPANT (rMaps rooms are open) + const result: ResolvedRole = { role: SpaceRole.PARTICIPANT, source: 'default' }; + roleCache.set(cacheKey, { role: result, expires: Date.now() + CACHE_TTL }); + return result; +} + +/** + * Check if a user has a specific rMaps capability. + * + * Usage (client-side): + * ```ts + * const { did } = useAuthStore(); + * const canAddMarkers = await checkMapCapability(did, roomSlug, 'add_markers'); + * ``` + */ +export async function checkMapCapability( + userDID: string | null, + spaceSlug: string, + capability: keyof typeof RMAPS_PERMISSIONS.capabilities, +): Promise { + const resolved = await resolveMapSpaceRole(userDID, spaceSlug); + return hasCapability(resolved.role, capability, RMAPS_PERMISSIONS); +} + +/** + * Get the SpaceRole for an anonymous or unauthenticated user + * on an open rMaps room (no space linkage). + */ +export function getDefaultMapRole(isAuthenticated: boolean): ResolvedRole { + return { + role: SpaceRole.PARTICIPANT, + source: isAuthenticated ? 'default' : 'anonymous', + }; +} + +/** + * Invalidate cached role for a user in a space. + */ +export function invalidateMapRoleCache(userDID?: string, spaceSlug?: string): void { + if (userDID && spaceSlug) { + roleCache.delete(`${userDID}:${spaceSlug}`); + } else { + roleCache.clear(); + } +}