@jacebenson/jsn 4.3.13 → 4.4.0-dev.113.1

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
package/src/sdk.js CHANGED
@@ -1,7 +1,8 @@
1
1
  // ServiceNow REST API client
2
2
 
3
+ import { batchRead } from './sdk-batch.js';
3
4
  import { gunzipSync } from 'node:zlib';
4
- import { errAuth, errAPI, errNetwork } from './errors.js';
5
+ import { AppError, CodeAPI, CodeAuth, errAuth, errAPI, errNetwork, errUsage } from './errors.js';
5
6
  import { getStringField } from './helpers.js';
6
7
  import { getAction, getStepTypes, createAction, updateAction, testAction } from './processflow-actions.js';
7
8
  import { readSkippedBaseline, revertSkippedUpdate } from './skipped-update-provider.js';
@@ -47,6 +48,8 @@ export class SDKClient {
47
48
  constructor(baseURL, authProvider, opts = {}) {
48
49
  this.baseURL = baseURL.replace(/\/$/, '');
49
50
  this.authProvider = authProvider;
51
+ this.recovery = opts.recovery;
52
+ this.onMutation = opts.onMutation;
50
53
  this.timeout = opts.timeout || DEFAULT_TIMEOUT;
51
54
  this.domain = opts.domain || '';
52
55
  this.oauthCookies = null;
@@ -58,7 +61,13 @@ export class SDKClient {
58
61
  if (!this.authProvider) {
59
62
  throw errAuth('No authentication configured');
60
63
  }
61
- const creds = await this.authProvider.getCredentials();
64
+ let creds;
65
+ try {
66
+ creds = await this.authProvider.getCredentials();
67
+ } catch (error) {
68
+ if (error.code === CodeAuth && this.recovery?.credentialError) throw this.recovery.credentialError(error);
69
+ throw error;
70
+ }
62
71
  if (!creds) {
63
72
  throw errAuth('No valid credentials');
64
73
  }
@@ -97,26 +106,85 @@ export class SDKClient {
97
106
 
98
107
  // Keep the timeout alive through body consumption for text/JSON requests.
99
108
  // Raw-response callers retain their existing timeout boundary at headers.
100
- async _withAuthenticatedRequest(endpoint, opts, {
109
+ async _withAuthenticatedRequest(endpoint, opts, settings, consume = (response) => response) {
110
+ const url = new URL(endpoint);
111
+ const match = url.pathname.match(/^\/api\/now\/table\/([^/]+)(?:\/([^/]+))?\/?$/);
112
+ const operation = { POST: 'create', PUT: 'update', PATCH: 'update', DELETE: 'delete' }[(opts.method || 'GET').toUpperCase()];
113
+ const event = match && operation && this.onMutation ? {
114
+ table: decodeURIComponent(match[1]), sys_id: match[2] ? decodeURIComponent(match[2]) : null, operation,
115
+ } : null;
116
+ let result;
117
+ try {
118
+ result = await this._performAuthenticatedRequest(endpoint, opts, settings, async response => {
119
+ if (event) event.http_status = response.status;
120
+ const value = await consume(response);
121
+ if (event && value instanceof Response && response.ok && operation === 'create') {
122
+ try { event.sys_id = (await response.clone().json())?.result?.sys_id || null; } catch { /* ID remains unknown. */ }
123
+ }
124
+ return value;
125
+ });
126
+ } catch (error) {
127
+ if (event) await this._reportMutation({ ...event, outcome: error.status >= 400 && error.status < 500 && error.status !== 408
128
+ ? 'failed' : 'unknown', http_status: error.status || null });
129
+ throw error;
130
+ }
131
+ if (event) {
132
+ let body = result;
133
+ if (typeof body === 'string') { try { body = JSON.parse(body); } catch { body = null; } }
134
+ const id = body?.result?.sys_id;
135
+ if (typeof id === 'string') event.sys_id = id;
136
+ else if (typeof id?.value === 'string') event.sys_id = id.value;
137
+ await this._reportMutation({ ...event, outcome: event.http_status >= 400
138
+ ? (event.http_status < 500 && event.http_status !== 408 ? 'failed' : 'unknown')
139
+ : (event.sys_id ? 'succeeded' : 'unknown') });
140
+ }
141
+ return result;
142
+ }
143
+
144
+ async _reportMutation(event) {
145
+ // History failures must never change the outcome of a completed remote write.
146
+ try { await this.onMutation(event); } catch { /* App callback reports local storage failures. */ }
147
+ }
148
+
149
+ async _performAuthenticatedRequest(endpoint, opts, {
101
150
  timeout = this.timeout, prepare, skipOAuthSession = false, normalizeErrors = false,
102
151
  }, consume = (response) => response) {
103
- const controller = new AbortController();
104
- const timer = setTimeout(() => controller.abort(), timeout);
105
- try {
106
- const req = new Request(endpoint, { ...opts, signal: controller.signal });
107
- if (prepare) prepare(req);
108
- await this._setAuth(req, { skipOAuthSession });
109
- return await consume(await fetch(req));
110
- } catch (err) {
111
- if (normalizeErrors) {
112
- if (err.name === 'AbortError') throw errNetwork(new Error('Request timed out'));
113
- if (err.code === 'ECONNREFUSED' || err.code === 'ENOTFOUND' || err.code === 'ETIMEDOUT') {
114
- throw errNetwork(err);
152
+ for (let attempt = 0; attempt < 2; attempt++) {
153
+ const controller = new AbortController();
154
+ const timer = setTimeout(() => controller.abort(), timeout);
155
+ let rejection;
156
+ try {
157
+ const req = new Request(endpoint, { ...opts, signal: controller.signal });
158
+ if (prepare) prepare(req);
159
+ await this._setAuth(req, { skipOAuthSession });
160
+ const response = await fetch(req);
161
+ if (!skipOAuthSession && response.status === 401 && this.recovery) {
162
+ // Only a definitive HTTP rejection permits replay, including POST.
163
+ // Do not consume/echo the provider's potentially sensitive body.
164
+ rejection = { status: response.status, method: req.method, url: req.url };
165
+ await response.body?.cancel();
166
+ } else {
167
+ return await consume(response);
168
+ }
169
+ } catch (err) {
170
+ if (normalizeErrors) {
171
+ if (err.name === 'AbortError') throw errNetwork(new Error('Request timed out'));
172
+ if (err.code === 'ECONNREFUSED' || err.code === 'ENOTFOUND' || err.code === 'ETIMEDOUT') {
173
+ throw errNetwork(err);
174
+ }
115
175
  }
176
+ throw err;
177
+ } finally {
178
+ clearTimeout(timer);
116
179
  }
117
- throw err;
118
- } finally {
119
- clearTimeout(timer);
180
+ // Browser renewal has its own existing hard timeout. Never leave a
181
+ // request timeout running while waiting for the human to authenticate.
182
+ if (attempt === 0 && await this.recovery.recover(rejection)) {
183
+ this.oauthCookies = null;
184
+ this.sessionUserToken = '';
185
+ continue;
186
+ }
187
+ throw this.recovery.error || errAPI(401, '');
120
188
  }
121
189
  }
122
190
 
@@ -174,6 +242,11 @@ export class SDKClient {
174
242
  });
175
243
  }
176
244
 
245
+ // Explicit opt-in; ordinary SDK methods remain unbatched.
246
+ async batch(requests, options = {}) {
247
+ return batchRead(this, requests, options);
248
+ }
249
+
177
250
  async getSkippedUpdateBaseline(sysID) {
178
251
  return readSkippedBaseline(this, sysID);
179
252
  }
@@ -695,6 +768,72 @@ export class SDKClient {
695
768
  });
696
769
  }
697
770
 
771
+ /** Load through the native XML upload processor, then verify the exact envelope. */
772
+ async importUpdateSet(xml, inspection, filename = 'update-set.xml') {
773
+ const { sys_id: id, remote_sys_id: sourceID } = inspection;
774
+ const params = new URLSearchParams({
775
+ sysparm_query: `sys_id=${id}^ORremote_sys_id=${sourceID}`,
776
+ sysparm_fields: 'sys_id,name,state', sysparm_limit: '2', sysparm_display_value: 'false',
777
+ });
778
+ if ((await this.list('sys_remote_update_set', params)).length) {
779
+ throw errUsage('This retrieved update set already exists on the target; refusing to overwrite it. Inspect it before retrying.');
780
+ }
781
+ // Native INSERT_OR_UPDATE must not move/overwrite a member belonging to another set.
782
+ for (let offset = 0; offset < inspection.members.length; offset += 50) {
783
+ const ids = inspection.members.slice(offset, offset + 50).map(member => member.sys_id).join(',');
784
+ const existing = await this.list('sys_update_xml', new URLSearchParams({
785
+ sysparm_query: `sys_idIN${ids}`, sysparm_fields: 'sys_id', sysparm_limit: '1',
786
+ }));
787
+ if (existing.length) throw errUsage('An XML member already exists on the target; refusing to overwrite or move existing updates.');
788
+ }
789
+ const cookies = await this._warmSession();
790
+ const formURL = `${this.baseURL}/upload.do?sysparm_target=sys_remote_update_set`;
791
+ const html = await this.rawRequest(formURL, { method: 'GET', headers: cookies ? { Cookie: cookies } : {} });
792
+ const csrf = html.match(/name=["']sysparm_ck["'][^>]*value=["']([^"']+)["']/i)?.[1];
793
+ if (!csrf || !/action=["']\/sys_upload\.do["']/i.test(html) || !/name=["']attachFile["']/i.test(html)) {
794
+ throw errUsage('The native XML upload form is unavailable; no XML was uploaded. Check admin access and session authentication.');
795
+ }
796
+ const form = new FormData();
797
+ form.set('sysparm_ck', csrf);
798
+ form.set('sysparm_target', 'sys_remote_update_set');
799
+ form.set('sysparm_upload_prefix', '');
800
+ form.set('sysparm_referring_url', 'sys_remote_update_set_list.do');
801
+ form.set('attachFile', new Blob([xml], { type: 'application/xml' }), filename);
802
+ let outcome = 'unknown';
803
+ try {
804
+ await this.rawRequest(`${this.baseURL}/sys_upload.do`, { method: 'POST', body: form,
805
+ headers: { ...(cookies ? { Cookie: cookies } : {}), 'X-UserToken': csrf, Referer: formURL },
806
+ });
807
+ const records = await this.list('sys_remote_update_set', params);
808
+ if (records.length !== 1) throw new Error('Could not identify exactly one uploaded retrieved update set');
809
+ const record = records[0];
810
+ const importedID = getStringField(record, 'sys_id');
811
+ const state = getStringField(record, 'state');
812
+ if (state !== 'loaded' || getStringField(record, 'name') !== inspection.name) throw new Error('Uploaded update set name/state did not match the loaded export');
813
+ const members = [];
814
+ for (let offset = 0; ; offset += 500) {
815
+ const page = await this.list('sys_update_xml', new URLSearchParams({
816
+ sysparm_query: `remote_update_set=${importedID}^ORDERBYsys_id`, sysparm_limit: '500',
817
+ sysparm_offset: String(offset), sysparm_display_value: 'false', sysparm_fields: 'sys_id,name,action',
818
+ }));
819
+ members.push(...page);
820
+ if (members.length > inspection.count) throw new Error('Uploaded update set contains unexpected extra members');
821
+ if (page.length < 500) break;
822
+ }
823
+ const actual = new Map(members.map(member => [getStringField(member, 'sys_id'), member]));
824
+ if (actual.size !== inspection.count || inspection.members.some(expected => {
825
+ const member = actual.get(expected.sys_id);
826
+ return !member || getStringField(member, 'name') !== expected.name || getStringField(member, 'action') !== expected.action;
827
+ })) throw new Error('Uploaded update set members did not match the XML export');
828
+ outcome = 'succeeded';
829
+ return { sys_id: importedID, state, count: members.length, link: `${this.baseURL}/sys_remote_update_set.do?sys_id=${importedID}` };
830
+ } catch (error) {
831
+ throw new AppError(CodeAPI, `XML import could not be verified: ${error.message}. It may have partially loaded; inspect Retrieved Update Sets before retrying. No automatic retry was attempted.`, 'Inspect Retrieved Update Sets before retrying.', 0, error);
832
+ } finally {
833
+ if (this.onMutation) await this._reportMutation({ table: 'sys_remote_update_set', sys_id: id, operation: 'create', outcome });
834
+ }
835
+ }
836
+
698
837
  /**
699
838
  * Execute a background script on the ServiceNow instance via sys.scripts.do.
700
839
  * Uses a 3-step session-establishment flow compatible with OAuth tokens:
@@ -0,0 +1,89 @@
1
+ // Parse the export envelope without evaluating its payload or resolving entities.
2
+ import { SaxesParser } from 'saxes';
3
+ import { errUsage } from './errors.js';
4
+
5
+ export const MAX_UPDATE_SET_BYTES = 50 * 1024 * 1024;
6
+ const ID = /^[a-f0-9]{32}$/i;
7
+
8
+ export function updateSetFilename(name) {
9
+ const stem = String(name).normalize('NFKC').replace(/[^a-z0-9._-]+/gi, '-').replace(/^[.-]+|[.-]+$/g, '').slice(0, 180);
10
+ return `${stem || 'update-set'}.xml`;
11
+ }
12
+
13
+ export function inspectUpdateSetXML(xml) {
14
+ if (Buffer.byteLength(xml, 'utf8') > MAX_UPDATE_SET_BYTES) throw errUsage('Update set XML exceeds the 50 MiB limit');
15
+ const parser = new SaxesParser();
16
+ const stack = [], sets = [], updates = [];
17
+ let record;
18
+ parser.on('doctype', () => { throw errUsage('DOCTYPE and external entities are not allowed in update set XML'); });
19
+ parser.on('opentag', tag => {
20
+ stack.push(tag.name);
21
+ if (stack.length === 1 && tag.name !== 'unload') throw errUsage('Expected an update set export with an <unload> root');
22
+ if (stack.length === 2) {
23
+ if (!['sys_remote_update_set', 'sys_update_xml'].includes(tag.name) || tag.attributes.action !== 'INSERT_OR_UPDATE') {
24
+ throw errUsage('Only INSERT_OR_UPDATE sys_remote_update_set and sys_update_xml envelope records may be imported');
25
+ }
26
+ record = {};
27
+ }
28
+ if (stack.length === 3) {
29
+ if (Object.hasOwn(record, tag.name)) throw errUsage(`Duplicate XML field: ${tag.name}`);
30
+ record[tag.name] = '';
31
+ }
32
+ if (stack.length > 3) throw errUsage('Update payloads must be XML text or CDATA, not nested envelope elements');
33
+ });
34
+ const text = value => { if (stack.length === 3) record[stack[2]] += value; };
35
+ parser.on('text', text); parser.on('cdata', text);
36
+ parser.on('closetag', () => {
37
+ if (stack.length === 2) (stack[1] === 'sys_remote_update_set' ? sets : updates).push(record);
38
+ stack.pop();
39
+ });
40
+ try { parser.write(xml).close(); } catch (error) { throw errUsage(`Invalid update set XML: ${error.message}`); }
41
+ if (sets.length !== 1) throw errUsage('Import exactly one retrieved update set per XML file');
42
+ const set = sets[0];
43
+ if (!ID.test(set.sys_id || '') || !ID.test(set.remote_sys_id || '') || !set.name?.trim()) {
44
+ throw errUsage('Update set requires a name, sys_id, and remote_sys_id');
45
+ }
46
+ if (set.sys_class_name && set.sys_class_name !== 'sys_remote_update_set') throw errUsage('Invalid retrieved update set class');
47
+ if (set.state !== 'loaded' || set.update_set?.trim()) throw errUsage('Expected a loaded, uncommitted retrieved update set export');
48
+ const seen = new Set();
49
+ const summary = new Map(), payloadOperations = new Map();
50
+ for (const update of updates) {
51
+ if (!ID.test(update.sys_id || '') || seen.has(update.sys_id.toLowerCase()) || update.remote_update_set !== set.sys_id
52
+ || update.update_set?.trim() || !update.name || !update.payload?.trim()
53
+ || !['INSERT_OR_UPDATE', 'DELETE'].includes(update.action)) {
54
+ throw errUsage('Invalid, duplicate, or unrelated update XML member');
55
+ }
56
+ if (update.sys_class_name && update.sys_class_name !== 'sys_update_xml') throw errUsage('Invalid update XML class');
57
+ seen.add(update.sys_id.toLowerCase());
58
+ // Payload XML stays inert; parse it solely to describe the affected table.
59
+ const payload = new SaxesParser(); let table = '', depth = 0, primary = '';
60
+ payload.on('doctype', () => { throw errUsage('DOCTYPE is not allowed in update payloads'); });
61
+ payload.on('opentag', tag => {
62
+ depth++;
63
+ if (depth === 1 && tag.name !== 'record_update') throw errUsage('Expected a record_update payload');
64
+ if (depth === 1) table = tag.attributes.table || '';
65
+ if (depth === 2) {
66
+ if (!primary) {
67
+ primary = tag.name;
68
+ if (table && table !== primary) throw errUsage('Payload table does not match its primary record');
69
+ table = primary;
70
+ if (tag.attributes.action && tag.attributes.action !== update.action) throw errUsage('Payload action does not match the update member action');
71
+ }
72
+ const action = tag.attributes.action || 'platform_default';
73
+ if (!['INSERT_OR_UPDATE', 'INSERT', 'UPDATE', 'DELETE', 'delete_multiple', 'platform_default'].includes(action)) throw errUsage('Unsupported payload record action');
74
+ const key = `${tag.name}:${action}`;
75
+ const operation = payloadOperations.get(key) || { table: tag.name, action, count: 0 };
76
+ operation.count++; payloadOperations.set(key, operation);
77
+ }
78
+ });
79
+ payload.on('closetag', () => { depth--; });
80
+ try { payload.write(update.payload).close(); } catch (error) { throw errUsage(`Invalid update payload: ${error.message}`); }
81
+ if (!primary || !/^[a-z][a-z0-9_]*$/i.test(table)) throw errUsage(`Missing or invalid payload table for ${update.name}`);
82
+ const key = `${table}:${update.action}`;
83
+ const row = summary.get(key) || { table, action: update.action, count: 0 };
84
+ row.count++; summary.set(key, row);
85
+ }
86
+ return { name: set.name, sys_id: set.sys_id, remote_sys_id: set.remote_sys_id,
87
+ state: set.state, count: updates.length, summary: [...summary.values()], payload_operations: [...payloadOperations.values()],
88
+ members: updates.map(update => ({ sys_id: update.sys_id, name: update.name, target_name: update.target_name || '', action: update.action })) };
89
+ }