@jacebenson/jsn 4.3.13 → 4.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +137 -0
  2. package/package.json +4 -3
  3. package/skills/servicenow/SKILL.md +20 -172
  4. package/src/app.js +29 -40
  5. package/src/auth-recovery.js +83 -0
  6. package/src/auth.js +2 -0
  7. package/src/capabilities.js +9 -0
  8. package/src/cli-adapter.js +12 -0
  9. package/src/cli.js +45 -9
  10. package/src/command-audience.js +38 -0
  11. package/src/commands/_generic.js +45 -28
  12. package/src/commands/_simple.js +30 -33
  13. package/src/commands/_ticket.js +24 -20
  14. package/src/commands/attachments.js +88 -0
  15. package/src/commands/auth.js +94 -19
  16. package/src/commands/b4rules.js +8 -3
  17. package/src/commands/catalog-order.js +93 -0
  18. package/src/commands/catalog.js +8 -10
  19. package/src/commands/changes.js +2 -4
  20. package/src/commands/cmdb-search.js +38 -0
  21. package/src/commands/cmdb.js +2 -0
  22. package/src/commands/columns.js +2 -1
  23. package/src/commands/customizations.js +13 -7
  24. package/src/commands/discover.js +54 -0
  25. package/src/commands/docs/db.js +9 -1
  26. package/src/commands/docs/docs.js +25 -2
  27. package/src/commands/docs/ingest.js +20 -3
  28. package/src/commands/docs/install.js +86 -0
  29. package/src/commands/docs/locks.js +34 -0
  30. package/src/commands/docs/refresh.js +22 -2
  31. package/src/commands/docs/replace.js +30 -0
  32. package/src/commands/docs/snapshot-check.js +11 -0
  33. package/src/commands/docs/snapshot.js +130 -0
  34. package/src/commands/docs/sync.js +16 -6
  35. package/src/commands/forms.js +22 -97
  36. package/src/commands/history.js +57 -0
  37. package/src/commands/incidents.js +2 -4
  38. package/src/commands/knowledge.js +75 -0
  39. package/src/commands/midservers.js +117 -10
  40. package/src/commands/records.js +57 -104
  41. package/src/commands/requests.js +9 -15
  42. package/src/commands/rest.js +15 -2
  43. package/src/commands/scan.js +234 -0
  44. package/src/commands/scopes.js +1 -1
  45. package/src/commands/setup.js +46 -9
  46. package/src/commands/skill.js +73 -76
  47. package/src/commands/tasks.js +2 -4
  48. package/src/commands/tickets.js +20 -19
  49. package/src/commands/uipolicies.js +2 -27
  50. package/src/commands/updatesets.js +66 -13
  51. package/src/commands/upgrade.js +69 -0
  52. package/src/commands/version.js +14 -8
  53. package/src/constrained-query.js +20 -0
  54. package/src/context.js +6 -6
  55. package/src/customization-guidance.js +31 -0
  56. package/src/customizations-report.js +6 -0
  57. package/src/customizations.js +28 -13
  58. package/src/errors.js +23 -1
  59. package/src/essential-records.js +40 -0
  60. package/src/field-behavior.js +15 -0
  61. package/src/help.js +12 -2
  62. package/src/metadata/records.js +249 -0
  63. package/src/midserver-linux-remote.js +210 -0
  64. package/src/midserver-remote-recipe.js +49 -0
  65. package/src/midserver-remote.js +221 -0
  66. package/src/midserver-windows-remote.js +318 -0
  67. package/src/midserver-zip.js +2 -2
  68. package/src/mutation-context.js +74 -0
  69. package/src/mutation-history.js +75 -0
  70. package/src/mutations.js +3 -2
  71. package/src/output.js +34 -2
  72. package/src/record-command-profiles.js +70 -0
  73. package/src/record-profiles.js +93 -0
  74. package/src/records-engine.js +57 -0
  75. package/src/related-lists.js +153 -0
  76. package/src/resolve-record.js +50 -28
  77. package/src/scan.js +249 -0
  78. package/src/sdk-batch.js +130 -0
  79. package/src/sdk.js +157 -18
  80. package/src/update-set-xml.js +89 -0
@@ -9,7 +9,7 @@
9
9
  // resolve → mutate → report.
10
10
 
11
11
  import { assertSafeExactMatch, isHexString } from './helpers.js';
12
- import { errNotFound } from './errors.js';
12
+ import { errNotFound, errUsage, AppError, CodeAmbiguous } from './errors.js';
13
13
 
