roles.mjs
file-level
1
files
1
commits
0
hotspots
0
🧊 dead
0
💥 blast risk
| 1 | /** |
| 2 | * Phase 13 — Role store. Reads data/hub_roles.json (optional). |
| 3 | * Format: { "roles": { "provider:id": "admin" | "editor" | "viewer" } } |
| 4 | * or flat: { "github:123": "admin" }. Unknown or missing → treat as member (editor). |
| 5 | */ |
| 6 | |
| 7 | import fs from 'fs'; |
| 8 | import path from 'path'; |
| 9 | |
| 10 | const ROLES_FILE = 'hub_roles.json'; |
| 11 | const VALID_ROLES = new Set(['admin', 'editor', 'viewer', 'evaluator']); |
| 12 | |
| 13 | /** |
| 14 | * Load role map from data_dir. Returns Map<sub, role>. |
| 15 | * File format: { "roles": { "sub": "role", ... } } or { "sub": "role", ... }. |
| 16 | * @param {string} dataDir - e.g. config.data_dir |
| 17 | * @returns {Map<string, string>} |
| 18 | */ |
| 19 | export function loadRoleMap(dataDir) { |
| 20 | const map = new Map(); |
| 21 | if (!dataDir) return map; |
| 22 | const filePath = path.join(dataDir, ROLES_FILE); |
| 23 | try { |
| 24 | if (!fs.existsSync(filePath)) return map; |
| 25 | const raw = fs.readFileSync(filePath, 'utf8'); |
| 26 | const data = JSON.parse(raw); |
| 27 | const roles = data.roles != null ? data.roles : data; |
| 28 | if (typeof roles !== 'object' || roles === null) return map; |
| 29 | for (const [sub, role] of Object.entries(roles)) { |
| 30 | if (typeof sub === 'string' && VALID_ROLES.has(role)) map.set(sub, role); |
| 31 | } |
| 32 | } catch (_) { |
| 33 | // Invalid file or missing: treat as no overrides |
| 34 | } |
| 35 | return map; |
| 36 | } |
| 37 | |
| 38 | /** |
| 39 | * Get role for a user (sub). Uses provided map; if not in map, returns 'member'. |
| 40 | * 'member' is treated as editor in permission checks (backward compatibility). |
| 41 | * @param {Map<string, string>} roleMap - from loadRoleMap(data_dir) |
| 42 | * @param {string} sub - e.g. "github:123" |
| 43 | * @returns {string} - 'admin' | 'editor' | 'viewer' | 'member' |
| 44 | */ |
| 45 | export function getRole(roleMap, sub) { |
| 46 | if (!sub) return 'member'; |
| 47 | const role = roleMap.get(sub); |
| 48 | return role ?? 'member'; |
| 49 | } |
| 50 | |
| 51 | /** |
| 52 | * Read current roles as a plain object (for API GET). |
| 53 | * @param {string} dataDir |
| 54 | * @returns {{ [sub: string]: string }} |
| 55 | */ |
| 56 | export function readRolesObject(dataDir) { |
| 57 | const map = loadRoleMap(dataDir); |
| 58 | return Object.fromEntries(map); |
| 59 | } |
| 60 | |
| 61 | /** |
| 62 | * When `hub_roles.json` had **no** rows before this write, force the acting user to `admin` in |
| 63 | * the same payload. Otherwise the first saved row makes `roleMap` non-empty and any user not |
| 64 | * listed becomes **editor** — the operator who added the first teammate loses admin/Team |
| 65 | * immediately (POST /api/v1/roles then fails with FORBIDDEN). |
| 66 | * |
| 67 | * @param {number} roleMapSizeBefore - `loadRoleMap(dataDir).size` before applying the request |
| 68 | * @param {{ [sub: string]: string }} rolesWithNewRow - object to persist (includes the POSTed row) |
| 69 | * @param {string} actorSub - `req.user.sub` (JWT `provider:id`) |
| 70 | * @returns {{ [sub: string]: string }} |
| 71 | */ |
| 72 | export function ensureActorAdminOnFirstRolesPopulation(roleMapSizeBefore, rolesWithNewRow, actorSub) { |
| 73 | if (roleMapSizeBefore !== 0 || typeof actorSub !== 'string' || !actorSub.trim()) { |
| 74 | return rolesWithNewRow; |
| 75 | } |
| 76 | return { ...rolesWithNewRow, [actorSub.trim()]: 'admin' }; |
| 77 | } |
| 78 | |
| 79 | /** |
| 80 | * Write roles to data/hub_roles.json. Overwrites the file. |
| 81 | * @param {string} dataDir |
| 82 | * @param {{ [sub: string]: string }} roles - e.g. { "github:123": "admin" } |
| 83 | */ |
| 84 | export function writeRolesFile(dataDir, roles) { |
| 85 | if (!dataDir) throw new Error('data_dir required'); |
| 86 | const filePath = path.join(dataDir, ROLES_FILE); |
| 87 | const obj = {}; |
| 88 | for (const [sub, role] of Object.entries(roles)) { |
| 89 | if (typeof sub === 'string' && sub.trim() && VALID_ROLES.has(role)) obj[sub.trim()] = role; |
| 90 | } |
| 91 | fs.writeFileSync(filePath, JSON.stringify({ roles: obj }, null, 2), 'utf8'); |
| 92 | } |