216 lines
11 KiB
JavaScript
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`));
|
|
}
|