Jason's live check after the 20:58Z restart posted no Approve button. The
Discord Sage wrote DEC-009's required_approvers as names; the SetSpark
service stores approvers as discord:<id> and accepted the names, and the
connector correctly refused the approval request ("bad approver id").
- binding.mjs derives setspark.approvers from the binding's users (name to
id); a binding-set approvers key and duplicate names are refused. With
setspark set, a user id or name change refuses the reload (pi's approvers
are fixed at start).
- setspark.mjs: record_create/record_update map required_approvers names to
discord:<id> and refuse unknown names, ids, duplicates and non-lists
before any request, without echoing the value. hideIds turns mentions,
discord: values and standalone 17-20 digit runs into the user's name or
"unknown user" in every verb's text and refusal, including the service
message and code before they are cut. The connector's approval request
keeps the bare ids.
- tests: boundary test over nested, keyed, numeric, mention and cut ids;
a local contract fixture from create through validateRequest, with the
old name-stored shape still refused.
Rocko: R1 revise, R2 revise, R3 approve (81379830..., report da75219f...).
Suites on an index export: 24/90/43/17/14/15/63/18; Discord node tests 173/173.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
375 lines
19 KiB
JavaScript
375 lines
19 KiB
JavaScript
// A binding is deployment policy for one seat on one Discord server: which
|
|
// guild, which channels in which mode, which people, which engine, which
|
|
// limits. It carries Discord IDs of real people, so it lives under the data
|
|
// root at <dataRoot>/discord/<name>.json, mode 0600, and is never committed.
|
|
// The repository holds this schema and fixtures/binding.example.json.
|
|
//
|
|
// Loading fails closed: unknown key, missing field, wrong type, bad mode,
|
|
// symlink, empty allowlist. Nothing is defaulted silently except the limits'
|
|
// documented defaults below, which the fixture spells out anyway.
|
|
//
|
|
// A user entry may carry `channels`, an allowlist of listed channel ids;
|
|
// absent means every listed channel. A running connector may re-read the
|
|
// file (`reload`): `reloadDiff` says which keys may change in place and
|
|
// refuses the rest.
|
|
//
|
|
// An optional `tools` key declares the roots for the Discord Sage's file
|
|
// tools (see tools.mjs): read-only unless a root says `write: true`.
|
|
// Absent means no tools and a launch exactly as before. It is a fixed
|
|
// key: the extension reads it at pi start.
|
|
|
|
import { existsSync, lstatSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
import { loadWebConfig } from "./web.mjs";
|
|
import { loadSetsparkConfig } from "./setspark.mjs";
|
|
import { isAbsolute, join, resolve, sep } from "node:path";
|
|
import { homedir } from "node:os";
|
|
import { DiscordError } from "./errors.mjs";
|
|
import { TOOL_DEFAULTS } from "./tools.mjs";
|
|
import { loadGitConfig } from "./git.mjs";
|
|
|
|
export const BINDING_VERSION = 1;
|
|
export const BINDING_NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
|
export const SNOWFLAKE = /^[0-9]{17,20}$/;
|
|
export const CHANNEL_MODES = Object.freeze(["open", "mention"]);
|
|
export const THINKING_LEVELS = Object.freeze(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
|
|
|
|
export const LIMIT_DEFAULTS = Object.freeze({
|
|
turnsPerDay: 200,
|
|
turnTimeoutSeconds: 180,
|
|
replyChunkChars: 1900,
|
|
inboundMaxChars: 4000,
|
|
});
|
|
|
|
const TOP_KEYS = ["bindingVersion", "name", "seat", "guildId", "guildName", "botUserId", "tokenFile", "channels", "users", "engine", "limits", "context", "tools"];
|
|
const CHANNEL_KEYS = ["id", "name", "mode"];
|
|
const USER_KEYS = ["id", "name", "channels"];
|
|
const ENGINE_KEYS = ["provider", "model", "thinking"];
|
|
const LIMIT_KEYS = Object.keys(LIMIT_DEFAULTS);
|
|
const CONTEXT_KEYS = ["files"];
|
|
const TOOLS_KEYS = ["roots", "maxFileBytes", "maxCallsPerTurn", "web", "setspark"];
|
|
const ROOT_KEYS = ["name", "path", "write", "git"];
|
|
const ROOT_NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
|
|
|
export function defaultConfigPath(env = process.env) {
|
|
return env.MOSAIC_CONFIG ? resolve(env.MOSAIC_CONFIG) : join(homedir(), ".config", "mosaic-dev", "config.json");
|
|
}
|
|
|
|
// Only dataRoot is read here; scripts/mosaic-config.mjs owns full validation.
|
|
export function loadDataRoot(path = defaultConfigPath()) {
|
|
if (!existsSync(path)) throw new DiscordError(`config not found: ${path}`);
|
|
let raw;
|
|
try {
|
|
raw = JSON.parse(readFileSync(path, "utf8"));
|
|
} catch (err) {
|
|
throw new DiscordError(`config is not valid JSON: ${path} (${err.message})`);
|
|
}
|
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new DiscordError(`config is not an object: ${path}`);
|
|
if (typeof raw.dataRoot !== "string" || !isAbsolute(raw.dataRoot)) throw new DiscordError(`config.dataRoot must be an absolute path: ${path}`);
|
|
return raw.dataRoot;
|
|
}
|
|
|
|
export function discordDir(dataRoot) {
|
|
return join(dataRoot, "discord");
|
|
}
|
|
|
|
export function bindingPath(dataRoot, name) {
|
|
if (!BINDING_NAME.test(String(name))) throw new DiscordError(`invalid binding name: ${JSON.stringify(name)}`, 4);
|
|
return join(discordDir(dataRoot), `${name}.json`);
|
|
}
|
|
|
|
export function bindingDataDir(dataRoot, name) {
|
|
if (!BINDING_NAME.test(String(name))) throw new DiscordError(`invalid binding name: ${JSON.stringify(name)}`, 4);
|
|
return join(discordDir(dataRoot), name);
|
|
}
|
|
|
|
function isObject(v) {
|
|
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
}
|
|
|
|
function onlyKeys(obj, allowed, where) {
|
|
for (const key of Object.keys(obj)) {
|
|
if (!allowed.includes(key)) throw new DiscordError(`${where}: unknown key ${JSON.stringify(key)}`);
|
|
}
|
|
}
|
|
|
|
function requireString(obj, key, where, pattern, what) {
|
|
const v = obj[key];
|
|
if (typeof v !== "string" || v.length === 0) throw new DiscordError(`${where}: ${key} must be a non-empty string`);
|
|
if (pattern && !pattern.test(v)) throw new DiscordError(`${where}: ${key} is not ${what}: ${JSON.stringify(v)}`);
|
|
return v;
|
|
}
|
|
|
|
function requireSnowflake(obj, key, where) {
|
|
return requireString(obj, key, where, SNOWFLAKE, "a Discord snowflake id");
|
|
}
|
|
|
|
function requireInteger(obj, key, where, { min, max }) {
|
|
const v = obj[key];
|
|
if (!Number.isInteger(v) || v < min || v > max) throw new DiscordError(`${where}: ${key} must be an integer in ${min}..${max}`);
|
|
return v;
|
|
}
|
|
|
|
// Validate an already-parsed object. Returns a frozen normalized binding.
|
|
export function validateBinding(raw, where = "binding") {
|
|
if (!isObject(raw)) throw new DiscordError(`${where}: not an object`);
|
|
onlyKeys(raw, TOP_KEYS, where);
|
|
if (raw.bindingVersion !== BINDING_VERSION) throw new DiscordError(`${where}: bindingVersion must be ${BINDING_VERSION}`);
|
|
const name = requireString(raw, "name", where, BINDING_NAME, "a binding name");
|
|
const seat = requireString(raw, "seat", where, BINDING_NAME, "a seat name");
|
|
const guildId = requireSnowflake(raw, "guildId", where);
|
|
const guildName = requireString(raw, "guildName", where);
|
|
const botUserId = requireSnowflake(raw, "botUserId", where);
|
|
const tokenFile = requireString(raw, "tokenFile", where);
|
|
if (!isAbsolute(tokenFile)) throw new DiscordError(`${where}: tokenFile must be an absolute path`);
|
|
|
|
if (!Array.isArray(raw.channels) || raw.channels.length === 0) throw new DiscordError(`${where}: channels must be a non-empty array`);
|
|
const channels = raw.channels.map((c, i) => {
|
|
const w = `${where}.channels[${i}]`;
|
|
if (!isObject(c)) throw new DiscordError(`${w}: not an object`);
|
|
onlyKeys(c, CHANNEL_KEYS, w);
|
|
const id = requireSnowflake(c, "id", w);
|
|
const cname = requireString(c, "name", w);
|
|
const mode = requireString(c, "mode", w);
|
|
if (!CHANNEL_MODES.includes(mode)) throw new DiscordError(`${w}: mode must be one of ${CHANNEL_MODES.join(", ")}`);
|
|
return Object.freeze({ id, name: cname, mode });
|
|
});
|
|
if (new Set(channels.map((c) => c.id)).size !== channels.length) throw new DiscordError(`${where}: duplicate channel id`);
|
|
|
|
if (!Array.isArray(raw.users) || raw.users.length === 0) throw new DiscordError(`${where}: users must be a non-empty array`);
|
|
const users = raw.users.map((u, i) => {
|
|
const w = `${where}.users[${i}]`;
|
|
if (!isObject(u)) throw new DiscordError(`${w}: not an object`);
|
|
onlyKeys(u, USER_KEYS, w);
|
|
const id = requireSnowflake(u, "id", w);
|
|
const uname = requireString(u, "name", w);
|
|
let allowed = null;
|
|
if (u.channels !== undefined) {
|
|
if (!Array.isArray(u.channels) || u.channels.length === 0) throw new DiscordError(`${w}: channels must be a non-empty array of listed channel ids`);
|
|
allowed = u.channels.map((cid, j) => {
|
|
if (typeof cid !== "string" || !SNOWFLAKE.test(cid)) throw new DiscordError(`${w}.channels[${j}]: not a Discord snowflake id`);
|
|
if (!channels.some((c) => c.id === cid)) throw new DiscordError(`${w}.channels[${j}]: ${cid} is not a listed channel`);
|
|
return cid;
|
|
});
|
|
if (new Set(allowed).size !== allowed.length) throw new DiscordError(`${w}: duplicate channel id`);
|
|
}
|
|
return Object.freeze({ id, name: uname, channels: allowed === null ? null : Object.freeze(allowed) });
|
|
});
|
|
if (new Set(users.map((u) => u.id)).size !== users.length) throw new DiscordError(`${where}: duplicate user id`);
|
|
if (users.some((u) => u.id === botUserId)) throw new DiscordError(`${where}: the bot cannot be an authorized user`);
|
|
|
|
if (!isObject(raw.engine)) throw new DiscordError(`${where}: engine must be an object`);
|
|
onlyKeys(raw.engine, ENGINE_KEYS, `${where}.engine`);
|
|
const provider = requireString(raw.engine, "provider", `${where}.engine`, /^[a-z0-9][a-z0-9._-]*$/, "a provider id");
|
|
const model = requireString(raw.engine, "model", `${where}.engine`, /^[A-Za-z0-9][A-Za-z0-9._:/-]*$/, "a model id");
|
|
const thinking = requireString(raw.engine, "thinking", `${where}.engine`);
|
|
if (!THINKING_LEVELS.includes(thinking)) throw new DiscordError(`${where}.engine: thinking must be one of ${THINKING_LEVELS.join(", ")}`);
|
|
|
|
const rawLimits = raw.limits === undefined ? {} : raw.limits;
|
|
if (!isObject(rawLimits)) throw new DiscordError(`${where}: limits must be an object`);
|
|
onlyKeys(rawLimits, LIMIT_KEYS, `${where}.limits`);
|
|
const merged = { ...LIMIT_DEFAULTS, ...rawLimits };
|
|
const limits = Object.freeze({
|
|
turnsPerDay: requireInteger(merged, "turnsPerDay", `${where}.limits`, { min: 0, max: 100000 }),
|
|
turnTimeoutSeconds: requireInteger(merged, "turnTimeoutSeconds", `${where}.limits`, { min: 5, max: 3600 }),
|
|
replyChunkChars: requireInteger(merged, "replyChunkChars", `${where}.limits`, { min: 100, max: 2000 }),
|
|
inboundMaxChars: requireInteger(merged, "inboundMaxChars", `${where}.limits`, { min: 100, max: 4000 }),
|
|
});
|
|
|
|
if (!isObject(raw.context)) throw new DiscordError(`${where}: context must be an object`);
|
|
onlyKeys(raw.context, CONTEXT_KEYS, `${where}.context`);
|
|
if (!Array.isArray(raw.context.files) || raw.context.files.length === 0) throw new DiscordError(`${where}.context: files must be a non-empty array`);
|
|
const files = raw.context.files.map((f, i) => {
|
|
if (typeof f !== "string" || f.length === 0) throw new DiscordError(`${where}.context.files[${i}]: must be a non-empty string`);
|
|
if (f.includes("\0")) throw new DiscordError(`${where}.context.files[${i}]: invalid path`);
|
|
return f;
|
|
});
|
|
|
|
let tools = null;
|
|
if (raw.tools !== undefined) {
|
|
if (!isObject(raw.tools)) throw new DiscordError(`${where}: tools must be an object`);
|
|
onlyKeys(raw.tools, TOOLS_KEYS, `${where}.tools`);
|
|
if (!Array.isArray(raw.tools.roots) || raw.tools.roots.length === 0) throw new DiscordError(`${where}.tools: roots must be a non-empty array`);
|
|
const roots = raw.tools.roots.map((r, i) => {
|
|
const w = `${where}.tools.roots[${i}]`;
|
|
if (!isObject(r)) throw new DiscordError(`${w}: not an object`);
|
|
onlyKeys(r, ROOT_KEYS, w);
|
|
const rname = requireString(r, "name", w, ROOT_NAME, "a root name");
|
|
const rpath = requireString(r, "path", w);
|
|
if (!isAbsolute(rpath) || rpath.includes("\0")) throw new DiscordError(`${w}: path must be an absolute path`);
|
|
if (rpath.split(sep).some((seg) => seg.startsWith(".") && seg.length > 0)) throw new DiscordError(`${w}: path must not have a dot-prefixed segment (${rpath})`);
|
|
if (resolve(rpath) === sep || resolve(rpath) === homedir()) throw new DiscordError(`${w}: path must not be the filesystem root or the home directory`);
|
|
if (r.write !== undefined && r.write !== true && r.write !== false) throw new DiscordError(`${w}: write must be true or false`);
|
|
let git = null;
|
|
if (r.git !== undefined) {
|
|
if (r.write !== true) throw new DiscordError(`${w}: git needs write: true`);
|
|
try {
|
|
git = loadGitConfig(r.git, `${w}.git`, null);
|
|
} catch (err) {
|
|
throw new DiscordError(err.message);
|
|
}
|
|
}
|
|
return Object.freeze({ name: rname, path: rpath, write: r.write === true, git });
|
|
});
|
|
if (new Set(roots.map((r) => r.name)).size !== roots.length) throw new DiscordError(`${where}.tools: duplicate root name`);
|
|
const mergedTools = { ...TOOL_DEFAULTS, ...raw.tools, roots };
|
|
tools = Object.freeze({
|
|
roots: Object.freeze(roots),
|
|
maxFileBytes: requireInteger(mergedTools, "maxFileBytes", `${where}.tools`, { min: 1024, max: 4 * 1024 * 1024 }),
|
|
maxCallsPerTurn: requireInteger(mergedTools, "maxCallsPerTurn", `${where}.tools`, { min: 1, max: 64 }),
|
|
web: raw.tools.web === undefined ? null : webConfig(raw.tools.web, `${where}.tools.web`),
|
|
setspark: raw.tools.setspark === undefined ? null : setsparkConfig(raw.tools.setspark, `${where}.tools.setspark`, users),
|
|
});
|
|
}
|
|
|
|
return Object.freeze({
|
|
bindingVersion: BINDING_VERSION,
|
|
name, seat, guildId, guildName, botUserId, tokenFile,
|
|
channels: Object.freeze(channels),
|
|
users: Object.freeze(users),
|
|
engine: Object.freeze({ provider, model, thinking }),
|
|
limits,
|
|
context: Object.freeze({ files: Object.freeze(files) }),
|
|
tools,
|
|
});
|
|
}
|
|
|
|
// What a running connector may take from a re-read binding, and what it may
|
|
// not: the engine and its prompt are launched once, the token is read once,
|
|
// and the journal directory is named after the binding. A change to a fixed
|
|
// key needs a stop and a start. Returns a summary of the reloadable
|
|
// differences or throws with exit 2.
|
|
export const RELOADABLE_KEYS = Object.freeze(["guildName", "channels", "users", "limits"]);
|
|
export const FIXED_KEYS = Object.freeze(["bindingVersion", "name", "seat", "guildId", "botUserId", "tokenFile", "engine", "context", "tools"]);
|
|
|
|
export function reloadDiff(current, next) {
|
|
for (const k of FIXED_KEYS) {
|
|
if (JSON.stringify(current[k]) !== JSON.stringify(next[k])) throw new DiscordError(`reload: ${k} cannot change while running; stop and start instead`);
|
|
}
|
|
const byId = (xs) => new Map(xs.map((x) => [x.id, JSON.stringify(x)]));
|
|
const listDiff = (a, b) => {
|
|
const A = byId(a);
|
|
const B = byId(b);
|
|
return Object.freeze({
|
|
added: Object.freeze([...B.keys()].filter((id) => !A.has(id))),
|
|
removed: Object.freeze([...A.keys()].filter((id) => !B.has(id))),
|
|
changed: Object.freeze([...B.keys()].filter((id) => A.has(id) && A.get(id) !== B.get(id))),
|
|
});
|
|
};
|
|
return Object.freeze({
|
|
channels: listDiff(current.channels, next.channels),
|
|
users: listDiff(current.users, next.users),
|
|
limits: Object.freeze(LIMIT_KEYS.filter((k) => current.limits[k] !== next.limits[k])),
|
|
guildName: current.guildName !== next.guildName,
|
|
});
|
|
}
|
|
|
|
// A private file: regular, not a symlink, owner-only (0600), non-empty.
|
|
export function checkPrivateFile(path, what) {
|
|
let st;
|
|
try {
|
|
st = lstatSync(path);
|
|
} catch {
|
|
throw new DiscordError(`${what} not found: ${path}`);
|
|
}
|
|
if (st.isSymbolicLink()) throw new DiscordError(`${what} must not be a symlink: ${path}`);
|
|
if (!st.isFile()) throw new DiscordError(`${what} is not a regular file: ${path}`);
|
|
const mode = st.mode & 0o777;
|
|
if (mode !== 0o600) throw new DiscordError(`${what} must be mode 0600, is ${mode.toString(8).padStart(4, "0")}: ${path}`);
|
|
if (st.size === 0) throw new DiscordError(`${what} is empty: ${path}`);
|
|
return st;
|
|
}
|
|
|
|
export function loadBinding(path) {
|
|
checkPrivateFile(path, "binding");
|
|
let raw;
|
|
try {
|
|
raw = JSON.parse(readFileSync(path, "utf8"));
|
|
} catch (err) {
|
|
throw new DiscordError(`binding is not valid JSON: ${path} (${err.message})`);
|
|
}
|
|
return validateBinding(raw, `binding ${path}`);
|
|
}
|
|
|
|
// The token is read once into memory and handed to the REST and gateway
|
|
// clients. It is never printed, journaled, or put on a command line.
|
|
export function readToken(binding) {
|
|
checkPrivateFile(binding.tokenFile, "token file");
|
|
const token = readFileSync(binding.tokenFile, "utf8").trim();
|
|
if (!/^[A-Za-z0-9._-]{20,}$/.test(token)) throw new DiscordError(`token file does not hold a bot token: ${binding.tokenFile}`);
|
|
return token;
|
|
}
|
|
|
|
// Context files are repository-relative and stay inside the repository:
|
|
// no absolute paths, no `..`, no symlinks, and the real path must sit under
|
|
// the repository's real path. The launch snapshot copies their contents into
|
|
// the model's prompt, so this is the boundary that keeps host files out of
|
|
// Discord Sage (Q14, Q16). Every file must be a regular non-empty file.
|
|
export function resolveContextFiles(binding, repo) {
|
|
const root = realpathSync(repo);
|
|
return binding.context.files.map((f) => {
|
|
if (typeof f !== "string" || f.length === 0) throw new DiscordError("context file must be a non-empty string");
|
|
if (isAbsolute(f)) throw new DiscordError(`context file must be repository-relative: ${f}`);
|
|
if (f.split(/[\\/]/).includes("..")) throw new DiscordError(`context file must not escape the repository: ${f}`);
|
|
const path = resolve(root, f);
|
|
let st;
|
|
try {
|
|
st = lstatSync(path);
|
|
} catch {
|
|
throw new DiscordError(`missing context file: ${path}`);
|
|
}
|
|
if (st.isSymbolicLink()) throw new DiscordError(`context file must not be a symlink: ${path}`);
|
|
if (!st.isFile() || st.size === 0) throw new DiscordError(`context file is not a regular non-empty file: ${path}`);
|
|
const real = realpathSync(path);
|
|
if (real !== path || !real.startsWith(root + sep)) throw new DiscordError(`context file resolves outside the repository: ${f}`);
|
|
return path;
|
|
});
|
|
}
|
|
|
|
// Tool roots must exist as real directories on this host, not symlinks, and
|
|
// must not sit inside the data root (bindings, tokens, journals) or contain
|
|
// it. Returns the resolved config the engine hands the extension.
|
|
function setsparkConfig(raw, where, users) {
|
|
if (raw !== null && typeof raw === "object" && Object.hasOwn(raw, "approvers")) throw new DiscordError(`${where}.approvers: not allowed; approvers come from users`);
|
|
const approvers = {};
|
|
for (const u of users) {
|
|
const name = u.name.trim().toLowerCase();
|
|
if (Object.hasOwn(approvers, name)) throw new DiscordError(`${where}: two users named ${name}; approver names must be distinct`);
|
|
approvers[name] = u.id;
|
|
}
|
|
try {
|
|
return loadSetsparkConfig(raw !== null && typeof raw === "object" && !Array.isArray(raw) ? { ...raw, approvers } : raw, where);
|
|
} catch (err) {
|
|
throw new DiscordError(err.message);
|
|
}
|
|
}
|
|
|
|
function webConfig(raw, where) {
|
|
try {
|
|
return loadWebConfig(raw, where);
|
|
} catch (err) {
|
|
throw new DiscordError(err.message);
|
|
}
|
|
}
|
|
|
|
export function resolveToolRoots(binding, { dataRoot }) {
|
|
if (!binding.tools) return null;
|
|
const data = existsSync(dataRoot) ? realpathSync(dataRoot) : resolve(dataRoot);
|
|
const roots = binding.tools.roots.map((r) => {
|
|
let st;
|
|
try {
|
|
st = lstatSync(r.path);
|
|
} catch {
|
|
throw new DiscordError(`tool root ${r.name} does not exist: ${r.path}`);
|
|
}
|
|
if (st.isSymbolicLink()) throw new DiscordError(`tool root ${r.name} must not be a symlink: ${r.path}`);
|
|
if (!st.isDirectory()) throw new DiscordError(`tool root ${r.name} is not a directory: ${r.path}`);
|
|
const real = realpathSync(r.path);
|
|
if (real === data || real.startsWith(data + sep) || data.startsWith(real + sep)) throw new DiscordError(`tool root ${r.name} overlaps the data root: ${r.path}`);
|
|
return { name: r.name, path: real, write: r.write, ...(r.git ? { git: { branch: r.git.branch, identity: r.git.identity, tokenFile: r.git.tokenFile, author: `${r.git.author.name} <${r.git.author.email}>`, ...(r.git.protocol ? { protocol: r.git.protocol } : {}) } } : {}) };
|
|
});
|
|
// setspark carries only the keys loadSetsparkConfig accepts; the extension
|
|
// re-validates it and adds the response cap itself.
|
|
const ss = binding.tools.setspark;
|
|
return { roots, maxFileBytes: binding.tools.maxFileBytes, maxCallsPerTurn: binding.tools.maxCallsPerTurn, ...(binding.tools.web ? { web: { searxng: binding.tools.web.searxng, maxFetchBytes: binding.tools.web.maxFetchBytes } } : {}), ...(ss ? { setspark: { baseUrl: ss.baseUrl, keyFile: ss.keyFile, principal: ss.principal, timeoutMs: ss.timeoutMs, approvers: { ...ss.approvers } } } : {}) };
|
|
}
|