// Role definitions, roles/.json. Version 2 (REQ-ROLE-1) adds a // contract, an authority map over the closed vocabulary and the // credentials the role needs, and keeps version 1's tool and network // ceilings. Version 1 files still load: they carry no authority, so every // vocabulary action is gated for them. import { lstatSync } from "node:fs"; import { basename, dirname, join } from "node:path"; import { refuse } from "./errors.mjs"; import { ACTIONS, GATED_ONLY, TOOLS, NETWORKS, SERVICES, GITEA_SCOPE_CATEGORIES, VIKUNJA_GRANTABLE, } from "./vocabulary.mjs"; import { requireObject, rejectUnknownKeys, requireId, requireString, requireDistinctList, readJsonFile, deepFreeze, } from "./util.mjs"; const CONTRACT_NAME = /^[a-z0-9][a-z0-9._-]{0,60}\.md$/; const V1_KEYS = ["roleVersion", "name", "tools", "network"]; const V2_KEYS = ["roleVersion", "name", "title", "contract", "tools", "network", "authority", "credentials"]; function checkTools(tools) { if (!Array.isArray(tools) || tools.length === 0) refuse('role "tools" must be a non-empty array of tool names'); return requireDistinctList(tools, "role tools", (tool) => { if (!TOOLS.includes(tool)) refuse(`unsupported tool: ${JSON.stringify(tool)} (supported: ${TOOLS.join(", ")})`); }); } function checkNetwork(network) { if (!NETWORKS.includes(network)) refuse(`role "network" must be one of: ${NETWORKS.join(", ")}`); return network; } function checkAuthority(authority) { requireObject(authority, 'role "authority"'); rejectUnknownKeys(authority, ["withinRole", "crossRole"], 'role "authority"'); const lists = {}; for (const key of ["withinRole", "crossRole"]) { lists[key] = requireDistinctList(authority[key], `role authority.${key}`, (action) => { if (!ACTIONS.includes(action)) refuse(`unknown action in authority.${key}: ${JSON.stringify(action)}`); if (GATED_ONLY.includes(action)) refuse(`authority.${key} lists ${action}, which is always gated`); }); } const both = lists.withinRole.filter((a) => lists.crossRole.includes(a)); if (both.length > 0) refuse(`action listed as both withinRole and crossRole: ${both.join(", ")}`); return lists; } function checkGiteaScopes(scopes) { const list = requireDistinctList(scopes, "gitea scopes", (scope) => { const m = typeof scope === "string" ? /^(read|write):([a-z]+)$/.exec(scope) : null; if (!m || !GITEA_SCOPE_CATEGORIES.includes(m[2])) { refuse(`unsupported gitea scope: ${JSON.stringify(scope)} (expected read: or write:; categories: ${GITEA_SCOPE_CATEGORIES.join(", ")})`); } }, { nonEmpty: true }); const categories = list.map((s) => s.split(":")[1]); const twice = categories.filter((c, i) => categories.indexOf(c) !== i); if (twice.length > 0) refuse(`gitea scopes name ${twice[0]} twice; a token holds one level per category`); return list; } function checkVikunjaScopes(scopes) { requireObject(scopes, "vikunja scopes"); if (Object.keys(scopes).length === 0) refuse("vikunja scopes must name at least one route group"); const out = {}; for (const [group, verbs] of Object.entries(scopes)) { const allowed = VIKUNJA_GRANTABLE[group]; if (!allowed) refuse(`vikunja route group not grantable to a role: ${JSON.stringify(group)}`); out[group] = requireDistinctList(verbs, `vikunja scopes.${group}`, (verb) => { if (!allowed.includes(verb)) refuse(`vikunja verb not grantable to a role: ${group}.${JSON.stringify(verb)}`); }, { nonEmpty: true }); } return out; } function checkCredentials(credentials) { if (!Array.isArray(credentials)) refuse('role "credentials" must be an array'); const seen = new Set(); return credentials.map((entry, i) => { requireObject(entry, `role credentials[${i}]`); rejectUnknownKeys(entry, ["service", "scopes"], `role credentials[${i}]`); if (!SERVICES.includes(entry.service)) refuse(`role credentials[${i}].service must be one of: ${SERVICES.join(", ")}`); if (seen.has(entry.service)) refuse(`role credentials name ${entry.service} twice`); seen.add(entry.service); const scopes = entry.service === "gitea" ? checkGiteaScopes(entry.scopes) : checkVikunjaScopes(entry.scopes); return { service: entry.service, scopes }; }); } function checkContract(contract, file) { if (typeof contract !== "string" || !CONTRACT_NAME.test(contract)) { refuse(`role "contract" must be a Markdown file name in the role's directory, matching ${CONTRACT_NAME} (got ${JSON.stringify(contract)})`); } const path = join(dirname(file), contract); let stat; try { stat = lstatSync(path); } catch { refuse(`role contract not found: ${path}`); } if (!stat.isFile() || stat.isSymbolicLink() || stat.size === 0) refuse(`role contract must be a non-empty regular file: ${path}`); return path; } // Validate a parsed role document read from `file`. Returns a frozen role: // { roleVersion, name, title, contract, contractPath, tools, network, // authority: { withinRole, crossRole }, credentials: [{ service, scopes }] }. export function validateRoleDocument(document, file) { requireObject(document, "role"); if (document.roleVersion !== 1 && document.roleVersion !== 2) refuse('role "roleVersion" must be 1 or 2'); rejectUnknownKeys(document, document.roleVersion === 1 ? V1_KEYS : V2_KEYS, "role"); requireId(document.name, "role name"); const base = basename(file).replace(/\.json$/, ""); if (document.name !== base) refuse(`role "name" (${document.name}) must match its filename (${base}.json)`); const tools = checkTools(document.tools); if (document.roleVersion === 1) { const network = document.network === undefined ? "none" : checkNetwork(document.network); return deepFreeze({ roleVersion: 1, name: document.name, title: null, contract: null, contractPath: null, tools, network, authority: { withinRole: [], crossRole: [] }, credentials: [], }); } for (const key of V2_KEYS) { if (document[key] === undefined) refuse(`role version 2 requires "${key}"`); } return deepFreeze({ roleVersion: 2, name: document.name, title: requireString(document.title, 'role "title"', { max: 80 }), contract: document.contract, contractPath: checkContract(document.contract, file), tools, network: checkNetwork(document.network), authority: checkAuthority(document.authority), credentials: checkCredentials(document.credentials), }); } export function loadRoleFile(file) { return validateRoleDocument(readJsonFile(file, "role file"), file); } export function loadRole(rolesDir, name) { requireId(name, "role name"); return loadRoleFile(join(rolesDir, `${name}.json`)); }