/** * Phase 13 — Role store. Reads data/hub_roles.json (optional). * Format: { "roles": { "provider:id": "admin" | "editor" | "viewer" } } * or flat: { "github:123": "admin" }. Unknown or missing → treat as member (editor). */ import fs from 'fs'; import path from 'path'; const ROLES_FILE = 'hub_roles.json'; const VALID_ROLES = new Set(['admin', 'editor', 'viewer', 'evaluator']); /** * Load role map from data_dir. Returns Map. * File format: { "roles": { "sub": "role", ... } } or { "sub": "role", ... }. * @param {string} dataDir - e.g. config.data_dir * @returns {Map} */ export function loadRoleMap(dataDir) { const map = new Map(); if (!dataDir) return map; const filePath = path.join(dataDir, ROLES_FILE); try { if (!fs.existsSync(filePath)) return map; const raw = fs.readFileSync(filePath, 'utf8'); const data = JSON.parse(raw); const roles = data.roles != null ? data.roles : data; if (typeof roles !== 'object' || roles === null) return map; for (const [sub, role] of Object.entries(roles)) { if (typeof sub === 'string' && VALID_ROLES.has(role)) map.set(sub, role); } } catch (_) { // Invalid file or missing: treat as no overrides } return map; } /** * Get role for a user (sub). Uses provided map; if not in map, returns 'member'. * 'member' is treated as editor in permission checks (backward compatibility). * @param {Map} roleMap - from loadRoleMap(data_dir) * @param {string} sub - e.g. "github:123" * @returns {string} - 'admin' | 'editor' | 'viewer' | 'member' */ export function getRole(roleMap, sub) { if (!sub) return 'member'; const role = roleMap.get(sub); return role ?? 'member'; } /** * Read current roles as a plain object (for API GET). * @param {string} dataDir * @returns {{ [sub: string]: string }} */ export function readRolesObject(dataDir) { const map = loadRoleMap(dataDir); return Object.fromEntries(map); } /** * When `hub_roles.json` had **no** rows before this write, force the acting user to `admin` in * the same payload. Otherwise the first saved row makes `roleMap` non-empty and any user not * listed becomes **editor** — the operator who added the first teammate loses admin/Team * immediately (POST /api/v1/roles then fails with FORBIDDEN). * * @param {number} roleMapSizeBefore - `loadRoleMap(dataDir).size` before applying the request * @param {{ [sub: string]: string }} rolesWithNewRow - object to persist (includes the POSTed row) * @param {string} actorSub - `req.user.sub` (JWT `provider:id`) * @returns {{ [sub: string]: string }} */ export function ensureActorAdminOnFirstRolesPopulation(roleMapSizeBefore, rolesWithNewRow, actorSub) { if (roleMapSizeBefore !== 0 || typeof actorSub !== 'string' || !actorSub.trim()) { return rolesWithNewRow; } return { ...rolesWithNewRow, [actorSub.trim()]: 'admin' }; } /** * Write roles to data/hub_roles.json. Overwrites the file. * @param {string} dataDir * @param {{ [sub: string]: string }} roles - e.g. { "github:123": "admin" } */ export function writeRolesFile(dataDir, roles) { if (!dataDir) throw new Error('data_dir required'); const filePath = path.join(dataDir, ROLES_FILE); const obj = {}; for (const [sub, role] of Object.entries(roles)) { if (typeof sub === 'string' && sub.trim() && VALID_ROLES.has(role)) obj[sub.trim()] = role; } fs.writeFileSync(filePath, JSON.stringify({ roles: obj }, null, 2), 'utf8'); }