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