All files / lib role-cache.ts

100% Statements 24/24
100% Branches 19/19
100% Functions 10/10
100% Lines 21/21

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136                                            6x                       16x                               38x 38x                       4x 4x                       13x   14x                     9x   10x                                   17x 17x   17x 24x 20x   16x 16x 14x 14x       17x                   19x    
// Role caching layer for user tenant roles
// Reduces 1-2 D1 queries per protected request to 0 on cache hit
 
import type { ProRole } from "@interioring/utils/constants/roles";
import type { UserTenantRole } from "../db/schema";
import type { DualCache } from "./cache";
import { CACHE_TTL } from "./cache";
 
type RolesDalLike = {
	findByUserId(userId: string): Promise<UserTenantRole[]>;
};
 
/**
 * Relative privilege of each pro role, keyed by the canonical `PRO_ROLES`
 * union from `@interioring/utils/constants/roles` — `Record<ProRole, number>`
 * stops compiling if a role is added to the enum without being ranked here, so
 * the order cannot drift from the schema.
 *
 * It exists because `user_tenant_roles` allowed duplicate grants: two rows for
 * the same (user, pro) meant the effective role was whichever row D1 returned
 * first. Ranking makes it deterministic — the most privileged active row wins.
 */
const PRO_ROLE_PRIVILEGE: Record<ProRole, number> = {
	owner: 3,
	manager: 2,
	staff: 1,
};
 
/**
 * Rank of a stored role string. An unrecognised value ranks below every known
 * role but still above "no role at all", so unexpected data can never silently
 * escalate a member and never silently locks one out.
 */
function proRolePrivilege(role: string): number {
	return PRO_ROLE_PRIVILEGE[role as ProRole] ?? 0;
}
 
/**
 * Is this grant still active?
 *
 * `user_tenant_roles.is_active` is an INTEGER column declared with Drizzle's
 * `mode: "boolean"` (NOT NULL, default 1), so a row read through the DAL
 * arrives as a real boolean and survives the L2 cache's JSON round-trip as
 * one. A row reaching us from a raw-SQL path still carries 0/1, so both shapes
 * are treated the same: present and falsy (`false`, `0`, `null`) means
 * deactivated. A missing field means the payload does not carry the column at
 * all — an older cache entry, a partial fixture — and stays active, which is
 * how `getProRoleFromRoles` has always read it.
 */
function isRoleActive(role: Pick<UserTenantRole, "isActive">): boolean {
	const isActive = role.isActive as boolean | number | null | undefined;
	return isActive === undefined || Boolean(isActive);
}
 
/**
 * Get all roles for a user from cache or DB.
 * Fetches ALL roles in 1 query and caches the result.
 */
export async function getCachedUserRoles(
	cache: DualCache,
	rolesDal: RolesDalLike,
	userId: string,
): Promise<UserTenantRole[]> {
	const cacheKey = `roles:${userId}`;
	return cache.getOrSet(cacheKey, () => rolesDal.findByUserId(userId), {
		l1Ttl: CACHE_TTL.ROLES_L1,
		l2Ttl: CACHE_TTL.ROLES_L2,
	});
}
 
/**
 * Check if user is a platform admin from cached roles.
 * A deactivated grant confers nothing — `is_active = 0` is how an admin is
 * revoked without deleting the audit row.
 */
export function isPlatformAdminFromRoles(roles: UserTenantRole[]): boolean {
	return roles.some(
		(r) =>
			r.tenantType === "platform" &&
			(r.role === "admin" || r.role === "super_admin") &&
			isRoleActive(r),
	);
}
 
/**
 * Check if user is a super admin from cached roles.
 * Deactivated grants are ignored, as in `isPlatformAdminFromRoles`.
 */
export function isSuperAdminFromRoles(roles: UserTenantRole[]): boolean {
	return roles.some(
		(r) =>
			r.tenantType === "platform" &&
			r.role === "super_admin" &&
			isRoleActive(r),
	);
}
 
/**
 * Get pro role for a user from cached roles.
 *
 * Returns the highest-privilege ACTIVE grant for that pro (owner > manager >
 * staff). Picking by rank rather than by row order keeps the answer stable
 * when the table holds more than one grant for the pair; ties keep the first
 * matching row, so a single-grant user sees no change.
 */
export function getProRoleFromRoles(
	roles: UserTenantRole[],
	proId: string,
): string | null {
	let bestRole: string | null = null;
	let bestPrivilege = -1;
 
	for (const r of roles) {
		if (r.tenantType !== "pro" || r.tenantId !== proId) continue;
		if (!isRoleActive(r)) continue;
 
		const privilege = proRolePrivilege(r.role);
		if (privilege > bestPrivilege) {
			bestPrivilege = privilege;
			bestRole = r.role;
		}
	}
 
	return bestRole;
}
 
/**
 * Invalidate cached roles for a user. Call after any role mutation.
 */
export async function invalidateUserRoles(
	cache: DualCache,
	userId: string,
): Promise<void> {
	await cache.delete(`roles:${userId}`);
}