/** * Lightweight arg validation for PearData RPCs. */ export const SCHEMA_VERSION = 1 /** * @typedef {{ ok: true, args: object } | { ok: false, error: string }} ValidateResult */ /** * @param {string} method * @param {object} args * @returns {ValidateResult} */ export function validateMethodArgs(method, args = {}) { if (args == null || typeof args !== 'object' || Array.isArray(args)) { return { ok: false, error: 'Arguments must be a plain object' } } switch (method) { case 'handshake': case 'ping': case 'getServerInfo': case 'getAuthStatus': case 'getNodeInfo': case 'getHealth': case 'listContexts': case 'listCharts': case 'listAnomalies': case 'listAlerts': case 'listJobs': case 'listPeers': case 'getWeights': case 'getHostSnapshot': case 'investigateHost': case 'getDbInfo': case 'listPeerLinks': case 'listChildPeers': case 'getFleetHealth': case 'getStorageInfo': case 'getRetentionConfig': return { ok: true, args } case 'hotMetrics': { const limit = args.limit == null ? 15 : Number(args.limit) if (!Number.isFinite(limit) || limit < 1 || limit > 50) { return { ok: false, error: 'limit must be 1..50' } } const window = args.window == null ? 120 : Number(args.window) if (!Number.isFinite(window) || window < 20 || window > 600) { return { ok: false, error: 'window must be 20..600' } } return { ok: true, args: { ...args, limit, window, family: args.family != null ? String(args.family) : '', }, } } case 'relatedCharts': { const chart = String(args.chart || args.id || '').trim() if (!chart) return { ok: false, error: 'chart or id is required' } const limit = args.limit == null ? 12 : Number(args.limit) if (!Number.isFinite(limit) || limit < 1 || limit > 40) { return { ok: false, error: 'limit must be 1..40' } } return { ok: true, args: { ...args, chart, id: chart, limit } } } case 'compareChartWindows': { const chart = String(args.chart || args.id || '').trim() if (!chart) return { ok: false, error: 'chart or id is required' } const points = args.points == null ? 60 : Number(args.points) if (!Number.isFinite(points) || points < 1 || points > 300) { return { ok: false, error: 'points must be 1..300' } } return { ok: true, args: { ...args, chart, id: chart, points } } } case 'summarizeCharts': { let charts = args.charts if (typeof charts === 'string') { charts = charts.split(/[\s,]+/).filter(Boolean) } if (!Array.isArray(charts) || !charts.length) { return { ok: false, error: 'charts array required' } } charts = charts.map(String).slice(0, 12) const points = args.points == null ? 60 : Number(args.points) if (!Number.isFinite(points) || points < 1 || points > 120) { return { ok: false, error: 'points must be 1..120' } } return { ok: true, args: { ...args, charts, points, after: args.after != null ? Number(args.after) : -120, }, } } case 'searchCharts': { const limit = args.limit == null ? 30 : Number(args.limit) if (!Number.isFinite(limit) || limit < 1 || limit > 100) { return { ok: false, error: 'limit must be 1..100' } } return { ok: true, args: { ...args, q: args.q != null ? String(args.q) : '', limit, }, } } case 'summarizeChart': { const chart = String(args.chart || args.id || '').trim() if (!chart) return { ok: false, error: 'chart or id is required' } const points = args.points == null ? 90 : Number(args.points) if (!Number.isFinite(points) || points < 1 || points > 600) { return { ok: false, error: 'points must be 1..600' } } return { ok: true, args: { ...args, chart, id: chart, points, after: args.after != null ? Number(args.after) : -Math.min(points, 300), group: String(args.group || 'average'), }, } } case 'queryLogs': { const source = String(args.source || 'journal').toLowerCase() if (!['anomaly', 'audit', 'journal'].includes(source)) { return { ok: false, error: 'source must be anomaly|audit|journal' } } const limit = args.limit == null ? 200 : Number(args.limit) if (!Number.isFinite(limit) || limit < 1 || limit > 2000) { return { ok: false, error: 'limit must be 1..2000' } } return { ok: true, args: { ...args, source, limit, q: args.q != null ? String(args.q) : '', priority: args.priority != null ? String(args.priority) : '', unit: args.unit != null ? String(args.unit) : '', cursor: args.cursor != null ? Number(args.cursor) : 0, }, } } case 'listProcesses': { const sort = String(args.sort || 'cpu').toLowerCase() const allowedSort = [ 'cpu', 'rss', 'pid', 'name', 'io', 'threads', 'age', 'fds', 'state', ] if (!allowedSort.includes(sort)) { return { ok: false, error: `sort must be ${allowedSort.join('|')}` } } const order = String(args.order || 'desc').toLowerCase() === 'asc' ? 'asc' : 'desc' const filter = String(args.filter || 'all').toLowerCase() const allowedFilter = [ 'all', 'running', 'sleeping', 'zombie', 'stopped', 'highcpu', 'highmem', 'hasio', 'kernel', 'user', ] if (!allowedFilter.includes(filter)) { return { ok: false, error: `filter must be ${allowedFilter.join('|')}` } } const limit = args.limit == null ? 250 : Number(args.limit) if (!Number.isFinite(limit) || limit < 1 || limit > 2000) { return { ok: false, error: 'limit must be 1..2000' } } const offset = args.offset == null ? 0 : Number(args.offset) if (!Number.isFinite(offset) || offset < 0) { return { ok: false, error: 'offset must be ≥ 0' } } const pid = args.pid == null || args.pid === '' ? null : Number(args.pid) if (pid != null && (!Number.isFinite(pid) || pid < 1)) { return { ok: false, error: 'pid must be a positive number' } } return { ok: true, args: { ...args, sort, order, filter, limit: Math.floor(limit), offset: Math.floor(offset), q: args.q != null ? String(args.q) : '', pid, }, } } case 'setDisplayName': { const name = String(args.name ?? '').trim() if (!name) return { ok: false, error: 'name is required' } if (name.length > 40) return { ok: false, error: 'name must be ≤ 40 characters' } return { ok: true, args: { ...args, name } } } case 'getContext': case 'getChart': case 'getAlert': { const id = String(args.id || args.chart || args.context || '').trim() if (!id) return { ok: false, error: 'id is required' } return { ok: true, args: { ...args, id } } } case 'queryData': { const chart = String(args.chart || args.context || '').trim() if (!chart) return { ok: false, error: 'chart or context is required' } const points = args.points == null ? 60 : Number(args.points) if (!Number.isFinite(points) || points < 1 || points > 10_000) { return { ok: false, error: 'points must be 1..10000' } } return { ok: true, args: { ...args, chart, points, after: args.after != null ? Number(args.after) : -points, before: args.before != null ? Number(args.before) : 0, group: String(args.group || 'average'), format: String(args.format || 'json'), }, } } case 'subscribeMetrics': { const charts = Array.isArray(args.charts) ? args.charts.map(String) : args.chart ? [String(args.chart)] : ['*'] const intervalMs = Math.max(500, Number(args.intervalMs) || 1000) return { ok: true, args: { ...args, charts, intervalMs } } } case 'unsubscribeMetrics': case 'unsubscribeAnomalies': return { ok: true, args } case 'subscribeAnomalies': return { ok: true, args } case 'setAlertConfig': { const id = String(args.id || '').trim() if (!id) return { ok: false, error: 'id is required' } return { ok: true, args: { ...args, id } } } case 'ackAlert': case 'silenceAlert': { const id = String(args.id || '').trim() if (!id) return { ok: false, error: 'id is required' } return { ok: true, args: { ...args, id } } } case 'runJob': { const name = String(args.name || args.job || '').trim() if (!name) return { ok: false, error: 'name is required' } return { ok: true, args: { ...args, name } } } case 'cancelJob': { const id = String(args.id || '').trim() if (!id) return { ok: false, error: 'id is required' } return { ok: true, args: { ...args, id } } } case 'mintInvite': { const role = String(args.role || 'operator').toLowerCase() if (!['viewer', 'operator', 'admin'].includes(role)) { return { ok: false, error: 'role must be viewer|operator|admin' } } return { ok: true, args: { ...args, role } } } case 'revokePeer': { const peerId = String(args.peerId || '').toLowerCase() if (!/^[0-9a-f]{64}$/.test(peerId)) { return { ok: false, error: 'peerId must be 64 hex characters' } } return { ok: true, args: { ...args, peerId } } } case 'getAllMetrics': { const format = String(args.format || 'json').toLowerCase() if (!['json', 'prometheus', 'shell'].includes(format)) { return { ok: false, error: 'format must be json|prometheus|shell' } } return { ok: true, args: { ...args, format } } } case 'exportSnapshot': return { ok: true, args } case 'setRetentionConfig': { const patch = args && typeof args === 'object' ? { ...args } : {} delete patch.success return { ok: true, args: patch } } case 'pruneHistory': { return { ok: true, args: { dryRun: Boolean(args?.dryRun), beforeMs: args?.beforeMs != null ? Number(args.beforeMs) : undefined, force: Boolean(args?.force), // undefined = default on; false disables clearUnlinked+compact gc: args?.gc === false ? false : args?.gc === true ? true : undefined, }, } } case 'linkPeer': { const remotePublicKey = String(args.remotePublicKey || args.peerId || '').toLowerCase() if (!/^[0-9a-f]{64}$/.test(remotePublicKey)) { return { ok: false, error: 'remotePublicKey must be 64 hex characters' } } const syncMode = String(args.syncMode || 'both') if (!['push', 'pull', 'both'].includes(syncMode)) { return { ok: false, error: 'syncMode must be push|pull|both' } } return { ok: true, args: { ...args, remotePublicKey, syncMode } } } case 'unlinkPeer': { const remotePublicKey = String(args.remotePublicKey || args.peerId || '').toLowerCase() if (!/^[0-9a-f]{64}$/.test(remotePublicKey)) { return { ok: false, error: 'remotePublicKey must be 64 hex characters' } } return { ok: true, args: { ...args, remotePublicKey } } } default: return { ok: true, args } } }