feat: add SpaceRole bridge for cross-module membership sync

Maps notebook collaborator roles (OWNER/EDITOR/VIEWER) to SpaceRoles
(ADMIN/PARTICIPANT/VIEWER). Falls back to EncryptID server for
cross-space membership when notebook is linked via canvasSlug.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-17 14:32:17 -07:00
parent 4e8d12f593
commit 5e21e0fa5c
1 changed files with 145 additions and 0 deletions

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

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