@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.
@@ -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 the new sys_db_object form and harvest every hidden/input field the browser
57
- * would submit (sysparm_ck, sysparm_encoded_record, the dynamic `<sysid>_text`
58
- * field, every sys_original.* default). The caller overlays capability params onto
59
- * `fields` before POSTing. NOTE: the new-record (sys_id=-1) form does NOT render
60
- * related lists, so `listEditKey` is normally empty — the caller falls back to the
61
- * constant "Table Columns" relId. Validated live 2026-06-13.
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
- || process.env.SN_INSTANCE
28
- || process.env.SN_DEV_INSTANCE
29
- || process.env.SN_PROD_INSTANCE
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
- var host = rawHost.replace(/^https?:\/\//, "").replace(/\/+$/, "");
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 || process.env.SN_USER || process.env.SN_DEV_USERNAME || process.env.SN_PROD_USERNAME || "";
38
- var password = c.password || process.env.SN_PASSWORD || process.env.SN_DEV_PASSWORD || process.env.SN_PROD_PASSWORD || "";
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) { return "https://" + auth.host; }
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" ? anyHeaders.getSetCookie() : [];
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).map(function (k) { return k + "=" + jar[k]; }).join("; ");
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
- || html.match(/window\.g_ck = ['"]([0-9a-f]{40,})['"]/)
63
- || html.match(/name="sysparm_ck"\s+value="([0-9a-f]{40,})"/);
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", { headers: { Accept: "text/html" }, redirect: "manual" });
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, user_password: auth.password, sysparm_ck: formCk,
75
- not_important: "", sys_action: "sysverb_login"
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: { "Content-Type": "application/x-www-form-urlencoded", Cookie: cookieHeader(jar), Accept: "text/html" },
80
- body: body, redirect: "manual"
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 ? loc : B + (loc.indexOf("/") === 0 ? loc : "/" + loc);
86
- res = await fetch(url, { headers: { Cookie: cookieHeader(jar), Accept: "text/html" }, redirect: "manual" });
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 the new sys_db_object form and harvest every hidden/input field the browser
137
- * would submit (sysparm_ck, sysparm_encoded_record, the dynamic `<sysid>_text`
138
- * field, every sys_original.* default). The caller overlays capability params onto
139
- * `fields` before POSTing. NOTE: the new-record (sys_id=-1) form does NOT render
140
- * related lists, so `listEditKey` is normally empty — the caller falls back to the
141
- * constant "Table Columns" relId. Validated live 2026-06-13.
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 getNewRecordForm(auth, session) {
188
+ async function getRecordForm(auth, session, sysId) {
144
189
  var B = base(auth);
145
- var res = await fetch(B + "/sys_db_object.do?sys_id=-1&sysparm_stack=no", {
146
- headers: { Cookie: cookieHeader(session.jar), Accept: "text/html" }
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 &amp; LAST. Doing it first double-unescapes inputs like "&amp;lt;"
251
+ // ("&amp;lt;" -> "&lt;" -> "<"), the js/double-escaping vulnerability. With the
252
+ // ampersand handled last, each entity is unescaped exactly once.
192
253
  return s
193
- .replace(/&amp;/g, "&")
194
254
  .replace(/&lt;/g, "<")
195
255
  .replace(/&gt;/g, ">")
196
256
  .replace(/&quot;/g, '"')
197
- .replace(/&#39;/g, "'");
257
+ .replace(/&#39;/g, "'")
258
+ .replace(/&amp;/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") || "";
@@ -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";