@jacebenson/jsn 4.3.12 → 4.3.13-dev.100.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.
- package/README.md +55 -0
- package/package.json +2 -1
- package/skills/servicenow/SKILL.md +20 -172
- package/src/app.js +29 -40
- package/src/auth-recovery.js +83 -0
- package/src/auth.js +2 -0
- package/src/capabilities.js +9 -0
- package/src/cli-adapter.js +24 -4
- package/src/cli.js +39 -9
- package/src/command-audience.js +35 -0
- package/src/commands/_generic.js +58 -37
- package/src/commands/_simple.js +30 -33
- package/src/commands/_ticket.js +38 -31
- package/src/commands/attachments.js +88 -0
- package/src/commands/auth.js +94 -19
- package/src/commands/b4rules.js +8 -3
- package/src/commands/changes.js +2 -4
- package/src/commands/columns.js +2 -1
- package/src/commands/customizations.js +13 -7
- package/src/commands/discover.js +54 -0
- package/src/commands/docs/db.js +9 -1
- package/src/commands/docs/docs.js +25 -2
- package/src/commands/docs/ingest.js +20 -3
- package/src/commands/docs/install.js +86 -0
- package/src/commands/docs/locks.js +34 -0
- package/src/commands/docs/refresh.js +22 -2
- package/src/commands/docs/replace.js +30 -0
- package/src/commands/docs/snapshot-check.js +11 -0
- package/src/commands/docs/snapshot.js +130 -0
- package/src/commands/docs/sync.js +16 -6
- package/src/commands/forms.js +22 -97
- package/src/commands/groups.js +11 -8
- package/src/commands/history.js +57 -0
- package/src/commands/incidents.js +2 -4
- package/src/commands/midservers.js +117 -10
- package/src/commands/records.js +64 -119
- package/src/commands/requests.js +10 -16
- package/src/commands/rest.js +15 -2
- package/src/commands/scopes.js +1 -1
- package/src/commands/setup.js +46 -9
- package/src/commands/skill.js +72 -76
- package/src/commands/tasks.js +2 -4
- package/src/commands/tickets.js +31 -22
- package/src/commands/uipolicies.js +2 -27
- package/src/commands/updatesets.js +65 -13
- package/src/commands/users.js +11 -8
- package/src/commands/version.js +14 -8
- package/src/constrained-query.js +20 -0
- package/src/context.js +6 -6
- package/src/customization-guidance.js +31 -0
- package/src/customizations-report.js +6 -0
- package/src/customizations.js +28 -13
- package/src/errors.js +23 -1
- package/src/essential-records.js +40 -0
- package/src/field-behavior.js +15 -0
- package/src/help.js +10 -2
- package/src/helpers.js +44 -0
- package/src/metadata/records.js +249 -0
- package/src/midserver-linux-remote.js +210 -0
- package/src/midserver-remote-recipe.js +49 -0
- package/src/midserver-remote.js +221 -0
- package/src/midserver-windows-remote.js +318 -0
- package/src/midserver-zip.js +2 -2
- package/src/mutation-context.js +74 -0
- package/src/mutation-history.js +75 -0
- package/src/mutations.js +3 -2
- package/src/output.js +34 -2
- package/src/record-command-profiles.js +70 -0
- package/src/record-profiles.js +93 -0
- package/src/records-engine.js +57 -0
- package/src/related-lists.js +153 -0
- package/src/resolve-record.js +50 -28
- package/src/sdk-batch.js +130 -0
- package/src/sdk.js +157 -18
- package/src/update-set-xml.js +89 -0
package/src/resolve-record.js
CHANGED
|
@@ -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 ||
|
|
28
|
+
if (sysId && typeof sysId === 'object') return sysId.value || '';
|
|
29
29
|
return sysId || '';
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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,
|
|
37
|
+
export async function resolveRecord(sdk, { table, identifier, matchField = 'sys_id', identifierField,
|
|
38
|
+
resource, fields, displayValue = 'all', partial = true }) {
|
|
48
39
|
assertSafeExactMatch(identifier);
|
|
49
|
-
|
|
50
|
-
const
|
|
51
|
-
|
|
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
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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/sdk-batch.js
ADDED
|
@@ -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
|
+
}
|
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
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
if (
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
+
}
|