@tenonhq/dovetail-servicenow 0.0.21 → 0.0.23
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/dist/cli.js +117 -0
- package/dist/client.d.ts +35 -0
- package/dist/client.js +31 -0
- package/dist/hostAssets.d.ts +44 -0
- package/dist/hostAssets.js +579 -0
- package/dist/index.d.ts +5 -4
- package/dist/index.js +8 -1
- package/dist/mcp/registry.d.ts +1 -1
- package/dist/mcp/registry.js +51 -1
- package/dist/mcp/schemas.d.ts +83 -0
- package/dist/mcp/schemas.js +20 -1
- package/dist/table/addColumn.d.ts +98 -0
- package/dist/table/addColumn.js +384 -0
- package/dist/table/formSession.d.ts +18 -6
- package/dist/table/formSession.js +98 -37
- package/dist/table/index.d.ts +3 -1
- package/dist/table/index.js +6 -1
- package/dist/types.d.ts +81 -0
- package/package.json +1 -1
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Add ONE column to an EXISTING ServiceNow table — headless, the faithful way.
|
|
4
|
+
* Creating a column is inserting a `sys_dictionary` record; a REST / Dovetail
|
|
5
|
+
* createRecord insert into sys_dictionary reliably 500s for a scoped-app column
|
|
6
|
+
* (scope-policy + the dictionary engine being wired to the form transaction, not
|
|
7
|
+
* the REST insert). The faithful path is the one the Studio UI drives: a single
|
|
8
|
+
* `POST /sys_db_object.do` against the EXISTING table record whose body embeds the
|
|
9
|
+
* new column as the same list-edit XML blob create-table uses.
|
|
10
|
+
*
|
|
11
|
+
* This REUSES the create-table machinery wholesale (buildColumnXml, formSession,
|
|
12
|
+
* the list-edit key + type normalization) and differs in exactly two ways:
|
|
13
|
+
* 1. It GETs the EXISTING table form (sys_id=<tableSysId>), which renders the
|
|
14
|
+
* "Columns" related list — so the real listEditKey is harvested, not faked.
|
|
15
|
+
* 2. It overlays ONLY the column XML + the save machinery, preserving every
|
|
16
|
+
* existing table field from the harvest (name/label/scope/ACLs untouched).
|
|
17
|
+
* Then it READS THE COLUMN BACK from sys_dictionary to prove it landed — a write
|
|
18
|
+
* that 302s but didn't create the field is reported as failed, never "created".
|
|
19
|
+
*
|
|
20
|
+
* Validated live 2026-06-19 on tenonworkstudio (a smoke-test scoped table): the
|
|
21
|
+
* form save creates the column (HTTP 200 re-render, not a 302), the before/after
|
|
22
|
+
* sys_dictionary diff confirms it, and a scoped insert into the new column
|
|
23
|
+
* round-trips — proving the physical column, not just the dictionary row. ES6
|
|
24
|
+
* only, no optional chaining, no `any`.
|
|
25
|
+
*/
|
|
26
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
27
|
+
if (k2 === undefined) k2 = k;
|
|
28
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
29
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
30
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
31
|
+
}
|
|
32
|
+
Object.defineProperty(o, k2, desc);
|
|
33
|
+
}) : (function(o, m, k, k2) {
|
|
34
|
+
if (k2 === undefined) k2 = k;
|
|
35
|
+
o[k2] = m[k];
|
|
36
|
+
}));
|
|
37
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
38
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
39
|
+
}) : function(o, v) {
|
|
40
|
+
o["default"] = v;
|
|
41
|
+
});
|
|
42
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
43
|
+
var ownKeys = function(o) {
|
|
44
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
45
|
+
var ar = [];
|
|
46
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
47
|
+
return ar;
|
|
48
|
+
};
|
|
49
|
+
return ownKeys(o);
|
|
50
|
+
};
|
|
51
|
+
return function (mod) {
|
|
52
|
+
if (mod && mod.__esModule) return mod;
|
|
53
|
+
var result = {};
|
|
54
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
55
|
+
__setModuleDefault(result, mod);
|
|
56
|
+
return result;
|
|
57
|
+
};
|
|
58
|
+
})();
|
|
59
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
60
|
+
exports.deriveElement = deriveElement;
|
|
61
|
+
exports.applyAddColumnOverlay = applyAddColumnOverlay;
|
|
62
|
+
exports.addColumn = addColumn;
|
|
63
|
+
const crypto = __importStar(require("crypto"));
|
|
64
|
+
const buildColumnXml_1 = require("./buildColumnXml");
|
|
65
|
+
const buildTableSave_1 = require("./buildTableSave");
|
|
66
|
+
const formSession_1 = require("./formSession");
|
|
67
|
+
const createTable_1 = require("./createTable");
|
|
68
|
+
var SYS_ID = /^[0-9a-f]{32}$/i;
|
|
69
|
+
/**
|
|
70
|
+
* Read a ServiceNow Table API field value. Reference fields (e.g. sys_scope) come
|
|
71
|
+
* back as a { link, value } object — the API includes the reference link unless
|
|
72
|
+
* excluded — so a bare String() would yield "[object Object]". Returns the
|
|
73
|
+
* sys_id/value for a reference object, the string otherwise.
|
|
74
|
+
*/
|
|
75
|
+
function fieldValue(v) {
|
|
76
|
+
if (v && typeof v === "object") {
|
|
77
|
+
var o = v;
|
|
78
|
+
return o.value === undefined || o.value === null ? "" : String(o.value);
|
|
79
|
+
}
|
|
80
|
+
return v === undefined || v === null ? "" : String(v);
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Derive the dictionary element (column name) from a label the way Studio does for
|
|
84
|
+
* a scoped table: lower-case, every run of non-alphanumerics -> a single
|
|
85
|
+
* underscore, trimmed. Scoped custom columns are NOT u_-prefixed (that is a
|
|
86
|
+
* global-scope-on-OOB-table behaviour), so "URL" -> "url". Pass `column.name`
|
|
87
|
+
* explicitly for anything non-trivial.
|
|
88
|
+
*/
|
|
89
|
+
function deriveElement(label, explicit) {
|
|
90
|
+
if (explicit && explicit.trim())
|
|
91
|
+
return explicit.trim();
|
|
92
|
+
// Single linear pass: lower-case, collapse each run of non-alphanumerics to one
|
|
93
|
+
// underscore, then trim leading/trailing underscores. Deliberately NOT a regex —
|
|
94
|
+
// an anchored-quantifier trim (/^_+|_+$/) is a polynomial-ReDoS on attacker-shaped
|
|
95
|
+
// input (CodeQL js/polynomial-redos); a char scan is O(n).
|
|
96
|
+
var lower = String(label || "").toLowerCase();
|
|
97
|
+
var collapsed = "";
|
|
98
|
+
var prevUnderscore = false;
|
|
99
|
+
for (var i = 0; i < lower.length; i += 1) {
|
|
100
|
+
var ch = lower.charAt(i);
|
|
101
|
+
var isAlnum = (ch >= "a" && ch <= "z") || (ch >= "0" && ch <= "9");
|
|
102
|
+
if (isAlnum) {
|
|
103
|
+
collapsed += ch;
|
|
104
|
+
prevUnderscore = false;
|
|
105
|
+
}
|
|
106
|
+
else if (!prevUnderscore) {
|
|
107
|
+
collapsed += "_";
|
|
108
|
+
prevUnderscore = true;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
var start = 0;
|
|
112
|
+
var end = collapsed.length;
|
|
113
|
+
while (start < end && collapsed.charAt(start) === "_")
|
|
114
|
+
start += 1;
|
|
115
|
+
while (end > start && collapsed.charAt(end - 1) === "_")
|
|
116
|
+
end -= 1;
|
|
117
|
+
var e = collapsed.slice(start, end);
|
|
118
|
+
if (!e)
|
|
119
|
+
throw new Error("addColumn: cannot derive a column name from label '" +
|
|
120
|
+
label +
|
|
121
|
+
"' — pass column.name.");
|
|
122
|
+
return e;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Overlay ONLY the new-column list-edit XML and the form-save machinery onto the
|
|
126
|
+
* fields harvested from the EXISTING table form. Returns a NEW object; every
|
|
127
|
+
* existing table field (name, label, scope, access, super_class, …) is preserved
|
|
128
|
+
* from `base` untouched — an add-column must not re-stamp the table. Mirrors the
|
|
129
|
+
* harvested-defaults-preserved contract of applyTableSaveOverlay.
|
|
130
|
+
*/
|
|
131
|
+
function applyAddColumnOverlay(base, o) {
|
|
132
|
+
var f = Object.assign({}, base);
|
|
133
|
+
f["sys_target"] = "sys_db_object";
|
|
134
|
+
f["sys_uniqueName"] = "sys_id";
|
|
135
|
+
f["sys_uniqueValue"] = o.tableSysId;
|
|
136
|
+
f["sys_row"] = o.tableSysId;
|
|
137
|
+
f["sys_action"] = o.saveActionSysId;
|
|
138
|
+
f["isFormPage"] = "true";
|
|
139
|
+
f["sysparm_modify_check"] = "true";
|
|
140
|
+
f["personalizer_sys_db_object"] = "true";
|
|
141
|
+
if (o.listEditKey) {
|
|
142
|
+
f[o.listEditKey] = o.columnXml;
|
|
143
|
+
}
|
|
144
|
+
return f;
|
|
145
|
+
}
|
|
146
|
+
function newSysId() {
|
|
147
|
+
return crypto.randomBytes(16).toString("hex");
|
|
148
|
+
}
|
|
149
|
+
function validate(params) {
|
|
150
|
+
if (!params || typeof params !== "object")
|
|
151
|
+
throw new Error("addColumn: params object required.");
|
|
152
|
+
if (!params.client)
|
|
153
|
+
throw new Error("addColumn: client is required.");
|
|
154
|
+
if (!params.table || !String(params.table).trim())
|
|
155
|
+
throw new Error("addColumn: table is required.");
|
|
156
|
+
if (!params.column || typeof params.column !== "object")
|
|
157
|
+
throw new Error("addColumn: column is required.");
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Map every existing column on a table to its internal_type, via sys_dictionary.
|
|
161
|
+
* Used to snapshot before/after the save so the new column is found by diff —
|
|
162
|
+
* robust against ServiceNow's server-side element derivation (trailing-digit
|
|
163
|
+
* underscoring, cross-scope prefixing) that makes predicting the element brittle.
|
|
164
|
+
*/
|
|
165
|
+
async function elementTypeMap(client, tableName) {
|
|
166
|
+
var rows = await client.table.query("sys_dictionary", "name=" + tableName + "^element!=NULL", 1000);
|
|
167
|
+
var map = {};
|
|
168
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
169
|
+
var el = fieldValue(rows[i].element);
|
|
170
|
+
if (el)
|
|
171
|
+
map[el] = fieldValue(rows[i].internal_type);
|
|
172
|
+
}
|
|
173
|
+
return map;
|
|
174
|
+
}
|
|
175
|
+
/** Resolve the table by name or sys_id; returns its name, sys_id, and sys_scope. */
|
|
176
|
+
async function resolveTable(client, table) {
|
|
177
|
+
var query = SYS_ID.test(table) ? "sys_id=" + table : "name=" + table;
|
|
178
|
+
var rows = await client.table.query("sys_db_object", query, 1);
|
|
179
|
+
if (rows.length === 0) {
|
|
180
|
+
throw new Error("addColumn: table '" + table + "' not found in sys_db_object.");
|
|
181
|
+
}
|
|
182
|
+
return {
|
|
183
|
+
name: fieldValue(rows[0].name) || table,
|
|
184
|
+
sysId: fieldValue(rows[0].sys_id),
|
|
185
|
+
scopeSysId: fieldValue(rows[0].sys_scope),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
/** Resolve a scope name-or-sysid to the sys_app sys_id used by setCurrentApplication. */
|
|
189
|
+
async function resolveAppSysId(client, scope) {
|
|
190
|
+
if (!scope)
|
|
191
|
+
return "";
|
|
192
|
+
var query = SYS_ID.test(scope) ? "sys_id=" + scope : "scope=" + scope;
|
|
193
|
+
var apps = await client.table.query("sys_app", query, 1);
|
|
194
|
+
return apps.length > 0 ? fieldValue(apps[0].sys_id) : "";
|
|
195
|
+
}
|
|
196
|
+
async function addColumn(params) {
|
|
197
|
+
validate(params);
|
|
198
|
+
var client = params.client;
|
|
199
|
+
// Normalize the single column (validates type, reference target, dedup is trivial).
|
|
200
|
+
var normalized = (0, buildTableSave_1.normalizeColumns)([params.column]);
|
|
201
|
+
var col = normalized[0];
|
|
202
|
+
var element = deriveElement(col.label, params.column.name);
|
|
203
|
+
var columnSysId = newSysId();
|
|
204
|
+
// The column XML is pure — build it now (used by dry-run AND the live POST).
|
|
205
|
+
var columnXml = (0, buildColumnXml_1.buildColumnXml)(normalized, [columnSysId]);
|
|
206
|
+
if (params.dryRun) {
|
|
207
|
+
return {
|
|
208
|
+
status: "dry-run",
|
|
209
|
+
table: params.table,
|
|
210
|
+
tableSysId: SYS_ID.test(params.table) ? params.table : "",
|
|
211
|
+
element: element,
|
|
212
|
+
label: col.label,
|
|
213
|
+
internalType: col.type,
|
|
214
|
+
columnSysId: columnSysId,
|
|
215
|
+
updateSetSysId: params.updateSetSysId ? params.updateSetSysId : "",
|
|
216
|
+
httpStatus: 0,
|
|
217
|
+
location: "",
|
|
218
|
+
columnXml: columnXml,
|
|
219
|
+
verified: false,
|
|
220
|
+
note: "dry-run: no session opened, no writes. Would add column '" +
|
|
221
|
+
element +
|
|
222
|
+
"' (" +
|
|
223
|
+
col.type +
|
|
224
|
+
") to '" +
|
|
225
|
+
params.table +
|
|
226
|
+
"' via the table form save, then read it back from sys_dictionary.",
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
// ---- LIVE PATH (NOT YET VALIDATED END-TO-END) -----------------------------
|
|
230
|
+
// Resolve the existing table + its scope.
|
|
231
|
+
var resolved = await resolveTable(client, params.table);
|
|
232
|
+
var scopeForApp = params.scope && params.scope.trim()
|
|
233
|
+
? params.scope.trim()
|
|
234
|
+
: resolved.scopeSysId;
|
|
235
|
+
var appSysId = await resolveAppSysId(client, scopeForApp);
|
|
236
|
+
var saveAction = params.saveActionSysId && params.saveActionSysId.trim()
|
|
237
|
+
? params.saveActionSysId.trim()
|
|
238
|
+
: createTable_1.DEFAULT_SAVE_ACTION;
|
|
239
|
+
// Open the form session and put it in the table's scope before the save.
|
|
240
|
+
var auth = (0, formSession_1.resolveFormAuth)({
|
|
241
|
+
instance: params.instance,
|
|
242
|
+
user: params.user,
|
|
243
|
+
password: params.password,
|
|
244
|
+
});
|
|
245
|
+
var session = await (0, formSession_1.openFormSession)(auth);
|
|
246
|
+
var appSwitch = { ok: false, status: 0, body: "no app resolved" };
|
|
247
|
+
if (appSysId) {
|
|
248
|
+
appSwitch = await (0, formSession_1.setCurrentApplication)(auth, session, appSysId);
|
|
249
|
+
}
|
|
250
|
+
if (params.updateSetSysId) {
|
|
251
|
+
try {
|
|
252
|
+
await client.claude.changeUpdateSet({ sysId: params.updateSetSysId });
|
|
253
|
+
}
|
|
254
|
+
catch (e) {
|
|
255
|
+
/* best-effort */
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
// Harvest the EXISTING table form — its rendered related list yields the real key.
|
|
259
|
+
var harvest = await (0, formSession_1.getRecordForm)(auth, session, resolved.sysId);
|
|
260
|
+
var relId = params.columnsRelId && params.columnsRelId.trim()
|
|
261
|
+
? params.columnsRelId.trim()
|
|
262
|
+
: createTable_1.DEFAULT_COLUMNS_REL_ID;
|
|
263
|
+
var colKey = harvest.listEditKey ? harvest.listEditKey : (0, buildTableSave_1.listEditKey)(relId);
|
|
264
|
+
var fields = applyAddColumnOverlay(harvest.fields, {
|
|
265
|
+
tableSysId: resolved.sysId,
|
|
266
|
+
saveActionSysId: saveAction,
|
|
267
|
+
listEditKey: colKey,
|
|
268
|
+
columnXml: columnXml,
|
|
269
|
+
});
|
|
270
|
+
// Snapshot the table's columns BEFORE the save so the new column is found by diff.
|
|
271
|
+
// ServiceNow derives the element from the column label server-side (the element
|
|
272
|
+
// field is sent empty), and normalizes it — a trailing digit gains an underscore,
|
|
273
|
+
// a cross-scope add gets a scope prefix — so predicting the element name is
|
|
274
|
+
// unreliable. The diff reports whatever element SN actually assigned.
|
|
275
|
+
var beforeMap = await elementTypeMap(client, resolved.name);
|
|
276
|
+
var resp = await (0, formSession_1.postForm)(auth, session, "/sys_db_object.do", fields);
|
|
277
|
+
// A form save's HTTP status does NOT prove the column landed: an EXISTING-record
|
|
278
|
+
// save re-renders the form with 200 on success (only a NEW record 302s to its
|
|
279
|
+
// assigned sys_id). So the sys_dictionary diff is the source of truth; only a hard
|
|
280
|
+
// HTTP error (4xx/5xx) skips it.
|
|
281
|
+
var hardError = resp.status >= 400;
|
|
282
|
+
var verified = false;
|
|
283
|
+
var actualElement = "";
|
|
284
|
+
var readBackType = "";
|
|
285
|
+
var added = [];
|
|
286
|
+
if (!hardError) {
|
|
287
|
+
var afterMap = await elementTypeMap(client, resolved.name);
|
|
288
|
+
added = Object.keys(afterMap).filter(function (e) {
|
|
289
|
+
return !Object.prototype.hasOwnProperty.call(beforeMap, e);
|
|
290
|
+
});
|
|
291
|
+
if (added.length > 0) {
|
|
292
|
+
verified = true;
|
|
293
|
+
// Prefer the new element that matches our derived guess; else take the first.
|
|
294
|
+
var exact = added.filter(function (e) {
|
|
295
|
+
return e === element || e.indexOf(element) !== -1;
|
|
296
|
+
});
|
|
297
|
+
actualElement = exact.length > 0 ? exact[0] : added[0];
|
|
298
|
+
readBackType = afterMap[actualElement];
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
var reportElement = actualElement || element;
|
|
302
|
+
var status = verified ? "created" : "failed";
|
|
303
|
+
var note;
|
|
304
|
+
if (status === "created") {
|
|
305
|
+
note =
|
|
306
|
+
"Added column '" +
|
|
307
|
+
reportElement +
|
|
308
|
+
"' (" +
|
|
309
|
+
col.type +
|
|
310
|
+
") to " +
|
|
311
|
+
resolved.name +
|
|
312
|
+
" — verified present in sys_dictionary (HTTP " +
|
|
313
|
+
resp.status +
|
|
314
|
+
")" +
|
|
315
|
+
(actualElement && actualElement !== element
|
|
316
|
+
? " (NOTE: ServiceNow assigned element '" +
|
|
317
|
+
actualElement +
|
|
318
|
+
"', not the requested '" +
|
|
319
|
+
element +
|
|
320
|
+
"')"
|
|
321
|
+
: "") +
|
|
322
|
+
(readBackType && readBackType !== col.type
|
|
323
|
+
? " (NOTE: internal_type read back as '" + readBackType + "')"
|
|
324
|
+
: "") +
|
|
325
|
+
(added.length > 1
|
|
326
|
+
? " (NOTE: " +
|
|
327
|
+
added.length +
|
|
328
|
+
" new columns appeared: " +
|
|
329
|
+
added.join(", ") +
|
|
330
|
+
")"
|
|
331
|
+
: "") +
|
|
332
|
+
". Confirm a scoped insert + the sys_update_xml landed in the update set.";
|
|
333
|
+
}
|
|
334
|
+
else if (!hardError) {
|
|
335
|
+
note =
|
|
336
|
+
"save POST returned " +
|
|
337
|
+
resp.status +
|
|
338
|
+
" but no new column appeared on " +
|
|
339
|
+
resolved.name +
|
|
340
|
+
" — the form likely rejected the column. " +
|
|
341
|
+
resp.body.slice(0, 300);
|
|
342
|
+
}
|
|
343
|
+
else {
|
|
344
|
+
note =
|
|
345
|
+
"save POST returned " +
|
|
346
|
+
resp.status +
|
|
347
|
+
" (HTTP error). " +
|
|
348
|
+
resp.body.slice(0, 300);
|
|
349
|
+
}
|
|
350
|
+
if (params.debug) {
|
|
351
|
+
note +=
|
|
352
|
+
" [debug: appSwitch=" +
|
|
353
|
+
appSwitch.status +
|
|
354
|
+
(appSwitch.ok ? "/ok" : "/FAIL:" + appSwitch.body) +
|
|
355
|
+
" appSysId=" +
|
|
356
|
+
(appSysId || "(none)") +
|
|
357
|
+
" harvestedListEditKey=" +
|
|
358
|
+
(harvest.listEditKey ? "yes" : "no") +
|
|
359
|
+
" colKey=" +
|
|
360
|
+
colKey +
|
|
361
|
+
" fieldCount=" +
|
|
362
|
+
Object.keys(fields).length +
|
|
363
|
+
" location=" +
|
|
364
|
+
resp.location +
|
|
365
|
+
" readBackType=" +
|
|
366
|
+
(readBackType || "(none)") +
|
|
367
|
+
"]";
|
|
368
|
+
}
|
|
369
|
+
return {
|
|
370
|
+
status: status,
|
|
371
|
+
table: resolved.name,
|
|
372
|
+
tableSysId: resolved.sysId,
|
|
373
|
+
element: reportElement,
|
|
374
|
+
label: col.label,
|
|
375
|
+
internalType: col.type,
|
|
376
|
+
columnSysId: columnSysId,
|
|
377
|
+
updateSetSysId: params.updateSetSysId ? params.updateSetSysId : "",
|
|
378
|
+
httpStatus: resp.status,
|
|
379
|
+
location: resp.location,
|
|
380
|
+
columnXml: columnXml,
|
|
381
|
+
verified: verified,
|
|
382
|
+
note: note,
|
|
383
|
+
};
|
|
384
|
+
}
|
|
@@ -53,12 +53,24 @@ export interface HarvestedForm {
|
|
|
53
53
|
listEditKey: string;
|
|
54
54
|
}
|
|
55
55
|
/**
|
|
56
|
-
* GET
|
|
57
|
-
* would submit (sysparm_ck, sysparm_encoded_record, the dynamic
|
|
58
|
-
* field, every sys_original.* default)
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
56
|
+
* GET a sys_db_object form (record `sysId`) and harvest every hidden/input field
|
|
57
|
+
* the browser would submit (sysparm_ck, sysparm_encoded_record, the dynamic
|
|
58
|
+
* `<sysid>_text` field, every sys_original.* default) plus the discovered list-edit
|
|
59
|
+
* key. The caller overlays capability params onto `fields` before POSTing.
|
|
60
|
+
*
|
|
61
|
+
* `sysId = "-1"` is the new-record form (table create). An EXISTING record's form
|
|
62
|
+
* renders the "Columns" related list inline, so for add-column the real
|
|
63
|
+
* `ListEditFormatterAction[sys_db_object.REL:<relId>]` key IS present and harvested
|
|
64
|
+
* here — unlike the new-record form, which renders no related lists (listEditKey
|
|
65
|
+
* empty, the create-table caller falls back to the constant relId). Validated live
|
|
66
|
+
* 2026-06-13 for the new-record path.
|
|
67
|
+
*/
|
|
68
|
+
export declare function getRecordForm(auth: FormAuth, session: FormSession, sysId: string): Promise<HarvestedForm>;
|
|
69
|
+
/**
|
|
70
|
+
* GET the new-record (sys_id=-1) sys_db_object form. Thin wrapper over getRecordForm
|
|
71
|
+
* preserved for the create-table caller. The new-record form renders no related
|
|
72
|
+
* lists, so `listEditKey` is normally empty — the caller falls back to the constant
|
|
73
|
+
* "Table Columns" relId. Validated live 2026-06-13.
|
|
62
74
|
*/
|
|
63
75
|
export declare function getNewRecordForm(auth: FormAuth, session: FormSession): Promise<HarvestedForm>;
|
|
64
76
|
/**
|
|
@@ -17,34 +17,54 @@ exports.resolveFormAuth = resolveFormAuth;
|
|
|
17
17
|
exports.scrapeCk = scrapeCk;
|
|
18
18
|
exports.openFormSession = openFormSession;
|
|
19
19
|
exports.setCurrentApplication = setCurrentApplication;
|
|
20
|
+
exports.getRecordForm = getRecordForm;
|
|
20
21
|
exports.getNewRecordForm = getNewRecordForm;
|
|
21
22
|
exports.parseFormInputs = parseFormInputs;
|
|
22
23
|
exports.postForm = postForm;
|
|
23
24
|
/** Env precedence mirrors client.ts: explicit > SN_* > SN_DEV_* > SN_PROD_*. */
|
|
24
25
|
function resolveFormAuth(cfg) {
|
|
25
26
|
var c = cfg || {};
|
|
26
|
-
var rawHost = c.instance
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
27
|
+
var rawHost = c.instance ||
|
|
28
|
+
process.env.SN_INSTANCE ||
|
|
29
|
+
process.env.SN_DEV_INSTANCE ||
|
|
30
|
+
process.env.SN_PROD_INSTANCE ||
|
|
31
|
+
"";
|
|
31
32
|
if (!rawHost) {
|
|
32
33
|
throw new Error("ServiceNow instance not configured. Set SN_INSTANCE or pass { instance }.");
|
|
33
34
|
}
|
|
34
|
-
|
|
35
|
+
// Strip the scheme, then trim trailing slashes by index. NOT /\/+$/ — an
|
|
36
|
+
// anchored one-or-more quantifier is a polynomial-ReDoS on a host with many
|
|
37
|
+
// trailing slashes (CodeQL js/polynomial-redos); the index walk is O(n).
|
|
38
|
+
var host = rawHost.replace(/^https?:\/\//, "");
|
|
39
|
+
var hostEnd = host.length;
|
|
40
|
+
while (hostEnd > 0 && host.charAt(hostEnd - 1) === "/")
|
|
41
|
+
hostEnd -= 1;
|
|
42
|
+
host = host.slice(0, hostEnd);
|
|
35
43
|
if (host.indexOf(".") === -1)
|
|
36
44
|
host = host.toLowerCase() + ".service-now.com";
|
|
37
|
-
var user = c.user ||
|
|
38
|
-
|
|
45
|
+
var user = c.user ||
|
|
46
|
+
process.env.SN_USER ||
|
|
47
|
+
process.env.SN_DEV_USERNAME ||
|
|
48
|
+
process.env.SN_PROD_USERNAME ||
|
|
49
|
+
"";
|
|
50
|
+
var password = c.password ||
|
|
51
|
+
process.env.SN_PASSWORD ||
|
|
52
|
+
process.env.SN_DEV_PASSWORD ||
|
|
53
|
+
process.env.SN_PROD_PASSWORD ||
|
|
54
|
+
"";
|
|
39
55
|
if (!user || !password) {
|
|
40
56
|
throw new Error("ServiceNow credentials missing — set SN_USER/SN_PASSWORD (or SN_DEV_*/SN_PROD_*).");
|
|
41
57
|
}
|
|
42
58
|
return { host: host, user: user, password: password };
|
|
43
59
|
}
|
|
44
|
-
function base(auth) {
|
|
60
|
+
function base(auth) {
|
|
61
|
+
return "https://" + auth.host;
|
|
62
|
+
}
|
|
45
63
|
function jarFrom(res, jar) {
|
|
46
64
|
var anyHeaders = res.headers;
|
|
47
|
-
var setCookies = typeof anyHeaders.getSetCookie === "function"
|
|
65
|
+
var setCookies = typeof anyHeaders.getSetCookie === "function"
|
|
66
|
+
? anyHeaders.getSetCookie()
|
|
67
|
+
: [];
|
|
48
68
|
for (var i = 0; i < setCookies.length; i += 1) {
|
|
49
69
|
var kv = setCookies[i].split(";")[0];
|
|
50
70
|
var eq = kv.indexOf("=");
|
|
@@ -54,40 +74,60 @@ function jarFrom(res, jar) {
|
|
|
54
74
|
return jar;
|
|
55
75
|
}
|
|
56
76
|
function cookieHeader(jar) {
|
|
57
|
-
return Object.keys(jar)
|
|
77
|
+
return Object.keys(jar)
|
|
78
|
+
.map(function (k) {
|
|
79
|
+
return k + "=" + jar[k];
|
|
80
|
+
})
|
|
81
|
+
.join("; ");
|
|
58
82
|
}
|
|
59
83
|
/** Scrape an authenticated g_ck (or form sysparm_ck) out of a page's HTML. */
|
|
60
84
|
function scrapeCk(html) {
|
|
61
|
-
var m = html.match(/var g_ck = ['"]([0-9a-f]{40,})['"]/)
|
|
62
|
-
|
|
63
|
-
|
|
85
|
+
var m = html.match(/var g_ck = ['"]([0-9a-f]{40,})['"]/) ||
|
|
86
|
+
html.match(/window\.g_ck = ['"]([0-9a-f]{40,})['"]/) ||
|
|
87
|
+
html.match(/name="sysparm_ck"\s+value="([0-9a-f]{40,})"/);
|
|
64
88
|
return m ? m[1] : "";
|
|
65
89
|
}
|
|
66
90
|
/** Form-login (login.do) and return an authenticated { ck, jar }. */
|
|
67
91
|
async function openFormSession(auth) {
|
|
68
92
|
var B = base(auth);
|
|
69
93
|
var jar = {};
|
|
70
|
-
var res = await fetch(B + "/login.do", {
|
|
94
|
+
var res = await fetch(B + "/login.do", {
|
|
95
|
+
headers: { Accept: "text/html" },
|
|
96
|
+
redirect: "manual",
|
|
97
|
+
});
|
|
71
98
|
jarFrom(res, jar);
|
|
72
99
|
var formCk = scrapeCk(await res.text());
|
|
73
100
|
var body = new URLSearchParams({
|
|
74
|
-
user_name: auth.user,
|
|
75
|
-
|
|
101
|
+
user_name: auth.user,
|
|
102
|
+
user_password: auth.password,
|
|
103
|
+
sysparm_ck: formCk,
|
|
104
|
+
not_important: "",
|
|
105
|
+
sys_action: "sysverb_login",
|
|
76
106
|
}).toString();
|
|
77
107
|
res = await fetch(B + "/login.do", {
|
|
78
108
|
method: "POST",
|
|
79
|
-
headers: {
|
|
80
|
-
|
|
109
|
+
headers: {
|
|
110
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
111
|
+
Cookie: cookieHeader(jar),
|
|
112
|
+
Accept: "text/html",
|
|
113
|
+
},
|
|
114
|
+
body: body,
|
|
115
|
+
redirect: "manual",
|
|
81
116
|
});
|
|
82
117
|
jarFrom(res, jar);
|
|
83
118
|
var loc = res.headers.get("location");
|
|
84
119
|
if (loc) {
|
|
85
|
-
var url = loc.indexOf("http") === 0
|
|
86
|
-
|
|
120
|
+
var url = loc.indexOf("http") === 0
|
|
121
|
+
? loc
|
|
122
|
+
: B + (loc.indexOf("/") === 0 ? loc : "/" + loc);
|
|
123
|
+
res = await fetch(url, {
|
|
124
|
+
headers: { Cookie: cookieHeader(jar), Accept: "text/html" },
|
|
125
|
+
redirect: "manual",
|
|
126
|
+
});
|
|
87
127
|
jarFrom(res, jar);
|
|
88
128
|
}
|
|
89
129
|
res = await fetch(B + "/sys_db_object.do?sys_id=-1&sysparm_stack=no", {
|
|
90
|
-
headers: { Cookie: cookieHeader(jar), Accept: "text/html" }
|
|
130
|
+
headers: { Cookie: cookieHeader(jar), Accept: "text/html" },
|
|
91
131
|
});
|
|
92
132
|
jarFrom(res, jar);
|
|
93
133
|
var ck = scrapeCk(await res.text());
|
|
@@ -114,12 +154,12 @@ async function setCurrentApplication(auth, session, appSysId) {
|
|
|
114
154
|
"X-UserToken": session.ck,
|
|
115
155
|
Cookie: cookieHeader(session.jar),
|
|
116
156
|
"Content-Type": "application/json",
|
|
117
|
-
Accept: "application/json"
|
|
157
|
+
Accept: "application/json",
|
|
118
158
|
},
|
|
119
159
|
// The picker's ApplicationProcessor expects `app_id` (a `value` body returns
|
|
120
160
|
// 400 "Missing Application Id").
|
|
121
161
|
body: JSON.stringify({ app_id: appSysId }),
|
|
122
|
-
redirect: "manual"
|
|
162
|
+
redirect: "manual",
|
|
123
163
|
});
|
|
124
164
|
jarFrom(res, session.jar);
|
|
125
165
|
var body = "";
|
|
@@ -133,17 +173,26 @@ async function setCurrentApplication(auth, session, appSysId) {
|
|
|
133
173
|
return { ok: ok, status: res.status, body: body.slice(0, 200) };
|
|
134
174
|
}
|
|
135
175
|
/**
|
|
136
|
-
* GET
|
|
137
|
-
* would submit (sysparm_ck, sysparm_encoded_record, the dynamic
|
|
138
|
-
* field, every sys_original.* default)
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
176
|
+
* GET a sys_db_object form (record `sysId`) and harvest every hidden/input field
|
|
177
|
+
* the browser would submit (sysparm_ck, sysparm_encoded_record, the dynamic
|
|
178
|
+
* `<sysid>_text` field, every sys_original.* default) plus the discovered list-edit
|
|
179
|
+
* key. The caller overlays capability params onto `fields` before POSTing.
|
|
180
|
+
*
|
|
181
|
+
* `sysId = "-1"` is the new-record form (table create). An EXISTING record's form
|
|
182
|
+
* renders the "Columns" related list inline, so for add-column the real
|
|
183
|
+
* `ListEditFormatterAction[sys_db_object.REL:<relId>]` key IS present and harvested
|
|
184
|
+
* here — unlike the new-record form, which renders no related lists (listEditKey
|
|
185
|
+
* empty, the create-table caller falls back to the constant relId). Validated live
|
|
186
|
+
* 2026-06-13 for the new-record path.
|
|
142
187
|
*/
|
|
143
|
-
async function
|
|
188
|
+
async function getRecordForm(auth, session, sysId) {
|
|
144
189
|
var B = base(auth);
|
|
145
|
-
var
|
|
146
|
-
|
|
190
|
+
var id = sysId && String(sysId).trim() ? String(sysId).trim() : "-1";
|
|
191
|
+
var res = await fetch(B +
|
|
192
|
+
"/sys_db_object.do?sys_id=" +
|
|
193
|
+
encodeURIComponent(id) +
|
|
194
|
+
"&sysparm_stack=no", {
|
|
195
|
+
headers: { Cookie: cookieHeader(session.jar), Accept: "text/html" },
|
|
147
196
|
});
|
|
148
197
|
jarFrom(res, session.jar);
|
|
149
198
|
var html = await res.text();
|
|
@@ -161,6 +210,15 @@ async function getNewRecordForm(auth, session) {
|
|
|
161
210
|
}
|
|
162
211
|
return { fields: fields, listEditKey: listEditKey };
|
|
163
212
|
}
|
|
213
|
+
/**
|
|
214
|
+
* GET the new-record (sys_id=-1) sys_db_object form. Thin wrapper over getRecordForm
|
|
215
|
+
* preserved for the create-table caller. The new-record form renders no related
|
|
216
|
+
* lists, so `listEditKey` is normally empty — the caller falls back to the constant
|
|
217
|
+
* "Table Columns" relId. Validated live 2026-06-13.
|
|
218
|
+
*/
|
|
219
|
+
async function getNewRecordForm(auth, session) {
|
|
220
|
+
return getRecordForm(auth, session, "-1");
|
|
221
|
+
}
|
|
164
222
|
/**
|
|
165
223
|
* Parse `<input>` (and hidden) name/value pairs out of form HTML. Best-effort —
|
|
166
224
|
* handles double/single-quoted attributes in either order. Exported for testing.
|
|
@@ -189,12 +247,15 @@ function attr(tag, name) {
|
|
|
189
247
|
return null;
|
|
190
248
|
}
|
|
191
249
|
function decodeHtml(s) {
|
|
250
|
+
// Unescape & LAST. Doing it first double-unescapes inputs like "&lt;"
|
|
251
|
+
// ("&lt;" -> "<" -> "<"), the js/double-escaping vulnerability. With the
|
|
252
|
+
// ampersand handled last, each entity is unescaped exactly once.
|
|
192
253
|
return s
|
|
193
|
-
.replace(/&/g, "&")
|
|
194
254
|
.replace(/</g, "<")
|
|
195
255
|
.replace(/>/g, ">")
|
|
196
256
|
.replace(/"/g, '"')
|
|
197
|
-
.replace(/'/g, "'")
|
|
257
|
+
.replace(/'/g, "'")
|
|
258
|
+
.replace(/&/g, "&");
|
|
198
259
|
}
|
|
199
260
|
/** POST a form-urlencoded field map to a `.do` path. Follows nothing — returns the 302. */
|
|
200
261
|
async function postForm(auth, session, path, fields) {
|
|
@@ -210,10 +271,10 @@ async function postForm(auth, session, path, fields) {
|
|
|
210
271
|
"X-UserToken": session.ck,
|
|
211
272
|
Cookie: cookieHeader(session.jar),
|
|
212
273
|
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
|
|
213
|
-
Accept: "text/html"
|
|
274
|
+
Accept: "text/html",
|
|
214
275
|
},
|
|
215
276
|
body: params.toString(),
|
|
216
|
-
redirect: "manual"
|
|
277
|
+
redirect: "manual",
|
|
217
278
|
});
|
|
218
279
|
jarFrom(res, session.jar);
|
|
219
280
|
var location = res.headers.get("location") || "";
|
package/dist/table/index.d.ts
CHANGED
|
@@ -9,5 +9,7 @@ export { buildColumnXml, xmlEscape } from "./buildColumnXml";
|
|
|
9
9
|
export type { NormalizedColumn } from "./buildColumnXml";
|
|
10
10
|
export { normalizeColumns, resolveType, applyTableSaveOverlay, defaultAccessFlags, showInMenuKey, listEditKey, TYPE_MAP } from "./buildTableSave";
|
|
11
11
|
export type { ColumnSpec, AccessFlags, OverlaySpec } from "./buildTableSave";
|
|
12
|
-
export { resolveFormAuth, openFormSession, setCurrentApplication, getNewRecordForm, parseFormInputs, postForm, scrapeCk } from "./formSession";
|
|
12
|
+
export { resolveFormAuth, openFormSession, setCurrentApplication, getRecordForm, getNewRecordForm, parseFormInputs, postForm, scrapeCk } from "./formSession";
|
|
13
13
|
export type { FormAuth, FormSession, HarvestedForm, PostResult } from "./formSession";
|
|
14
|
+
export { addColumn, deriveElement, applyAddColumnOverlay } from "./addColumn";
|
|
15
|
+
export type { AddColumnParams, AddColumnResult } from "./addColumn";
|