Files

216 lines
11 KiB
JavaScript

// Role definitions, roles/<name>.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
// <dataRoot>/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:<category> or write:<category>; 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`));
}