14
14
  /**
15
15
  * True when the identifier looks like a ServiceNow sys_id (32 hex chars).
@@ -25,40 +25,60 @@ export function isSysId(identifier) {
25
25
  */
26
26
  export function unwrapSysId(record) {
27
27
  const sysId = record?.sys_id;
28
- if (sysId && typeof sysId === 'object') return sysId.value || sysId.display_value || '';
28
+ if (sysId && typeof sysId === 'object') return sysId.value || '';
29
29
  return sysId || '';
30
30
  }
31
31
 
32
32
  /**
33
- * Resolve a single record by human identifier.
34
- *
35
- * @param {object} sdk — SDK handle (uses sdk.list)
36
- * @param {object} opts
37
- * @param {string} opts.table — ServiceNow table
38
- * @param {string} opts.identifier — sys_id (32 hex) or value of matchField
39
- * @param {string} opts.matchField — field to match non-sys_id identifiers on
40
- * (e.g. 'number', 'name', 'user_name')
41
- * @param {string} [opts.resource] — label for the not-found error
42
- * (e.g. 'User' → "User not found: <id>"); defaults to matchField
43
- * @param {string[]} [opts.fields] — restrict sysparm_fields when set
44
- * @returns {Promise<object>} the record (display values requested)
45
- * @throws {AppError} code not_found when no record matches
33
+ * Exact lookups precede partial sys_id lookup. Configured human identifiers keep
34
+ * their legacy precedence; identifierField explicitly disambiguates collisions.
35
+ * Each query requests at most two rows so no operation silently picks a match.
46
36
  */
