// 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 { existsSync, lstatSync, readFileSync } from "node:fs"; import { basename, dirname, join } from "node:path"; import { refuse } from "./errors.mjs"; import { ACTIONS, GATED_ONLY, TOOLS, NETWORKS, SERVICES, HOST_SERVICE, GITEA_SCOPE_CATEGORIES, VIKUNJA_GRANTABLE, CREATIVE_DEFINITIONS, CREATIVE_TOOLS, CREATIVE_ACTIONS, CREATIVE_SERVICE, } from "./vocabulary.mjs"; import { requireObject, rejectUnknownKeys, requireId, requireString, requireDistinctList, readJsonFile, deepFreeze, } from "./util.mjs"; import { pluginName } from "../../plugins/src/organization.mjs"; const CONTRACT_NAME = /^[a-z0-9][a-z0-9._-]{0,60}\.md$/; const V1_KEYS = ["roleVersion", "name", "tools", "network"]; const V2_REQUIRED = ["roleVersion", "name", "title", "contract", "tools", "network", "authority", "credentials"]; const V2_KEYS = [...V2_REQUIRED, "messaging", "skills"]; const SKILL_NAME = /^[A-Za-z0-9._-]+$/; 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) && !pluginName(tool)) refuse(`unsupported 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) && !pluginName(action)) refuse(`unknown action in authority.${key}; expected an action from the role vocabulary`); 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; } // REQ-ASSIST-2. Optional: a role with no "messaging" key may message anyone and be messaged by // anyone the broker's other rules allow. With it, the broker refuses a send to a role outside // sendTo and a send from a role outside receiveFrom. Entries name role definitions, since a role // file can't know a business's instance names; "human" names the user. The tester is never a // role, so it can't be listed. function checkMessaging(messaging) { requireObject(messaging, 'role "messaging"'); rejectUnknownKeys(messaging, ["sendTo", "receiveFrom"], 'role "messaging"'); const lists = {}; for (const key of ["sendTo", "receiveFrom"]) { lists[key] = requireDistinctList(messaging[key], `role messaging.${key}`, (entry) => { requireId(entry, `role messaging.${key} entry`); if (entry === "tester") refuse(`messaging.${key} lists tester, which is never a role`); }); } return lists; } // Optional: the skills a session of this role gets, by their directory name under // /skills-enabled. "." and ".." match the pattern but name no skill directory. function checkSkills(skills) { return requireDistinctList(skills, "role skills", (name) => { if (typeof name !== "string" || !SKILL_NAME.test(name) || name === "." || name === "..") { refuse(`role skill must be a skill directory name matching ${SKILL_NAME}`); } }); } 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 (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`); out[group] = requireDistinctList(verbs, `vikunja scopes.${group}`, (verb) => { if (!allowed.includes(verb)) refuse(`vikunja verb not grantable to a role`); }, { 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}]`); // Row 210 (#1707, design D1): "host" takes no scopes; the business file names the hosts. const host = entry.service === HOST_SERVICE; rejectUnknownKeys(entry, host ? ["service"] : ["service", "scopes"], `role credentials[${i}]`); if (!host && !SERVICES.includes(entry.service) && !pluginName(entry.service)) { refuse(`role credentials[${i}].service must be one of: ${[...SERVICES, HOST_SERVICE].join(", ")}`); } if (seen.has(entry.service)) refuse(`role credentials name ${entry.service} twice`); seen.add(entry.service); if (host) return { service: HOST_SERVICE }; const scopes = pluginName(entry.service) ? requireDistinctList(entry.scopes, "plugin credential scopes", s => { if (typeof s !== "string" || !/^[a-z][a-z0-9_.:-]{0,63}$/.test(s)) refuse("invalid plugin credential scope"); }, {nonEmpty:true}) : 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}`); } 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}`); if (!/^## Authority[\t ]*\r?$/m.test(readFileSync(path, "utf8"))) { refuse(`role contract ${path} requires a ## Authority section`); } return path; } // Row 213 (C2-08, Case 2 design 4, M1): a creative role only reads and answers the human. Its file // may name no tool outside CREATIVE_TOOLS, and must carry a messaging key that names only the human, // so it can't relay through another role. Row 285 (C2-08b, mos-1309 item 5) lets it write supporting // docs: its authority is withinRole CREATIVE_ACTIONS at most, and its one credential is gitea. function checkCreative(role) { const wider = role.tools.filter((tool) => !CREATIVE_TOOLS.includes(tool)); if (wider.length > 0) refuse(`creative role ${role.name} may not name tools ${wider.join(", ")} (allowed: ${CREATIVE_TOOLS.join(", ")})`); if (role.authority.crossRole.length > 0 || role.authority.withinRole.some((a) => !CREATIVE_ACTIONS.includes(a))) { refuse(`creative role ${role.name} may hold only withinRole ${CREATIVE_ACTIONS.join(" and ")}`); } const services = role.credentials.map((c) => c.service).filter((service) => service !== CREATIVE_SERVICE); if (services.length > 0) refuse(`creative role ${role.name} may hold only the ${CREATIVE_SERVICE} credential (got ${services.join(", ")})`); if (role.messaging === null) refuse(`creative role ${role.name} requires "messaging"`); for (const key of ["sendTo", "receiveFrom"]) { if (role.messaging[key].some((entry) => entry !== "human")) { refuse(`creative role ${role.name} messaging.${key} may name only human`); } } return role; } // 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 }], // messaging: { sendTo, receiveFrom } | null, skills: [name] }. 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" must match its filename (${base}.json)`); const tools = checkTools(document.tools); if (document.roleVersion === 1) { if (CREATIVE_DEFINITIONS.includes(document.name)) refuse(`creative role ${document.name} must be version 2`); 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: [], messaging: null, skills: [], }); } for (const key of V2_REQUIRED) { if (document[key] === undefined) refuse(`role version 2 requires "${key}"`); } const role = { 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), messaging: document.messaging === undefined ? null : checkMessaging(document.messaging), skills: document.skills === undefined ? [] : checkSkills(document.skills), }; return deepFreeze(CREATIVE_DEFINITIONS.includes(role.name) ? checkCreative(role) : role); } export function loadRoleFile(file) { return validateRoleDocument(readJsonFile(file, /^[a-z0-9][a-z0-9._-]{0,63}\.json$/.test(basename(file)) ? `role file ${basename(file)}` : "role file"), file); } export function loadRole(rolesDir, name) { requireId(name, "role name"); return loadRoleFile(join(rolesDir, `${name}.json`)); }