@jacebenson/jsn 4.3.8 → 4.3.9

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.
package/src/sdk.js CHANGED
@@ -4,6 +4,7 @@ import { gunzipSync } from 'node:zlib';
4
4
  import { errAuth, errAPI, errNetwork } from './errors.js';
5
5
  import { getStringField } from './helpers.js';
6
6
  import { getAction, getStepTypes, createAction, updateAction, testAction } from './processflow-actions.js';
7
+ import { readSkippedBaseline, revertSkippedUpdate } from './skipped-update-provider.js';
7
8
 
8
9
  const DEFAULT_TIMEOUT = 30000;
9
10
 
@@ -94,7 +95,8 @@ export class SDKClient {
94
95
  }
95
96
 
96
97
  async request(endpoint, opts = {}) {
97
- const { timeout = this.timeout, touchLastSeen = true, ...requestOptions } = opts;
98
+ const { timeout = this.timeout, readOnly = false, touchLastSeen, ...requestOptions } = opts;
99
+ if (readOnly && (requestOptions.method || 'GET').toUpperCase() !== 'GET') throw new Error('Read-only SDK requests require GET');
98
100
  const controller = new AbortController();
99
101
  const timer = setTimeout(() => controller.abort(), timeout);
100
102
 
@@ -110,6 +112,8 @@ export class SDKClient {
110
112
  if (this.domain) {
111
113
  req.headers.set('X-Now-Domain', this.domain);
112
114
  }
115
+ // readOnly guards the data request, not standard authentication bootstrap.
116
+ // OAuth refresh and session/GCK negotiation are not instance-record writes.
113
117
  await this._setAuth(req);
114
118
 
115
119
  const resp = await fetch(req);
@@ -125,7 +129,10 @@ export class SDKClient {
125
129
 
126
130
  // Stamp last_seen on successful API call
127
131
  try {
128
- if (touchLastSeen && this.authProvider && typeof this.authProvider.touchLastSeen === 'function') {
132
+ // readOnly suppresses the credential last-seen write unconditionally: a
133
+ // caller passing touchLastSeen cannot override it (GLOSSARY contract).
134
+ const shouldTouch = readOnly ? false : (touchLastSeen ?? true);
135
+ if (shouldTouch && this.authProvider && typeof this.authProvider.touchLastSeen === 'function') {
129
136
  this.authProvider.touchLastSeen(this.baseURL);
130
137
  }
131
138
  } catch { /* non-fatal */ }
@@ -197,6 +204,14 @@ export class SDKClient {
197
204
  }
198
205
  }
199
206
 
207
+ async getSkippedUpdateBaseline(sysID) {
208
+ return readSkippedBaseline(this, sysID);
209
+ }
210
+
211
+ async revertSkippedUpdate(sysID, modCount) {
212
+ return revertSkippedUpdate(this, sysID, modCount);
213
+ }
214
+
200
215
  async list(table, params = {}, opts = {}) {
201
216
  const query = new URLSearchParams(params).toString();
202
217
  const endpoint = `${this.baseURL}/api/now/table/${table}${query ? '?' + query : ''}`;
@@ -204,6 +219,22 @@ export class SDKClient {
204
219
  return result?.result || [];
205
220
  }
206
221
 
222
+ // Strict Table API seam. `list` stays best-effort for legacy callers, but a
223
+ // 2xx response without a result array is a malformed envelope, not an empty
224
+ // table. Capture paths use this so an unexpected body becomes an unavailable
225
+ // source instead of a complete-looking empty inventory.
226
+ async listStrict(table, params = {}, opts = {}) {
227
+ const query = new URLSearchParams(params).toString();
228
+ const endpoint = `${this.baseURL}/api/now/table/${table}${query ? '?' + query : ''}`;
229
+ const response = await this.request(endpoint, { method: 'GET', ...opts });
230
+ if (!response || typeof response !== 'object' || !Array.isArray(response.result)) {
231
+ const error = new Error(`Malformed table response for ${table}: expected a JSON envelope with a result array`);
232
+ error.code = 'malformed_response';
233
+ throw error;
234
+ }
235
+ return response.result;
236
+ }
237
+
207
238
  async get(table, sysID) {
208
239
  const endpoint = `${this.baseURL}/api/now/table/${table}/${sysID}`;
209
240
  const result = await this.request(endpoint, { method: 'GET' });
@@ -0,0 +1,45 @@
1
+ import { errUsage } from './errors.js';
2
+
3
+ function validateId(id) {
4
+ if (!/^[0-9a-fA-F]{32}$/.test(id || '')) throw errUsage('A literal 32-hex skipped finding sys_id is required.');
5
+ }
6
+
7
+ // Contract observed in RevertUpdateVersionAjax.revertToBaseSystem, PDI Australia.
8
+ // Keep read and write separate. No Table API state update can implement revert.
9
+ export async function readSkippedBaseline(sdk, id) {
10
+ validateId(id);
11
+ const output = await sdk.executeScript(`(function() {
12
+ var record = new GlideRecordSecure('sys_upgrade_history_log');
13
+ if (!record.get('${id}') || !record.canRead()) throw new Error('Finding unavailable');
14
+ var name = record.getValue('file_name');
15
+ var storeAppID = GlideappUpdateVersion.getStoreAppID(record);
16
+ var baseline = storeAppID != null
17
+ ? GlideappUpdateVersion.getVersion(name, storeAppID, 'sys_store_app', null)
18
+ : GlideappUpdateVersion.getVersion(name, record.upgrade_history, 'sys_upgrade_history', null);
19
+ if (!baseline || !baseline.isValidRecord()) throw new Error('No base system version found');
20
+ gs.print('JSN_SKIPPED_BASE:' + JSON.stringify({sys_id: baseline.getUniqueValue(),
21
+ payload: baseline.getValue('payload'), source: baseline.getValue('source'),
22
+ source_table: baseline.getValue('source_table')}));
23
+ })();`, 'global');
24
+ const match = output.match(/JSN_SKIPPED_BASE:(\{[^\r\n]*\})/);
25
+ if (!match) throw errUsage('Base version inspection returned no verifiable result.');
26
+ try { return JSON.parse(match[1]); } catch { throw errUsage('Base version inspection returned invalid JSON.'); }
27
+ }
28
+
29
+ export async function revertSkippedUpdate(sdk, id, modCount) {
30
+ validateId(id);
31
+ if (!/^\d+$/.test(modCount || '')) throw errUsage('Finding update count is required for safe revert.');
32
+ const output = await sdk.executeScript(`(function() {
33
+ if (!gs.hasRole('admin')) throw new Error('Admin role required');
34
+ var record = new GlideRecordSecure('sys_upgrade_history_log');
35
+ if (!record.get('${id}') || !record.canWrite()) throw new Error('Finding unavailable or not writable');
36
+ if (record.getValue('disposition') !== '4' ||
37
+ ['', 'not_reviewed'].indexOf(record.getValue('resolution_status') || '') < 0 ||
38
+ record.getValue('sys_mod_count') !== '${modCount}') throw new Error('Finding changed or is not eligible');
39
+ new RevertUpdateVersionAjax().revertToBaseSystem('${id}');
40
+ gs.print('JSN_SKIPPED_REVERT:dispatched');
41
+ })();`, 'global');
42
+ if (!output.includes('JSN_SKIPPED_REVERT:dispatched')) throw errUsage('Revert dispatch outcome is unknown; inspect the finding before retrying.');
43
+ // The processor returns no success boolean. Caller must verify disposition and
44
+ // resolution by GET; dispatch alone must never be reported as success.
45
+ }
@@ -0,0 +1,177 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { errUsage, errNotFound, AppError } from './errors.js';
3
+ import { analyzeImpact, baselineFields, isImpactField } from './impact-analysis.js';
4
+
5
+ export const SKIP_TABLE = 'sys_upgrade_history_log';
6
+ const SKIPPED = ['4', '104', '9', '10', '11'];
7
+ export const raw = value => String(value?.value ?? value ?? '');
8
+ export const unresolved = record => Boolean(record && Object.hasOwn(record, 'disposition')
9
+ && Object.hasOwn(record, 'resolution_status') && SKIPPED.includes(raw(record.disposition))
10
+ && ['', 'not_reviewed'].includes(raw(record.resolution_status)));
11
+ const FIELDS = 'sys_id,file_name,target_name,disposition,resolution_status,upgrade_history,reason,changed,sys_mod_count,sys_updated_on';
12
+ const digest = value => createHash('sha256').update(JSON.stringify(value)).digest('hex');
13
+ const validId = id => /^[0-9a-fA-F]{32}$/.test(id || '');
14
+
15
+ function validateSelection(ids, action) {
16
+ if (!Array.isArray(ids) || !ids.length || ids.length > 100 || ids.some(id => !validId(id))) {
17
+ throw errUsage('Select between 1 and 100 literal finding sys_ids.');
18
+ }
19
+ if (new Set(ids.map(id => id.toLowerCase())).size !== ids.length) throw errUsage('Duplicate finding sys_ids are not allowed.');
20
+ if (!['retain', 'revert'].includes(action)) throw errUsage('Select an explicit action: retain or revert. Recommendations are never actions.');
21
+ }
22
+
23
+ export async function reviewSkippedUpdate(sdk, id) {
24
+ if (!validId(id)) throw errUsage('A literal 32-hex finding sys_id is required.');
25
+ const finding = await sdk.get(SKIP_TABLE, id);
26
+ if (!finding) throw errNotFound('Skipped finding', id);
27
+ if (raw(finding.sys_id).toLowerCase() !== id.toLowerCase()) throw errUsage('Provider finding identity does not match selection.');
28
+ const fileName = raw(finding.file_name);
29
+ const match = fileName.match(/^([a-z][a-z0-9_]*)_([0-9a-fA-F]{32})$/);
30
+ const artifact = { update_name: fileName, name: raw(finding.target_name),
31
+ table: match?.[1] || null, sys_id: match?.[2] || null };
32
+ let current = null;
33
+ let baseline = null;
34
+ const unknown = [];
35
+ if (match) {
36
+ try { current = await sdk.get(artifact.table, artifact.sys_id); }
37
+ catch { unknown.push('Current artifact could not be read; access or availability is unknown.'); }
38
+ } else unknown.push('Artifact identity cannot be decoded from the provider update name.');
39
+ try { baseline = await sdk.getSkippedUpdateBaseline(id); }
40
+ catch { unknown.push('Base inspection is unavailable. Background-script access and version API availability are required.'); }
41
+ const dependencies = [];
42
+ if (match) {
43
+ try {
44
+ const refs = await sdk.list('sys_dictionary', new URLSearchParams({
45
+ sysparm_query: `reference=${artifact.table}^elementISNOTEMPTY^ORDERBYname^ORDERBYelement`,
46
+ sysparm_fields: 'name,element', sysparm_limit: '21', sysparm_display_value: 'false',
47
+ }));
48
+ if (refs.length > 20) unknown.push('Incoming reference discovery is truncated to 20 dictionary fields.');
49
+ for (const ref of refs.slice(0, 20)) {
50
+ const table = raw(ref.name), field = raw(ref.element);
51
+ if (!/^[a-z][a-z0-9_]*$/.test(table) || !/^[a-z][a-z0-9_]*$/.test(field)) continue;
52
+ try {
53
+ const rows = await sdk.list(table, new URLSearchParams({ sysparm_query: `${field}=${artifact.sys_id}^ORDERBYsys_id`,
54
+ sysparm_fields: 'sys_id', sysparm_limit: '11', sysparm_display_value: 'false' }));
55
+ for (const row of rows.slice(0, 10)) dependencies.push({ table, field, sys_id: raw(row.sys_id), relation: 'incoming_reference' });
56
+ if (rows.length > 10) unknown.push(`Incoming references from ${table}.${field} are truncated to 10 records.`);
57
+ } catch { unknown.push(`Incoming references from ${table}.${field} could not be read.`); }
58
+ }
59
+ } catch { unknown.push('Dictionary-backed incoming dependency discovery is unavailable.'); }
60
+ }
61
+ const impact = analyzeImpact({ artifact, current, baseline, dependencies, unknown });
62
+ const fields = baselineFields(baseline?.payload, artifact);
63
+ const fingerprint = digest({ finding, current, baseline });
64
+ return { sys_id: raw(finding.sys_id), artifact, finding: Object.fromEntries(FIELDS.split(',').map(key => [key, raw(finding[key])])),
65
+ impact, fingerprint, eligible: unresolved(finding),
66
+ // The provider's revert writes a NEW base version record whose payload is a
67
+ // copy of the version that was read here, so revert readback compares base
68
+ // CONTENT (this digest) instead of the previewed version sys_id.
69
+ baseline_payload_digest: typeof baseline?.payload === 'string' && baseline.payload
70
+ ? digest(baseline.payload) : null,
71
+ revert_supported: raw(finding.disposition) === '4' && validId(baseline?.sys_id)
72
+ && Boolean(current && raw(current.sys_id) === artifact.sys_id && fields
73
+ && Object.keys(fields).every(field => !isImpactField(field) || Object.hasOwn(current, field))) };
74
+ }
75
+
76
+ export async function prepareSkippedBatch(sdk, { ids, action, instance = '' }) {
77
+ validateSelection(ids, action);
78
+ const selected = [];
79
+ for (const id of ids) {
80
+ const review = await reviewSkippedUpdate(sdk, id);
81
+ if (!review.eligible) throw errUsage(`Finding ${id} is no longer unresolved and skipped.`);
82
+ if (action === 'revert' && !review.revert_supported) throw errUsage(`Revert is unsupported for ${id}: requires disposition 4, readable artifact and exact base version.`);
83
+ selected.push({ ...review, action, operation: action === 'retain'
84
+ ? 'Set resolution_status=reviewed_retained only; leave artifact unchanged.'
85
+ : 'Call RevertUpdateVersionAjax.revertToBaseSystem; replace customization with provider-selected base version.' });
86
+ }
87
+ if (action === 'revert') {
88
+ const includes = await sdk.list('sys_script_include', new URLSearchParams({
89
+ sysparm_query: 'name=RevertUpdateVersionAjax', sysparm_fields: 'script', sysparm_limit: '2', sysparm_display_value: 'false' }));
90
+ if (includes.length !== 1 || !/revertToBaseSystem\s*:\s*function\s*\(\s*sys_id\s*\)/.test(raw(includes[0].script))
91
+ || !raw(includes[0].script).includes('GlideappUpdateVersion.getVersion')) {
92
+ throw errUsage('Provider revert lifecycle contract is unavailable; no writes performed.');
93
+ }
94
+ }
95
+ const preview = { instance, selected, count: selected.length };
96
+ return { ...preview, confirmation_token: digest(preview), writes_performed: false };
97
+ }
98
+
99
+
100
+ export async function processSkippedBatch({ sdk, ids, action, instance = '', readOnly = false,
101
+ confirmationToken, confirm, onPreview }) {
102
+ if (readOnly) throw new AppError('read_only', 'Read-only profiles cannot process findings.');
103
+ const preview = await prepareSkippedBatch(sdk, { ids, action, instance });
104
+ // Preview precedes the sole confirmation. Never honor force or a profile's
105
+ // skip_confirmations here; this batch always requires explicit consent.
106
+ if (onPreview) await onPreview(preview);
107
+ if (confirmationToken) {
108
+ if (confirmationToken !== preview.confirmation_token) throw errUsage('Preview changed or confirmation token does not match. Review a new preview before confirming.');
109
+ } else if (confirm) {
110
+ if (!await confirm(preview)) return { preview, cancelled: true, results: [], success_count: 0, failure_count: 0, partial: false, writes_performed: false };
111
+ } else {
112
+ throw new AppError('confirmation_required', 'Review the exact batch preview before processing.',
113
+ `Run jsn skipped-updates preview --ids ${ids.join(',')} --action ${action}; review its exact selection, then process that selection with --confirm <preview-token>.`);
114
+ }
115
+ const results = [];
116
+ for (const selected of preview.selected) {
117
+ let attempted = false;
118
+ try {
119
+ const latest = await reviewSkippedUpdate(sdk, selected.sys_id);
120
+ if (!latest.eligible || latest.fingerprint !== selected.fingerprint) throw errUsage('Finding, artifact or base version changed after preview; no write attempted.');
121
+ attempted = true;
122
+ if (action === 'retain') await sdk.update(SKIP_TABLE, selected.sys_id, { resolution_status: 'reviewed_retained' });
123
+ else await sdk.revertSkippedUpdate(selected.sys_id, selected.finding.sys_mod_count);
124
+ const persisted = await sdk.get(SKIP_TABLE, selected.sys_id);
125
+ const expected = action === 'retain' ? 'reviewed_retained' : 'reviewed_reverted';
126
+ if (!persisted || raw(persisted.sys_id) !== selected.sys_id || raw(persisted.resolution_status) !== expected
127
+ || raw(persisted.disposition) !== (action === 'revert' ? '5' : selected.finding.disposition)) {
128
+ throw errUsage('Provider state readback did not verify the selected action.');
129
+ }
130
+ if (action === 'revert') {
131
+ const artifact = await sdk.get(selected.artifact.table, selected.artifact.sys_id);
132
+ const baseline = await sdk.getSkippedUpdateBaseline(selected.sys_id);
133
+ const fields = baselineFields(baseline?.payload, selected.artifact);
134
+ // RevertToBaseSystem reverts to the previewed base version and leaves a new
135
+ // base version record behind, so the sys_id legitimately changes. What must
136
+ // still match is the base CONTENT we previewed and the artifact now at base.
137
+ if (!artifact || raw(artifact.sys_id) !== selected.artifact.sys_id || !fields
138
+ || !validId(baseline?.sys_id) || typeof baseline?.payload !== 'string'
139
+ || digest(baseline.payload) !== selected.baseline_payload_digest
140
+ || Object.entries(fields).some(([field, value]) => isImpactField(field)
141
+ && (!Object.hasOwn(artifact, field) || raw(artifact[field]) !== value))) {
142
+ throw errUsage('Artifact readback did not verify the previewed base content on the reverted artifact.');
143
+ }
144
+ results.push({ sys_id: selected.sys_id, action, success: true, write_attempted: true,
145
+ resolution_status: raw(persisted.resolution_status), disposition: raw(persisted.disposition),
146
+ base_version: raw(baseline.sys_id), verified: true });
147
+ continue;
148
+ }
149
+ results.push({ sys_id: selected.sys_id, action, success: true, write_attempted: true,
150
+ resolution_status: raw(persisted.resolution_status), disposition: raw(persisted.disposition), verified: true });
151
+ } catch (err) {
152
+ results.push({ sys_id: selected.sys_id, action, success: false, write_attempted: attempted,
153
+ effect: attempted ? 'unknown' : 'unchanged', code: err.code || 'provider_error',
154
+ message: attempted ? 'Selected action or its readback failed; inspect this finding before retrying.' : err.message });
155
+ }
156
+ }
157
+ const successCount = results.filter(result => result.success).length;
158
+ return { preview, results, cancelled: false, success_count: successCount,
159
+ failure_count: results.length - successCount, partial: successCount > 0 && successCount < results.length,
160
+ writes_performed: results.some(result => result.write_attempted) };
161
+ }
162
+
163
+ export async function discoverSkippedUpdates(sdk, { limit = 100, offset = 0 } = {}) {
164
+ if (!Number.isInteger(limit) || limit < 1 || limit > 500 || !Number.isInteger(offset) || offset < 0) {
165
+ throw errUsage('Use a limit between 1 and 500 and a nonnegative integer offset.');
166
+ }
167
+ const params = new URLSearchParams({
168
+ sysparm_query: 'dispositionIN4,104,9,10,11^resolution_statusIN,not_reviewed^ORDERBYsys_id',
169
+ sysparm_fields: FIELDS, sysparm_display_value: 'all',
170
+ sysparm_limit: String(limit + 1), sysparm_offset: String(offset),
171
+ });
172
+ const rows = await sdk.list(SKIP_TABLE, params);
173
+ // Recheck locally: an instance can ignore a query field the caller cannot read.
174
+ if (rows.some(row => !unresolved(row))) throw errUsage('Provider returned a finding outside the unresolved skipped state.');
175
+ return { findings: rows.slice(0, limit), count: Math.min(rows.length, limit),
176
+ limit, offset, has_more: rows.length > limit, total: null };
177
+ }