diff --git a/src/lib/space-role.ts b/src/lib/space-role.ts new file mode 100644 index 0000000..9bdbe56 --- /dev/null +++ b/src/lib/space-role.ts @@ -0,0 +1,145 @@ +/** + * Space Role bridge for rNotes + * + * Bridges EncryptID session + notebook-level collaborator roles + * with the unified SpaceRole system. When a notebook is linked to + * a canvas space (via canvasSlug), cross-module membership is + * resolved from the EncryptID server. + */ + +import { prisma } from './prisma'; +import { getAuthUser, type AuthResult } from './auth'; +import { + SpaceRole, + hasCapability, + type ResolvedRole, +} from '@encryptid/sdk/types'; +import { RNOTES_PERMISSIONS } from '@encryptid/sdk/types/modules'; + +const ENCRYPTID_SERVER = process.env.ENCRYPTID_SERVER_URL || 'https://encryptid.jeffemmett.com'; + +// In-memory cache (5 minute TTL) +const roleCache = new Map(); +const CACHE_TTL = 5 * 60 * 1000; + +/** + * Map rNotes CollaboratorRole to SpaceRole. + */ +function notebookRoleToSpaceRole(role: string): SpaceRole { + switch (role) { + case 'OWNER': + return SpaceRole.ADMIN; + case 'EDITOR': + return SpaceRole.PARTICIPANT; + case 'VIEWER': + return SpaceRole.VIEWER; + default: + return SpaceRole.VIEWER; + } +} + +/** + * Resolve a user's SpaceRole for a given notebook. + * + * Resolution order: + * 1. Check local notebook collaborator role → map to SpaceRole + * 2. If notebook is linked to a canvas space (canvasSlug), check EncryptID membership + * 3. If notebook is public, default to VIEWER + * 4. Otherwise VIEWER + */ +export async function resolveNotebookSpaceRole( + userId: string, + notebookId: string, +): Promise { + const cacheKey = `${userId}:${notebookId}`; + const cached = roleCache.get(cacheKey); + if (cached && cached.expires > Date.now()) { + return cached.role; + } + + // Check local notebook collaborator role + const collab = await prisma.notebookCollaborator.findUnique({ + where: { userId_notebookId: { userId, notebookId } }, + }); + + if (collab) { + const result: ResolvedRole = { + role: notebookRoleToSpaceRole(collab.role), + source: collab.role === 'OWNER' ? 'owner' : 'membership', + }; + roleCache.set(cacheKey, { role: result, expires: Date.now() + CACHE_TTL }); + return result; + } + + // Check if notebook is linked to a canvas space + const notebook = await prisma.notebook.findUnique({ + where: { id: notebookId }, + select: { canvasSlug: true, isPublic: true }, + }); + + if (notebook?.canvasSlug) { + // Look up user's DID for cross-module membership check + const user = await prisma.user.findUnique({ where: { id: userId } }); + if (user?.did) { + try { + const url = `${ENCRYPTID_SERVER}/api/spaces/${encodeURIComponent(notebook.canvasSlug)}/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 — fall through to defaults + } + } + } + + // Default: public notebooks get VIEWER, otherwise no access + const isPublic = notebook?.isPublic ?? false; + const result: ResolvedRole = { + role: isPublic ? SpaceRole.VIEWER : SpaceRole.VIEWER, + source: 'default', + }; + roleCache.set(cacheKey, { role: result, expires: Date.now() + CACHE_TTL }); + return result; +} + +/** + * Check if a user has a specific rNotes capability on a notebook. + */ +export async function checkNotesCapability( + userId: string, + notebookId: string, + capability: keyof typeof RNOTES_PERMISSIONS.capabilities, +): Promise { + const resolved = await resolveNotebookSpaceRole(userId, notebookId); + return hasCapability(resolved.role, capability, RNOTES_PERMISSIONS); +} + +/** + * Resolve SpaceRole from a request + notebook context. + * Convenience wrapper for API routes. + */ +export async function resolveRequestSpaceRole( + request: Request, + notebookId: string, +): Promise<{ auth: AuthResult; resolved: ResolvedRole } | null> { + const auth = await getAuthUser(request); + if (!auth) return null; + + const resolved = await resolveNotebookSpaceRole(auth.user.id, notebookId); + return { auth, resolved }; +} + +/** + * Invalidate cached role for a user on a notebook. + */ +export function invalidateNotesRoleCache(userId?: string, notebookId?: string): void { + if (userId && notebookId) { + roleCache.delete(`${userId}:${notebookId}`); + } else { + roleCache.clear(); + } +}