roles.mjs file-level

at sha256:0 · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 💥 blast risk
sha256:4 fix(security): pin patched transitive deps to clear Dependabot moderate… · aaronrene · Jun 11, 2026
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 }