1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
// Composition parameters and exports (both hosts; Rust: rust/host/src/params.rs, same words).
// parameters: [{ key, label?, type?, required?, description?, secret?, minimum?, maximum?, values?: { default?, enum? } }]
// type: a module.json field type (mcp FIELD_TYPES), default "string". values holds the JSON values (an object, so
// the record stays a lexicon `unknown`). A secret has no default and comes from `secrets`, never `inputs`.
// instance input: { "$param": "<key>" } and { "$host": "<name>" } anywhere in it; bindParameters fills them.
// exports: [{ nsid, instance, entry }]: the entry that serves a contract.
const TYPES = ["string", "integer", "number", "boolean", "object", "array"];
const KEY = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;
const NSID = /^[a-zA-Z][a-zA-Z0-9-]*(\.[a-zA-Z0-9-]+){2,}$/;
const ENTRY = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;
const ABSENT = Symbol("absent");
const plain = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
const holder = (v) => plain(v) && Object.keys(v).length === 1 && (typeof v.$param === "string" || typeof v.$host === "string");
const typed = (type, v) =>
type === "string" ? typeof v === "string"
: type === "integer" ? Number.isInteger(v)
: type === "number" ? typeof v === "number" && Number.isFinite(v)
: type === "boolean" ? typeof v === "boolean"
: type === "array" ? Array.isArray(v)
: plain(v);
/** Why `v` is not a value of parameter `p`, or undefined. */
function wrong(p, v) {
const type = p.type ?? "string";
if (!typed(type, v)) return `must be of type ${type}`;
const choices = p.values?.enum;
if (choices && !choices.some((c) => JSON.stringify(c) === JSON.stringify(v))) return `must be one of ${JSON.stringify(choices)}`;
if (typeof v === "number" && p.minimum !== undefined && v < p.minimum) return `must be at least ${p.minimum}`;
if (typeof v === "number" && p.maximum !== undefined && v > p.maximum) return `must be at most ${p.maximum}`;
return undefined;
}
function holders(value, fn) {
if (holder(value)) return fn(value);
if (Array.isArray(value)) value.forEach((v) => holders(v, fn));
else if (plain(value)) Object.values(value).forEach((v) => holders(v, fn));
}
/** validateComposition's part for parameters, placeholders and exports. */
export function validateParameters(manifest, fail) {
if (manifest.parameters !== undefined && !Array.isArray(manifest.parameters)) fail("parameters must be a list");
const keys = new Set();
for (const p of manifest.parameters ?? []) {
if (!KEY.test(p?.key ?? "") || keys.has(p.key)) fail(`parameter key ${p?.key} must be unique and match ${KEY}`);
keys.add(p.key);
const type = p.type ?? "string";
if (!TYPES.includes(type)) fail(`parameter ${p.key}: type ${type} is not one of ${TYPES.join(", ")}`);
for (const k of ["minimum", "maximum"]) if (p[k] !== undefined && !Number.isInteger(p[k])) fail(`parameter ${p.key}: ${k} must be an integer`);
if (p.values !== undefined && !plain(p.values)) fail(`parameter ${p.key}: values must be an object`);
const values = p.values ?? {};
if (values.enum !== undefined && !(Array.isArray(values.enum) && values.enum.length)) fail(`parameter ${p.key}: values.enum must be a non-empty list`);
if (p.secret === true && "default" in values) fail(`parameter ${p.key}: a secret has no default`);
for (const v of [...(values.enum ?? []), ...("default" in values ? [values.default] : [])]) {
const why = wrong(p, v);
if (why) fail(`parameter ${p.key}: ${JSON.stringify(v)} ${why}`);
}
}
for (const inst of manifest.instances) {
holders(inst.input, (h) => {
if (h.$param !== undefined && !keys.has(h.$param)) fail(`instance ${inst.name}: $param ${h.$param} is not a parameter`);
if (h.$host !== undefined && !KEY.test(h.$host)) fail(`instance ${inst.name}: $host ${h.$host} must match ${KEY}`);
});
}
if (manifest.exports !== undefined && !Array.isArray(manifest.exports)) fail("exports must be a list");
const instances = new Set(manifest.instances.map((i) => i.name));
const nsids = new Set();
for (const e of manifest.exports ?? []) {
if (!NSID.test(e?.nsid ?? "") || nsids.has(e.nsid)) fail(`export ${e?.nsid} must be a unique NSID`);
nsids.add(e.nsid);
if (!instances.has(e.instance) || !ENTRY.test(e.entry ?? "")) fail(`export ${e.nsid}: instance must be an instance and entry an export name`);
}
}
/**
* An instance's input against its module's config (module.json `config`, fields as in actions): every key one
* of its fields, of its type (a $param's parameter of that type; a $host value is the host's); a required field
* given. A module without config takes no input.
*/
export function checkInput(inst, mod, parameters, fail) {
const input = inst.input ?? {};
if (!plain(input)) fail(`instance ${inst.name}: input must be an object`);
const fields = new Map((mod.config ?? []).map((f) => [f.key, f]));
for (const [key, v] of Object.entries(input)) {
const field = fields.get(key);
if (!field) fail(`instance ${inst.name}: input ${key} is not in ${inst.module}'s config${fields.size ? ` (${[...fields.keys()].join(", ")})` : ""}`);
const type = field.type ?? "string";
if (holder(v)) {
const p = v.$param !== undefined && (parameters ?? []).find((x) => x.key === v.$param);
if (p && (p.type ?? "string") !== type) fail(`instance ${inst.name}: input ${key} is ${type}, but parameter ${p.key} is ${p.type ?? "string"}`);
} else if (!typed(type, v)) fail(`instance ${inst.name}: input ${key} must be of type ${type}`);
}
for (const f of fields.values()) if (f.required === true && !Object.hasOwn(input, f.key)) fail(`instance ${inst.name}: input ${f.key} is required by ${inst.module}'s config`);
}
/**
* The manifest with its placeholders filled: `inputs` (parameter values), `secrets` (secret parameters' values),
* `host` (values only the host gives). Missing values take their default; a required one without either fails;
* an optional one without is dropped (null in a list). Throws "composition: ..." on the first problem.
*/
export function bindParameters(manifest, { inputs = {}, secrets = {}, host = {} } = {}) {
const fail = (message) => {
throw new Error(`composition: ${message}`);
};
const declared = new Map((manifest.parameters ?? []).map((p) => [p.key, p]));
for (const k of Object.keys(inputs)) {
if (!declared.has(k)) fail(`parameter ${k} is not declared`);
if (declared.get(k).secret === true) fail(`parameter ${k} is a secret: give it in secrets, not inputs`);
}
for (const k of Object.keys(secrets)) if (declared.get(k)?.secret !== true) fail(`secret ${k} is not a secret parameter`);
const values = new Map();
for (const p of declared.values()) {
const given = p.secret === true ? secrets : inputs;
const v = Object.hasOwn(given, p.key) ? given[p.key] : p.values?.default;
if (v === undefined) {
if (p.required === true) fail(`parameter ${p.key} is required`);
continue;
}
const why = wrong(p, v);
if (why) fail(`parameter ${p.key} ${why}`);
values.set(p.key, v);
}
const swap = (value) => {
if (holder(value)) {
if (value.$host !== undefined) return Object.hasOwn(host, value.$host) ? host[value.$host] : fail(`host value ${value.$host} is not given by this host`);
return values.has(value.$param) ? values.get(value.$param) : ABSENT;
}
if (Array.isArray(value)) return value.map((v) => ((v = swap(v)) === ABSENT ? null : v));
if (plain(value)) return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, swap(v)]).filter(([, v]) => v !== ABSENT));
return value;
};
const instances = manifest.instances.map((inst) => {
if (inst.input === undefined) return inst;
const input = swap(inst.input);
const { input: _, ...rest } = inst;
return input === ABSENT ? rest : { ...inst, input };
});
return { ...manifest, instances };
}
/** runCompose's guard: a placeholder still in an instance's input fails the run. */
export function checkBound(manifest) {
for (const inst of manifest.instances) {
holders(inst.input, (h) => {
throw new Error(`composition: ${h.$param !== undefined ? `parameter ${h.$param}` : `host value ${h.$host}`} is not bound`);
});
}
}
/** The instance and entry that serve contract `nsid`, or undefined. */
export const exportOf = (manifest, nsid) => (manifest.exports ?? []).find((e) => e.nsid === nsid);