47
- export async function resolveRecord(sdk, { table, identifier, matchField, resource, fields }) {
37
+ export async function resolveRecord(sdk, { table, identifier, matchField = 'sys_id', identifierField,
38
+ resource, fields, displayValue = 'all', partial = true }) {
48
39
  assertSafeExactMatch(identifier);
49
- const queryField = isSysId(identifier) ? 'sys_id' : matchField;
50
- const params = new URLSearchParams();
51
- params.set('sysparm_query', `${queryField}=${identifier}`);
52
- params.set('sysparm_limit', '1');
53
- params.set('sysparm_display_value', 'all');
54
- if (fields && fields.length > 0) {
55
- params.set('sysparm_fields', fields.join(','));
40
+ if (typeof identifier !== 'string' || !identifier.trim()) throw errUsage('An identifier is required.');
41
+ for (const value of [table, identifierField || matchField]) {
42
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(value || '')) throw errUsage('Invalid table or identifier field name.');
56
43
  }
57
- const records = await sdk.list(table, params);
58
- if (records.length === 0) {
59
- throw errNotFound(resource || matchField, identifier);
44
+ const explicit = identifierField !== undefined;
45
+ const firstField = identifierField || (isSysId(identifier) ? 'sys_id' : matchField);
46
+ const attempts = [];
47
+ async function lookup(field, operator, mode) {
48
+ const params = new URLSearchParams({
49
+ sysparm_query: `${field}${operator}${identifier}`,
50
+ sysparm_limit: '2', sysparm_display_value: displayValue,
51
+ });
52
+ if (fields?.length) params.set('sysparm_fields', [...new Set(['sys_id', ...fields])].join(','));
53
+ const records = await sdk.list(table, params);
54
+ const resolution = { table, field, mode, matches: Math.min(records.length, 2),
55
+ ...(records.length >= 2 ? { at_least: true } : {}) };
56
+ attempts.push(resolution);
57
+ if (records.length > 1) {
58
+ const error = new AppError(CodeAmbiguous, `Multiple ${resource || 'records'} found matching ${mode} ${field}: ${identifier}`,
59
+ 'Use a longer/full sys_id or select the exact human field with --identifier-field <field>.');
60
+ error.resolution = { ...resolution, attempts };
61
+ throw error;
62
+ }
63
+ return records[0];
60
64
  }
61
- return records[0];
65
+ let record = await lookup(firstField, '=', 'exact');
66
+ if (record) return record;
67
+ if (!explicit && firstField !== 'sys_id') {
68
+ record = await lookup('sys_id', '=', 'exact');
69
+ if (record) return record;
70
+ }
71
+ // An explicitly selected human field must never fall back to another field.
72
+ if (partial && (!explicit || firstField === 'sys_id')) {
73
+ record = await lookup('sys_id', 'STARTSWITH', 'prefix');
74
+ if (record) return record;
75
+ record = await lookup('sys_id', 'ENDSWITH', 'suffix');
76
+ if (record) return record;
77
+ }
78
+ const error = errNotFound(resource || matchField, identifier);
79
+ error.hint = 'No accessible record matched. Check the table/ACLs, supply a full or longer sys_id, or use --identifier-field <field> for an exact human identifier.';
80
+ error.resolution = { ...attempts.at(-1), attempts };
81
+ throw error;
62
82
  }
63
83
 
64
84
  /**
@@ -67,5 +87,7 @@ export async function resolveRecord(sdk, { table, identifier, matchField, resour
67
87
  */
68
88
  export async function resolveSysId(sdk, opts) {
69
89
  const record = await resolveRecord(sdk, { fields: ['sys_id'], ...opts });
70
- return unwrapSysId(record);
90
+ const id = unwrapSysId(record);
91
+ if (!id) throw errUsage('The matched record has no readable sys_id; check field ACLs before retrying.');
92
+ return id;
71
93
  }
package/src/scan.js ADDED
@@ -0,0 +1,249 @@
1
+ import { SaxesParser } from 'saxes';
2
+ import { getRecord, recordListParams } from './records-engine.js';
3
+ import { isSysId, unwrapSysId } from './resolve-record.js';
4
+ import { errAPI, errNotFound, errUsage } from './errors.js';
5
+
6
+ export const CHECK_TABLES = {
7
+ table: 'scan_table_check', column: 'scan_column_type_check',
8
+ linter: 'scan_linter_check', script: 'scan_script_only_check',
9
+ };
10
+ export const raw = value => value && typeof value === 'object' ? (value.value ?? '') : value;
11
+ export const active = record => [true, 'true', '1', 1].includes(raw(record?.active));
12
+ export const referenceId = value => {
13
+ const id = raw(value) || value?.link?.split('/').at(-1);
14
+ return isSysId(id) ? id : '';
15
+ };
16
+
17
+ export function requireId(value, label = 'execution') {
18
+ if (!isSysId(value)) throw errUsage(`Use a full 32-character sys_id for ${label}.`);
19
+ return value;
20
+ }
21
+
22
+ export function boundedLimit(value = 50) {
23
+ if (!Number.isInteger(value) || value < 1 || value > 200) throw errUsage('--limit must be an integer from 1 to 200.');
24
+ return value;
25
+ }
26
+
27
+ export async function scanList(sdk, table, { limit = 50, offset = 0, fields, query } = {}) {
28
+ boundedLimit(limit);
29
+ if (!Number.isInteger(offset) || offset < 0) throw errUsage('--offset must be a nonnegative integer.');
30
+ const params = recordListParams({ limit, offset, fields, query, displayValue: 'false' });
31
+ return sdk.listStrict(table, params);
32
+ }
33
+
34
+ export async function resolveScanRecord(sdk, table, identifier, matchField = 'name') {
35
+ const record = await getRecord(sdk, { table, identifier, matchField, displayValues: 'raw', resource: table });
36
+ if (table !== 'scan_check') return record;
37
+ const subtype = String(raw(record.sys_class_name) || '');
38
+ if (!Object.values(CHECK_TABLES).includes(subtype)) return record;
39
+ const concrete = await sdk.get(subtype, unwrapSysId(record));
40
+ if (!concrete || unwrapSysId(concrete) !== unwrapSysId(record)) throw errUsage('The concrete check definition is unavailable; inspect its class and permissions before changing it.');
41
+ return { ...record, ...concrete };
42
+ }
43
+
44
+ const COMMON_FIELDS = ['name', 'description', 'short_description', 'active', 'category', 'priority',
45
+ 'script', 'run_condition', 'finding_type', 'resolution_details', 'documentation_url', 'score_min', 'score_max', 'score_scale'];
46
+
47
+ export function validateScanPayload(kind, input, { create = false, suite = false, current = {} } = {}) {
48
+ if (!input || typeof input !== 'object' || Array.isArray(input)) throw errUsage('The payload must be a JSON object.');
49
+ if (!suite && !CHECK_TABLES[kind]) throw errUsage('Choose --type table, column, linter, or script.');
50
+ const fields = suite ? ['name', 'description', 'active', 'private'] : [...COMMON_FIELDS,
51
+ ...(kind === 'table' ? ['table', 'conditions', 'advanced', 'use_manifest'] : []),
52
+ ...(kind === 'column' ? ['column_type'] : [])];
53
+ for (const field of Object.keys(input)) {
54
+ if (!fields.includes(field)) throw errUsage(`Unsupported ${suite ? 'suite' : kind + ' check'} field: ${field}`);
55
+ }
56
+ const data = { ...input };
57
+ for (const field of ['active', 'private', 'advanced', 'use_manifest']) {
58
+ if (data[field] === undefined) continue;
59
+ if (![true, false, 'true', 'false'].includes(data[field])) throw errUsage(`${field} must be true or false.`);
60
+ data[field] = data[field] === true || data[field] === 'true';
61
+ }
62
+ if ((create || data.name !== undefined) && (typeof data.name !== 'string' || !data.name.trim())) throw errUsage('A nonempty name is required.');
63
+ if (data.script !== undefined && typeof data.script !== 'string') throw errUsage('script must be a string.');
64
+ if (create && data.active === undefined) data.active = false;
65
+ if (data.table !== undefined && !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(data.table)) throw errUsage('Invalid target table name.');
66
+ if (data.column_type !== undefined && !['script', 'xml', 'html'].includes(data.column_type)) throw errUsage('column_type must be script, xml, or html.');
67
+ if (!suite) {
68
+ const script = data.script ?? raw(current.script);
69
+ const advanced = data.advanced ?? active({ active: current.advanced });
70
+ if (create && kind === 'table' && !data.table) throw errUsage('Table checks require a table.');
71
+ if (create && kind === 'column' && !data.column_type) throw errUsage('Column checks require column_type.');
72
+ if ((create || Object.hasOwn(data, 'script') || Object.hasOwn(data, 'advanced'))
73
+ && (kind !== 'table' || advanced) && (typeof script !== 'string' || !script.trim())) {
74
+ throw errUsage('This check type requires a nonempty script.');
75
+ }
76
+ }
77
+ return data;
78
+ }
79
+
80
+ export function checkKind(record) {
81
+ const table = String(raw(record.sys_class_name) || '');
82
+ const kind = Object.keys(CHECK_TABLES).find(key => CHECK_TABLES[key] === table);
83
+ if (!kind) throw errUsage('Unknown or unreadable check class; inspect the check before changing it.');
84
+ return kind;
85
+ }
86
+
87
+ function decodeAjax(xml) {
88
+ let answer;
89
+ const parser = new SaxesParser();
90
+ parser.on('doctype', () => { throw errAPI(200, 'Unexpected document type in scan response.'); });
91
+ parser.on('opentag', node => { if (node.name === 'xml') answer = node.attributes.answer; });
92
+ try { parser.write(xml).close(); } catch { throw errAPI(200, 'Malformed scan processor XML response.'); }
93
+ if (typeof answer !== 'string') throw errAPI(200, 'Scan processor returned no answer; check endpoint access.');
94
+ return answer;
95
+ }
96
+
97
+ export async function scanAjax(sdk, method, parameters) {
98
+ const query = new URLSearchParams({ sysparm_processor: 'ScanAjaxProcessor', sysparm_name: method, ...parameters });
99
+ return decodeAjax(await sdk.rawRequest(`${sdk.baseURL}/xmlhttp.do?${query}`, { method: 'POST' }));
100
+ }
101
+
102
+ const TERMINAL = new Set(['successful', 'failed', 'cancelled']);
103
+ const STATES = { '0': 'pending', '1': 'running', '2': 'successful', '3': 'failed', '4': 'cancelled',
104
+ pending: 'pending', running: 'running', successful: 'successful', failed: 'failed',
105
+ cancelled: 'cancelled', canceled: 'cancelled' };
106
+
107
+ export async function scanStatus(sdk, id, { allowInitializing = false } = {}) {
108
+ requireId(id);
109
+ const tracker = await sdk.get('sys_execution_tracker', id);
110
+ if (!tracker) throw errNotFound('scan execution', id);
111
+ if (!raw(tracker.type) && allowInitializing) return { execution_id: id, status: 'pending', terminal: false,
112
+ percent_complete: null, message: 'Execution tracker is initializing', result_id: null };
113
+ if (raw(tracker.type) === 'sys.instance_scan.queue') return { execution_id: id, status: 'pending', terminal: false,
114
+ percent_complete: raw(tracker.percent_complete) ?? null, message: raw(tracker.message) || 'Scan is queued', result_id: null };
115
+ if (raw(tracker.type) !== 'instance_scan') throw errUsage('This execution tracker is not an Instance Scan.');
116
+ const state = STATES[String(raw(tracker.state)).toLowerCase()] || 'unknown';
117
+ let resultId = '';
118
+ if (raw(tracker.result)) {
119
+ try { resultId = referenceId(JSON.parse(raw(tracker.result)).result_id); } catch { /* reported as unavailable below */ }
120
+ }
121
+ return { execution_id: id, status: state, terminal: TERMINAL.has(state),
122
+ percent_complete: raw(tracker.percent_complete) ?? null,
123
+ message: raw(tracker.message) || '', result_id: resultId || null };
124
+ }
125
+
126
+ async function resolveSuite(sdk, identifier) {
127
+ const suite = await resolveScanRecord(sdk, 'scan_check_suite', identifier);
128
+ if (!active(suite)) throw errUsage('The selected suite is inactive or its active state is unreadable. Activate it explicitly first.');
129
+ return unwrapSysId(suite);
130
+ }
131
+
132
+ export async function prepareScan(sdk, argv) {
133
+ const previous = argv.previous;
134
+ const updateSet = argv.updateSet ?? argv['update-set'];
135
+ for (const value of [previous, updateSet, argv.scope, argv.record, argv.suite]) {
136
+ if (value !== undefined && (typeof value !== 'string' || !value.trim())) throw errUsage('Scan target and suite identifiers must be nonempty strings.');
137
+ }
138
+ const selectors = [updateSet, argv.scope, argv.record, previous].filter(value => value !== undefined).length;
139
+ if (selectors + Number(Boolean(argv.full)) !== 1 && !(previous && argv.full && selectors === 1)) {
140
+ throw errUsage('Choose exactly one target: --update-set, --scope, --record with --table, --full, or --previous.');
141
+ }
142
+ if (Boolean(argv.table) !== Boolean(argv.record)) throw errUsage('--record and --table must be supplied together.');
143
+ if (previous && argv.suite) throw errUsage('--previous preserves its source suite; do not combine it with --suite.');
144
+ let target;
145
+ if (previous) {
146
+ const status = await scanStatus(sdk, previous);
147
+ if (!status.terminal || !status.result_id) throw errUsage('The previous scan must have finished and have a readable result.');
148
+ const result = await sdk.get('scan_result', status.result_id);
149
+ const comboId = referenceId(result?.combo);
150
+ if (!comboId) throw errUsage('The previous scan has no reusable target combination. Choose an explicit target.');
151
+ const combo = await sdk.get('scan_combo', comboId);
152
+ if (!combo) throw errUsage('The previous scan combination is unavailable.');
153
+ if (!raw(combo.targets) && !argv.full) throw errUsage('The previous scan was instance-wide. Repeat with --full to approve that breadth.');
154
+ target = { kind: 'previous', execution_id: previous, combo_id: comboId, full: !raw(combo.targets) };
155
+ } else if (updateSet !== undefined) {
156
+ const record = await resolveScanRecord(sdk, 'sys_update_set', updateSet);
157
+ target = { kind: 'update_set', sys_id: unwrapSysId(record), name: raw(record.name),
158
+ note: 'Scans current instance records referenced by this update set, not historical XML versions. Script-only checks do not participate.' };
159
+ } else if (argv.scope !== undefined) {
160
+ const record = await resolveScanRecord(sdk, 'sys_scope', argv.scope, 'scope');
161
+ target = { kind: 'scope', sys_id: unwrapSysId(record), name: raw(record.scope) };
162
+ } else if (argv.record !== undefined) {
163
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(argv.table)) throw errUsage('Invalid target table.');
164
+ const record = await resolveScanRecord(sdk, argv.table, argv.record, 'sys_id');
165
+ target = { kind: 'record', table: argv.table, sys_id: unwrapSysId(record),
166
+ inactive: raw(record.active) === false || raw(record.active) === 'false',
167
+ note: 'Inactive target records may be skipped by the scan engine. Script-only checks do not participate.' };
168
+ } else target = { kind: 'full', note: 'Instance-wide scans can traverse many tables and take substantially longer.' };
169
+ if (!target.sys_id && !['full', 'previous'].includes(target.kind)) throw errUsage('The target has no readable sys_id.');
170
+ const suiteId = argv.suite ? await resolveSuite(sdk, argv.suite) : null;
171
+ return { target, suite_id: suiteId };
172
+ }
173
+
174
+ export async function startScan(sdk, plan) {
175
+ const { target, suite_id: suite } = plan;
176
+ let response;
177
+ if (suite && ['update_set', 'scope'].includes(target.kind)) {
178
+ const kind = target.kind === 'scope' ? 'scoped_apps' : 'update_sets';
179
+ const field = target.kind === 'scope' ? 'app_scope_sys_ids' : 'update_set_sys_ids';
180
+ const data = await sdk.request(`${sdk.baseURL}/api/sn_cicd/instance_scan/suite_scan/${requireId(suite, 'suite')}/${kind}`, {
181
+ method: 'POST', body: JSON.stringify({ [field]: [target.sys_id] }),
182
+ });
183
+ response = data?.result;
184
+ if (response?.error || !['0', '1', '2'].includes(String(response?.status))) throw errAPI(200, 'Scan dispatch was rejected by the provider.');
185
+ const executionId = response?.links?.progress?.id;
186
+ if (!isSysId(executionId)) throw errAPI(200, 'Scan dispatch returned no valid execution id. Inspect existing scans before retrying.');
187
+ return { execution_id: executionId, via: 'sn_cicd', ...plan };
188
+ }
189
+ let method, parameters;
190
+ if (target.kind === 'previous') { method = 'executeScanFromCombo'; parameters = { sysparm_combo_id: target.combo_id }; }
191
+ else if (suite && target.kind === 'full') { method = 'executeSuiteScan'; parameters = { sysparm_sys_id: suite }; }
192
+ else if (suite) {
193
+ method = 'executeSuiteScanWithTargets'; parameters = { sysparm_suite_sys_id: suite,
194
+ sysparm_target_table: target.table, sysparm_target_sys_ids: JSON.stringify([target.sys_id]) };
195
+ } else if (target.kind === 'update_set') { method = 'executeUpdateSetScan'; parameters = { sysparm_sys_id: target.sys_id }; }
196
+ else if (target.kind === 'scope') { method = 'executeAppScan'; parameters = { sysparm_scope_id: target.sys_id }; }
197
+ else if (target.kind === 'record') { method = 'executePointScan'; parameters = { sysparm_sys_id: target.sys_id, sysparm_table_name: target.table }; }
198
+ else { method = 'executeFullScan'; parameters = {}; }
199
+ try { response = JSON.parse(await scanAjax(sdk, method, parameters)); }
200
+ catch (error) { if (error.code) throw error; throw errAPI(200, 'Malformed scan dispatch answer. Inspect existing scans before retrying.'); }
201
+ if (response.status !== 0 || !isSysId(response.progressId)) throw errAPI(200, 'Scan dispatch failed or returned no valid execution id. Inspect existing scans before retrying.');
202
+ return { execution_id: response.progressId, via: 'ScanAjaxProcessor', ...plan };
203
+ }
204
+
205
+ export async function scanResults(sdk, id, limit = 50) {
206
+ boundedLimit(limit);
207
+ const status = await scanStatus(sdk, id);
208
+ const data = { ...status, checks_executed: null, findings_count: null, checks: [], findings: [],
209
+ incomplete: !status.terminal, unavailable: [], truncated: {}, rerun_full: false, reusable_target: false };
210
+ if (!status.result_id) { data.unavailable.push('scan_result'); data.incomplete = true; return data; }
211
+ let result;
212
+ try {
213
+ result = await sdk.get('scan_result', status.result_id);
214
+ if (!result) throw new Error('Result unavailable');
215
+ data.scan_number = raw(result.number);
216
+ const value = raw(result.finding_count);
217
+ const count = Number(value);
218
+ if ((typeof value === 'number' || (typeof value === 'string' && /^\d+$/.test(value))) && Number.isInteger(count) && count >= 0) data.findings_count = count;
219
+ else { data.unavailable.push('scan_result.finding_count'); data.incomplete = true; }
220
+ } catch { data.unavailable.push('scan_result'); data.incomplete = true; }
221
+ for (const [key, table, fields] of [
222
+ ['checks', 'scan_check_execution', ['check', 'message', 'execution_time']],
223
+ ['findings', 'scan_finding', ['check', 'source_table', 'source', 'finding_details', 'count']],
224
+ ]) {
225
+ try {
226
+ const rows = await sdk.listStrict(table, recordListParams({ limit: limit + 1,
227
+ query: `result=${status.result_id}^ORDERBYsys_id`, fields, displayValue: 'all' }));
228
+ data[key] = rows.slice(0, limit);
229
+ data.truncated[key] = rows.length > limit;
230
+ if (key === 'checks' && !data.truncated[key] && status.terminal) data.checks_executed = rows.length;
231
+ } catch { data.unavailable.push(table); data.incomplete = true; }
232
+ }
233
+ const comboId = referenceId(result?.combo);
234
+ if (comboId) {
235
+ try { const combo = await sdk.get('scan_combo', comboId); if (combo) { data.reusable_target = true; data.rerun_full = !raw(combo.targets); } }
236
+ catch { /* Optional rerun hint is omitted when target metadata is inaccessible. */ }
237
+ }
238
+ data.coverage_note = 'Execution success does not prove functional correctness or complete coverage. Zero findings may mean records or checks were skipped.';
239
+ return data;
240
+ }
241
+
242
+ export async function cancelScan(sdk, id) {
243
+ const status = await scanStatus(sdk, id);
244
+ if (status.terminal) return { ...status, cancellation_requested: false };
245
+ const answer = await scanAjax(sdk, 'cancelScan', { sysparm_progress_id: id });
246
+ const after = await scanStatus(sdk, id);
247
+ if (after.status !== 'cancelled' && !/cancelled successfully/i.test(answer)) throw errAPI(200, 'Cancellation was not confirmed. Check the execution status before retrying.');
248
+ return { ...after, cancellation_requested: true };
249
+ }
@@ -0,0 +1,130 @@
1
+ // Optional, read-only ServiceNow Batch REST transport. No ordinary SDK call is
2
+ // routed here implicitly; writes are deliberately outside this initial contract.
3
+ import { randomUUID } from 'node:crypto';
4
+ import { AppError, errAPI, errUsage } from './errors.js';
5
+
6
+ const UNSUPPORTED = new Set([404, 405, 501]);
7
+ function malformed(message) { return new AppError('malformed_response', `Invalid Batch API response: ${message}`); }
8
+ function errorData(error) {
9
+ return { code: error.code || 'network_error', message: error.message, ...(error.status ? { status: error.status } : {}) };
10
+ }
11
+ function normalizeRequests(sdk, requests) {
12
+ if (!Array.isArray(requests)) throw errUsage('Batch requests must be an array.');
13
+ const ids = new Set();
14
+ return requests.map((item, index) => {
15
+ if (!item || typeof item !== 'object') throw errUsage('Each batch request must be an object.');
16
+ const id = item.id == null ? String(index + 1) : String(item.id);
17
+ if (!id || ids.has(id)) throw errUsage('Batch request IDs must be nonempty and unique.');
18
+ ids.add(id);
19
+ if (String(item.method || 'GET').toUpperCase() !== 'GET' || item.body !== undefined) {
20
+ throw errUsage('SDK batch supports GET reads only; send writes through ordinary SDK methods with their existing safety and history contracts.');
21
+ }
22
+ if (typeof item.url !== 'string' || !item.url.trim()) throw errUsage('Each batch request needs a same-instance API URL.');
23
+ let url;
24
+ try { url = new URL(item.url, `${sdk.baseURL}/`); } catch { throw errUsage('Invalid batch API URL.'); }
25
+ if (url.origin !== new URL(sdk.baseURL).origin || url.username || url.password || url.hash
26
+ || !url.pathname.startsWith('/api/') || /\/batch\/?$/.test(url.pathname)) {
27
+ throw errUsage('Batch URLs must target a same-instance API endpoint; nested batches, credentials, and fragments are unsupported.');
28
+ }
29
+ const headers = new Headers(item.headers || {});
30
+ for (const name of headers.keys()) {
31
+ if (/^(authorization|cookie|x-usertoken|host|proxy-authorization)$/i.test(name)) throw errUsage('Batch subrequests cannot override authentication headers.');
32
+ }
33
+ if (!headers.has('Accept')) headers.set('Accept', 'application/json');
34
+ if (sdk.domain) headers.set('X-Now-Domain', sdk.domain);
35
+ return { id, method: 'GET', url: url.pathname + url.search, headers: [...headers].map(([name, value]) => ({ name, value })), exclude_response_headers: true };
36
+ });
37
+ }
38
+ function decodeBody(body) {
39
+ if (body == null || body === '') return null;
40
+ if (typeof body !== 'string') throw malformed('item body must be base64 text');
41
+ const encoded = body.replace(/\s/g, '');
42
+ if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(encoded)) throw malformed('item body is not valid base64');
43
+ const text = Buffer.from(encoded, 'base64').toString('utf8');
44
+ if (!text) return null;
45
+ try { return JSON.parse(text); } catch { return text; }
46
+ }
47
+ function parseBatch(response, requests, batchId) {
48
+ // ServiceNow's endpoint returns these arrays directly, not in a Table API
49
+ // result wrapper. Missing/duplicated correlations are never inferred by order.
50
+ if (!response || response.batch_request_id !== batchId || !Array.isArray(response.serviced_requests) || !Array.isArray(response.unserviced_requests)) {
51
+ throw malformed('expected matching batch_request_id and serviced/unserviced arrays');
52
+ }
53
+ const expected = new Set(requests.map(item => item.id));
54
+ const mapped = new Map();
55
+ for (const id of response.unserviced_requests) {
56
+ if (typeof id !== 'string' || !expected.has(id) || mapped.has(id)) throw malformed('unknown or duplicate unserviced ID');
57
+ mapped.set(id, { id, ok: false, serviced: false, status: null, data: null,
58
+ error: { code: 'batch_unserviced', message: 'The batch processing limit left this request unserviced; it was not retried.' } });
59
+ }
60
+ for (const item of response.serviced_requests) {
61
+ if (!item || !expected.has(item.id) || mapped.has(item.id)) throw malformed('unknown or duplicate serviced ID');
62
+ const status = item.status_code;
63
+ if (!Number.isInteger(status) || status < 100 || status > 599) throw malformed('invalid item HTTP status');
64
+ const ok = status >= 200 && status < 300;
65
+ let data;
66
+ try { data = decodeBody(item.body); } catch (error) {
67
+ mapped.set(item.id, { id: item.id, ok: false, serviced: true, status, data: null, error: errorData(error) });
68
+ continue;
69
+ }
70
+ const error = ok ? null : errorData(errAPI(status, typeof data === 'string' ? data : JSON.stringify(data || { error: item.error_message || item.status_text || 'Request failed' })));
71
+ mapped.set(item.id, { id: item.id, ok, serviced: true, status, data, error });
72
+ }
73
+ return requests.map(({ id }) => mapped.get(id) || { id, ok: false, serviced: null, status: null, data: null,
74
+ error: { code: 'batch_missing_response', message: 'No correlated response was returned; execution is unknown and was not retried.' } });
75
+ }
76
+ async function individual(sdk, request, timeout) {
77
+ try {
78
+ return await sdk._withAuthenticatedRequest(`${sdk.baseURL}${request.url}`, {
79
+ method: 'GET', headers: Object.fromEntries(request.headers.map(({ name, value }) => [name, value])),
80
+ }, { timeout, normalizeErrors: true }, async response => {
81
+ const text = await response.text();
82
+ let data = text || null;
83
+ if (text) { try { data = JSON.parse(text); } catch { /* Non-JSON REST reads remain text. */ } }
84
+ return { id: request.id, ok: response.ok, serviced: true, status: response.status, data,
85
+ error: response.ok ? null : errorData(errAPI(response.status, text)) };
86
+ });
87
+ } catch (error) {
88
+ return { id: request.id, ok: false, serviced: null, status: error.status || null, data: null, error: errorData(error) };
89
+ }
90
+ }
91
+
92
+ /**
93
+ * @param {SDKClient} sdk authenticated SDK instance
94
+ * @param {Array<{id?: string, method?: 'GET', url: string, headers?: object}>} requests
95
+ * @param {{fallback?: boolean, chunkSize?: number, timeout?: number}} options
96
+ */
97
+ export async function batchRead(sdk, requests, { fallback = true, chunkSize = 50, timeout = sdk.timeout } = {}) {
98
+ if (typeof fallback !== 'boolean') throw errUsage('Batch fallback must be a boolean.');
99
+ const normalized = normalizeRequests(sdk, requests);
100
+ if (!Number.isInteger(chunkSize) || chunkSize < 1 || chunkSize > 1000) throw errUsage('Batch chunkSize must be an integer from 1 to 1000.');
101
+ const results = [], transports = new Set();
102
+ for (let offset = 0; offset < normalized.length; offset += chunkSize) {
103
+ const chunk = normalized.slice(offset, offset + chunkSize);
104
+ let useIndividual = fallback && sdk._batchUnsupported === true;
105
+ if (!useIndividual) {
106
+ const batchId = randomUUID();
107
+ try {
108
+ const response = await sdk.request(`${sdk.baseURL}/api/now/v1/batch`, {
109
+ method: 'POST', timeout, touchLastSeen: false,
110
+ body: JSON.stringify({ batch_request_id: batchId, rest_requests: chunk }),
111
+ });
112
+ results.push(...parseBatch(response, chunk, batchId));
113
+ transports.add('batch');
114
+ } catch (error) {
115
+ // Only explicit endpoint absence permits fallback. ACL/auth, rate limits,
116
+ // malformed responses and uncertain transport failures must not replay.
117
+ if (!fallback || !UNSUPPORTED.has(error.status)) throw error;
118
+ sdk._batchUnsupported = true;
119
+ useIndividual = true;
120
+ }
121
+ }
122
+ if (useIndividual) {
123
+ transports.add('individual');
124
+ for (const request of chunk) results.push(await individual(sdk, request, timeout));
125
+ }
126
+ }
127
+ const successful = results.filter(item => item.ok).length;
128
+ return { transport: transports.size > 1 ? 'mixed' : [...transports][0] || 'none',
129
+ results, counts: { total: results.length, successful, failed: results.length - successful } };
130
+ }