feat: add SpaceRole bridge for cross-module membership sync

Anonymous-first role resolution (PARTICIPANT default for open rooms).
Queries EncryptID server for space-linked rooms with 5-minute cache.
Capability checks for add_markers, share_location, configure_map.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-17 14:32:26 -07:00
parent b26547bd83
commit 97e9922da2
1 changed files with 106 additions and 0 deletions

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

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