@salesforce/ui-bundle-template-feature-react-search 11.31.15 → 11.33.0

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/CHANGELOG.md CHANGED
@@ -3,6 +3,23 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ ## [11.33.0](https://github.com/salesforce-experience-platform-emu/webapps/compare/v11.32.0...v11.33.0) (2026-07-21)
7
+
8
+
9
+ ### Features
10
+
11
+ * **org-setup:** harden org-setup.mjs against SOQL/Apex/CLI injection (W-23043731) ([#751](https://github.com/salesforce-experience-platform-emu/webapps/issues/751)) ([e820204](https://github.com/salesforce-experience-platform-emu/webapps/commit/e8202049e262ddbb47235cbedf5ccf54fe6a4d31))
12
+
13
+
14
+
15
+ ## [11.32.0](https://github.com/salesforce-experience-platform-emu/webapps/compare/v11.31.15...v11.32.0) (2026-07-21)
16
+
17
+ **Note:** Version bump only for package @salesforce/ui-bundle-template-base-sfdx-project
18
+
19
+
20
+
21
+
22
+
6
23
  ## [11.31.15](https://github.com/salesforce-experience-platform-emu/webapps/compare/v11.31.14...v11.31.15) (2026-07-21)
7
24
 
8
25
  **Note:** Version bump only for package @salesforce/ui-bundle-template-base-sfdx-project
@@ -18,8 +18,8 @@
18
18
  "graphql:schema": "node scripts/get-graphql-schema.mjs"
19
19
  },
20
20
  "dependencies": {
21
- "@salesforce/platform-sdk": "^11.31.15",
22
- "@salesforce/ui-bundle": "^11.31.15",
21
+ "@salesforce/platform-sdk": "^11.33.0",
22
+ "@salesforce/ui-bundle": "^11.33.0",
23
23
  "@tailwindcss/vite": "^4.1.17",
24
24
  "class-variance-authority": "^0.7.1",
25
25
  "clsx": "^2.1.1",
@@ -44,8 +44,8 @@
44
44
  "@graphql-eslint/eslint-plugin": "^4.1.0",
45
45
  "@graphql-tools/utils": "^11.0.0",
46
46
  "@playwright/test": "^1.49.0",
47
- "@salesforce/graphiti": "^11.31.15",
48
- "@salesforce/vite-plugin-ui-bundle": "^11.31.15",
47
+ "@salesforce/graphiti": "^11.33.0",
48
+ "@salesforce/vite-plugin-ui-bundle": "^11.33.0",
49
49
  "@testing-library/jest-dom": "^6.6.3",
50
50
  "@testing-library/react": "^16.1.0",
51
51
  "@testing-library/user-event": "^14.5.2",
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@salesforce/ui-bundle-template-base-sfdx-project",
3
- "version": "11.31.15",
3
+ "version": "11.33.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@salesforce/ui-bundle-template-base-sfdx-project",
9
- "version": "11.31.15",
9
+ "version": "11.33.0",
10
10
  "license": "SEE LICENSE IN LICENSE.txt",
11
11
  "dependencies": {
12
12
  "fast-xml-parser": "^5.9.3",
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@salesforce/ui-bundle-template-base-sfdx-project",
3
- "version": "11.31.15",
3
+ "version": "11.33.0",
4
4
  "description": "Base SFDX project template",
5
5
  "license": "SEE LICENSE IN LICENSE.txt",
6
6
  "publishConfig": {
@@ -1,11 +1,11 @@
1
1
  /**
2
- * Shared helpers for the org-setup scripts (W-23043735, spec §5.2/§5.6/Q9).
2
+ * Shared helpers for the org-setup scripts.
3
3
  *
4
4
  * Factored out of org-setup.mjs so BOTH `org-setup.mjs` and `org-setup-dev.mjs` import the
5
5
  * same primitives instead of duplicating them:
6
6
  * - process runners (`run`, `runAsync`, `spawnAsync`)
7
7
  * - SFDX path + UI-bundle discovery (`resolveSfdxSource`, `discoverAllUIBundleDirs`,
8
- * `discoverUIBundleDir` — with the multi-bundle acknowledgment of §5.1 item 2)
8
+ * `discoverUIBundleDir` — with the multi-bundle acknowledgment)
9
9
  * - the interactive `--target-org` fallback (`resolveTargetOrg`) and its
10
10
  * single-select picker (`promptSelect`)
11
11
  * - pure, unit-testable logic for the license gate, SOQL-name validation, org-list
@@ -27,35 +27,207 @@ import { resolve } from 'node:path';
27
27
  // ---------------------------------------------------------------------------
28
28
 
29
29
  /**
30
- * Validate that a profile name is safe to interpolate into a SOQL `WHERE Name =
31
- * '<name>'` clause (spec §5.3, Q10 — validate-and-fail, NOT an escaper). The
32
- * `selfRegProfile` is developer-authored config the app ships, and license /
33
- * profile names are not expected to contain special characters, so a `'`, `\`,
34
- * or control char is treated as a config mistake to surface loudly rather than a
35
- * value to silently escape. Returns the name unchanged when valid; throws a
36
- * clear config error otherwise. AC-8.
30
+ * Validate that a config-authored identifier is safe to interpolate into a SOQL
31
+ * `WHERE <field> = '<value>'` clause (validate-and-fail with a strict whitelist,
32
+ * NOT an escaper). These values (role name, site name,
33
+ * self-reg profile name) are developer-authored identifiers the app ships, not
34
+ * end-user data, and legitimate identifiers are drawn from letters, digits,
35
+ * spaces, hyphens, and underscores. Anything outside that set a `'`, `\`, `=`,
36
+ * `"`, `&`, `<`, `>`, punctuation, or a control char — is treated as a config
37
+ * mistake to surface loudly rather than a value to silently escape. The
38
+ * whitelist is deliberately broader than the injection characters alone so a
39
+ * single positive rule covers every SOQL/quoting hazard at once.
40
+ *
41
+ * NOTE: this is for config *identifiers*. Free-form display values that
42
+ * legitimately contain punctuation (e.g. an Account name like `O'Brien Rentals`)
43
+ * are escaped at their call site instead — see org-setup.mjs's Account path.
44
+ *
45
+ * @param {*} value the value to validate (coerced to string)
46
+ * @param {string} fieldLabel how to name the field in an error (e.g. "roleName")
47
+ * @param {object} [opts]
48
+ * @param {string} [opts.remediation] where the author fixes it, e.g.
49
+ * "org-setup.config.json" or "the networks/<siteName>.network-meta.xml filename".
50
+ * Defaults to org-setup.config.json.
51
+ * @returns {string} the value unchanged when valid; throws otherwise.
52
+ */
53
+ export function validateSoqlName(value, fieldLabel, opts = {}) {
54
+ const { remediation = 'org-setup.config.json' } = opts;
55
+ const s = String(value);
56
+ if (!/^[A-Za-z0-9 _-]+$/.test(s)) {
57
+ throw new Error(
58
+ `${fieldLabel} "${value}" contains an unsupported character ` +
59
+ `(only letters, digits, spaces, hyphens, and underscores are allowed). ` +
60
+ `Fix it in ${remediation}.`,
61
+ );
62
+ }
63
+ return s;
64
+ }
65
+
66
+ /**
67
+ * Back-compat wrapper for the self-reg profile name (the original call site).
68
+ * Delegates to the strict whitelist above; kept as a named export so
69
+ * existing importers and tests need no change beyond the tightened rule.
37
70
  */
38
71
  export function validateProfileNameForSoql(name) {
39
- const s = String(name);
40
- // SOQL string delimiter (') and escape char (\) would break/inject the query;
41
- // control chars (C0 + DEL) have no legitimate place in a profile name.
42
- if (/['\\]/.test(s) || /[\x00-\x1f\x7f]/.test(s)) {
72
+ return validateSoqlName(name, 'selfRegProfile name');
73
+ }
74
+
75
+ /**
76
+ * Escape a free-form value for safe interpolation inside a single-quoted SOQL
77
+ * string literal. Unlike validateSoqlName, this is for display
78
+ * values that legitimately contain punctuation — e.g. an Account name like
79
+ * `O'Brien Rentals & Co.` — where rejecting the value would be wrong. Escapes
80
+ * the backslash FIRST (so an already-present `\` isn't double-counted) then the
81
+ * quote, matching SOQL's `\'` / `\\` escape rules. Returns the escaped inner
82
+ * string (no surrounding quotes — the caller supplies those).
83
+ */
84
+ export function escapeSoqlString(value) {
85
+ return String(value).replace(/\\/g, '\\\\').replace(/'/g, "\\'");
86
+ }
87
+
88
+ /**
89
+ * Validate a Salesforce API name (an SObject type or a field API name) that is
90
+ * interpolated as a bare identifier into generated Apex / dynamic SOQL — e.g.
91
+ * `new ${sobject}()`, `r.put('${field}', …)`, or `Database.query('SELECT Id FROM
92
+ * ${sobject} …')`. Unlike a WHERE value, an identifier can't be
93
+ * quoted-and-escaped, so the only safe treatment is a strict whitelist. Valid API
94
+ * names are letters, digits, and underscores only (the `__c` / `__r` custom and
95
+ * namespace suffixes are already covered by that set); anything else — a quote,
96
+ * space, dot, or `)` — would let a crafted seed-data field name or sobject break
97
+ * out of the surrounding statement, so it is rejected loudly. These identifiers
98
+ * come from the app-shipped data plan / seed JSON, so a violation is a fixture
99
+ * bug, not user input; this keeps the identifier hardening symmetric with the
100
+ * value escaping (apexLiteral / escapeSoqlString) already applied around them.
101
+ *
102
+ * @param {*} value the identifier to validate (coerced to string)
103
+ * @param {string} fieldLabel how to name it in an error (e.g. "sobject type")
104
+ * @returns {string} the value unchanged when valid; throws otherwise.
105
+ */
106
+ export function validateApiName(value, fieldLabel) {
107
+ const s = String(value);
108
+ if (!/^[A-Za-z0-9_]+$/.test(s)) {
43
109
  throw new Error(
44
- `selfRegProfile name "${name}" contains an unsupported character ` +
45
- `(quotes, backslashes, and control characters are not allowed). ` +
46
- `Fix the profile name in org-setup.config.json.`,
110
+ `${fieldLabel} "${value}" is not a valid Salesforce API name ` +
111
+ `(only letters, digits, and underscores are allowed). ` +
112
+ `Check the seed data plan (data-plan.json) and record field names.`,
47
113
  );
48
114
  }
49
115
  return s;
50
116
  }
51
117
 
118
+ /**
119
+ * Parse the single `SETUP_RESULT_JSON:` marker line that buildApexInsert emits
120
+ * from anonymous Apex. Replaces the old per-record REF:/ERR: debug
121
+ * scraping. The marker carries a JSON array of `{ ref, id? , err? }` — one entry
122
+ * per inserted record.
123
+ *
124
+ * Robustness is the whole point: a debug log can be truncated, so a missing
125
+ * marker or a payload that doesn't parse as the expected array is reported as a
126
+ * hard failure via `ok: false` rather than silently reading as "0 records, 0
127
+ * errors" (which would let a partial import look successful).
128
+ *
129
+ * @param {string} apexOut raw stdout from `sf apex run`
130
+ * @returns {{ ok: boolean, error: string|null,
131
+ * successes: Array<{ref:string, id:string}>,
132
+ * errors: Array<{ref:string, message:string}> }}
133
+ */
134
+ export function parseApexInsertResults(apexOut) {
135
+ const text = String(apexOut ?? '');
136
+ // The line looks like: `...|DEBUG|SETUP_RESULT_JSON:[{"ref":"a","id":"001..."}]`.
137
+ // Anchor on the marker and take the rest of that line.
138
+ const markerIdx = text.indexOf('SETUP_RESULT_JSON:');
139
+ if (markerIdx === -1) {
140
+ return {
141
+ ok: false,
142
+ error: 'no SETUP_RESULT_JSON marker found in Apex output (log may be truncated or the insert did not run)',
143
+ successes: [],
144
+ errors: [],
145
+ };
146
+ }
147
+ const afterMarker = text.slice(markerIdx + 'SETUP_RESULT_JSON:'.length);
148
+ // The payload runs to the end of the debug line; stop at the first newline.
149
+ const payload = afterMarker.split('\n')[0].trim();
150
+
151
+ let parsed;
152
+ try {
153
+ parsed = JSON.parse(payload);
154
+ } catch {
155
+ return {
156
+ ok: false,
157
+ error: 'SETUP_RESULT_JSON payload was not valid JSON (log line may be truncated)',
158
+ successes: [],
159
+ errors: [],
160
+ };
161
+ }
162
+ if (!Array.isArray(parsed)) {
163
+ return {
164
+ ok: false,
165
+ error: 'SETUP_RESULT_JSON payload was not a JSON array',
166
+ successes: [],
167
+ errors: [],
168
+ };
169
+ }
170
+
171
+ const successes = [];
172
+ const errors = [];
173
+ for (const entry of parsed) {
174
+ if (!entry || typeof entry !== 'object') continue;
175
+ const ref = entry.ref != null ? String(entry.ref) : '';
176
+ if (entry.err != null) {
177
+ errors.push({ ref, message: String(entry.err) });
178
+ } else if (entry.id != null) {
179
+ successes.push({ ref, id: String(entry.id) });
180
+ }
181
+ }
182
+ return { ok: true, error: null, successes, errors };
183
+ }
184
+
185
+ /**
186
+ * Group serialized-record sizes into batches whose combined Apex character count
187
+ * (fixed per-batch overhead + each record's rendered size) stays under
188
+ * `charLimit`, subject to `maxBatch` records per batch. Replaces the
189
+ * old `estCharsPerRec = 40 + fields*55` heuristic with a measurement the caller
190
+ * supplies via `recordSizes`.
191
+ *
192
+ * Guarantees at least one record per batch even when a single record's size (plus
193
+ * overhead) already exceeds `charLimit` — otherwise a large record would loop
194
+ * forever or emit an empty batch. That oversized record is placed alone in its
195
+ * own batch and left for Apex to accept or reject.
196
+ *
197
+ * @param {number[]} recordSizes rendered Apex char length of each record, in order
198
+ * @param {object} opts
199
+ * @param {number} opts.charLimit max total chars per batch (record chars + overhead)
200
+ * @param {number} opts.maxBatch max records per batch
201
+ * @param {number} [opts.overhead] fixed per-batch char cost (DML boilerplate); default 0
202
+ * @returns {Array<{start:number, count:number}>} batch slices over the input order
203
+ */
204
+ export function planApexBatches(recordSizes, { charLimit, maxBatch, overhead = 0 }) {
205
+ const batches = [];
206
+ let start = 0;
207
+ const n = recordSizes.length;
208
+ while (start < n) {
209
+ let count = 0;
210
+ let total = overhead;
211
+ while (start + count < n && count < maxBatch) {
212
+ const next = recordSizes[start + count];
213
+ // Always take at least one record, even if it alone busts the limit.
214
+ if (count > 0 && total + next > charLimit) break;
215
+ total += next;
216
+ count += 1;
217
+ }
218
+ batches.push({ start, count });
219
+ start += count;
220
+ }
221
+ return batches;
222
+ }
223
+
52
224
  /**
53
225
  * Decide whether the self-reg profile's UserLicense is satisfied, from the rows
54
226
  * a `SELECT UserLicense.LicenseDefinitionKey, UserLicense.Name, UserLicense.Status,
55
227
  * UserLicense.TotalLicenses, UserLicense.UsedLicenses FROM Profile WHERE Name = '…'`
56
- * query returns (spec §5.3). The seat math MUST be done here in JS — SOQL cannot
57
- * compare two fields (Q3, verified live). Matches/report on the stable
58
- * `LicenseDefinitionKey`, never the display Name (AC-2b).
228
+ * query returns. The seat math MUST be done here in JS — SOQL cannot
229
+ * compare two fields. Matches/report on the stable
230
+ * `LicenseDefinitionKey`, never the display Name.
59
231
  *
60
232
  * satisfied ⇔ Status === 'Active' && (total === -1 [unlimited sentinel] || total - used > 0).
61
233
  * 0 rows / null UserLicense ⇒ not satisfied, with a reason naming the profile.
@@ -85,7 +257,7 @@ export function evaluateLicenseRows(rows, profileName) {
85
257
  if (!Number.isFinite(total) || !Number.isFinite(used)) {
86
258
  return { satisfied: false, reason: `could not read seat counts for license ${label} (${key})`, license };
87
259
  }
88
- // -1 is the documented "unlimited" sentinel (defensive — unverified live, §5.3).
260
+ // -1 is the documented "unlimited" sentinel (defensive — unverified live).
89
261
  if (total === -1 || total - used > 0) {
90
262
  return { satisfied: true, reason: null, license };
91
263
  }
@@ -98,7 +270,7 @@ export function evaluateLicenseRows(rows, profileName) {
98
270
 
99
271
  /**
100
272
  * Parse `sf org list --json` defensively into a flat org list + the default org
101
- * (spec §5.1 item 3; mirrors isOrgConnected's try/catch tolerance). Collects
273
+ * (mirrors isOrgConnected's try/catch tolerance). Collects
102
274
  * every array under `result` (nonScratchOrgs, scratchOrgs, sandboxes, devHubs, …)
103
275
  * so the picker sees all authenticated orgs; the default is the entry marked
104
276
  * `isDefaultUsername`. Malformed / unexpected JSON ⇒ empty result (caller then
@@ -136,8 +308,8 @@ export function parseOrgList(jsonText) {
136
308
  }
137
309
 
138
310
  /**
139
- * Multi-line warning for the non-TTY multi-bundle case (spec §5.1 item 2, AC-4b):
140
- * there is nothing to interactively acknowledge, so keep the deterministic
311
+ * Multi-line warning for the non-TTY multi-bundle case: there is nothing to
312
+ * interactively acknowledge, so keep the deterministic
141
313
  * `all[0]` pick but never do it silently — name every bundle, the chosen one, and
142
314
  * the `--ui-bundle-name` remedy.
143
315
  */
@@ -233,13 +405,12 @@ export function discoverAllUIBundleDirs(uiBundlesDir, uiBundleName) {
233
405
 
234
406
  /**
235
407
  * Resolve the single UI bundle to operate on, with the multi-bundle
236
- * acknowledgment of spec §5.1 item 2 (Q5). When >1 bundle exists and no
237
- * `--ui-bundle-name` was given:
408
+ * acknowledgment. When >1 bundle exists and no `--ui-bundle-name` was given:
238
409
  * - TTY: present a single-select picker (all bundles, alphabetical all[0]
239
- * preselected) — enter accepts all[0], arrows choose another (AC-4).
410
+ * preselected) — enter accepts all[0], arrows choose another.
240
411
  * - non-TTY: keep the deterministic all[0] pick but emit the multi-line warning
241
- * (AC-4b) — never a silent pick.
242
- * Explicit `--ui-bundle-name` short-circuits both (no prompt, no warning; AC-4c).
412
+ * — never a silent pick.
413
+ * Explicit `--ui-bundle-name` short-circuits both (no prompt, no warning).
243
414
  */
244
415
  export async function discoverUIBundleDir(uiBundlesDir, uiBundleName) {
245
416
  const all = discoverAllUIBundleDirs(uiBundlesDir, uiBundleName);
@@ -265,7 +436,7 @@ export async function discoverUIBundleDir(uiBundlesDir, uiBundleName) {
265
436
  * Interactive single-select: arrow keys navigate, enter confirms. Returns the
266
437
  * chosen index. Falls through to `preselectedIndex` immediately when stdin is not
267
438
  * a TTY. Built on the same raw-mode scaffolding as promptSteps (org-setup.mjs)
268
- * but selects exactly one item (spec §5.1 items 2 & 3).
439
+ * but selects exactly one item.
269
440
  */
270
441
  export function promptSelect(options, { prompt = 'Select an option:', preselectedIndex = 0 } = {}) {
271
442
  if (!process.stdin.isTTY || options.length === 0) return Promise.resolve(preselectedIndex);
@@ -325,14 +496,13 @@ export function promptSelect(options, { prompt = 'Select an option:', preselecte
325
496
  }
326
497
 
327
498
  /**
328
- * Resolve the target org (spec §5.1 item 3, §15). When `--target-org` is supplied
329
- * it is returned verbatim with NO `sf org list` call (AC-7, byte-for-byte the
330
- * scripted path). Otherwise discover authenticated orgs via `sf org list --json`
331
- * and:
332
- * - TTY: present a picker with the default org at the top and preselected
333
- * (AC-5); enter selects the default.
499
+ * Resolve the target org. When `--target-org` is supplied it is returned
500
+ * verbatim with NO `sf org list` call (byte-for-byte the scripted path).
501
+ * Otherwise discover authenticated orgs via `sf org list --json` and:
502
+ * - TTY: present a picker with the default org at the top and preselected;
503
+ * enter selects the default.
334
504
  * - non-TTY: use the default org if resolvable, else exit 1 with a clear
335
- * message (AC-6).
505
+ * message.
336
506
  */
337
507
  export async function resolveTargetOrg(parsed) {
338
508
  if (parsed.targetOrg) return parsed.targetOrg;
@@ -65,6 +65,11 @@ import {
65
65
  resolveTargetOrg,
66
66
  evaluateLicenseRows,
67
67
  validateProfileNameForSoql,
68
+ validateSoqlName,
69
+ validateApiName,
70
+ escapeSoqlString,
71
+ parseApexInsertResults,
72
+ planApexBatches,
68
73
  } from './org-setup-utils.mjs';
69
74
 
70
75
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -296,8 +301,8 @@ function parseArgs() {
296
301
  else if (args[i] === '--skip-self-reg') flags.skipSelfReg = true;
297
302
  else if (args[i] === '--skip-graphql') flags.skipGraphql = true;
298
303
  else if (args[i] === '--skip-ui-bundle-build') flags.skipUIBundleBuild = true;
299
- // --skip-login and --skip-dev are retired (spec §5.5/§5.6, AC-14): login is now
300
- // an unconditional precondition and the dev step moved to `npm run dev:preview`.
304
+ // --skip-login and --skip-dev are retired: login is now an unconditional
305
+ // precondition and the dev step moved to `npm run dev:preview`.
301
306
  // Accept them silently as no-ops so existing invocations don't hard-error.
302
307
  else if (args[i] === '--skip-login' || args[i] === '--skip-dev') {
303
308
  /* no-op (retired flag) */
@@ -344,7 +349,7 @@ Permset config (scripts/org-setup.config.json):
344
349
  process.exit(0);
345
350
  }
346
351
  }
347
- // NOTE: no hard-exit on a missing --target-org here (spec §5.4). Resolution is
352
+ // NOTE: no hard-exit on a missing --target-org here. Resolution is
348
353
  // deferred to resolveTargetOrg(), which either prompts (TTY) or falls back to
349
354
  // the default org / exits with a clear message (non-TTY).
350
355
  return { targetOrg, uiBundleName, permsetNamesExplicit, yes, ...flags };
@@ -352,8 +357,8 @@ Permset config (scripts/org-setup.config.json):
352
357
 
353
358
  // Bundle discovery lives in org-setup-utils.mjs so org-setup-dev.mjs shares it verbatim.
354
359
  // These thin wrappers bind the shared helpers to this script's UIBUNDLES_DIR and
355
- // keep the existing call-site signatures. discoverUIBundleDir now carries the
356
- // multi-bundle acknowledgment of spec §5.1 item 2 (TTY picker / non-TTY warning).
360
+ // keep the existing call-site signatures. discoverUIBundleDir carries the
361
+ // multi-bundle acknowledgment (TTY picker / non-TTY warning).
357
362
  function discoverAllUIBundleDirs(uiBundleName) {
358
363
  return discoverAllUIBundleDirsIn(UIBUNDLES_DIR, uiBundleName);
359
364
  }
@@ -420,7 +425,15 @@ function deriveSiteName() {
420
425
  `cannot derive guest site: multiple network metadata files found in ${networksDir} (${files.join(', ')}); guestUser assignment requires exactly one`,
421
426
  );
422
427
  }
423
- return files[0].replace(/\.network-meta\.xml$/, '');
428
+ const siteName = files[0].replace(/\.network-meta\.xml$/, '');
429
+ // The site name is interpolated into SOQL (Network lookup, guest-user lookup),
430
+ // so validate it here — the single chokepoint both callers pass through. Its
431
+ // source is the metadata filename, not org-setup.config.json, so point the fix
432
+ // there.
433
+ validateSoqlName(siteName, 'site name', {
434
+ remediation: `the networks/<siteName>.network-meta.xml filename (currently "${files[0]}")`,
435
+ });
436
+ return siteName;
424
437
  }
425
438
 
426
439
  /**
@@ -603,8 +616,13 @@ function enableSelfRegistration(selfRegConfig, siteName, targetOrg) {
603
616
  }
604
617
  }
605
618
 
606
- // 3. Create Account (idempotent)
607
- const acctQuery = `SELECT Id FROM Account WHERE Name = '${accountName.replace(/'/g, "\\'")}' LIMIT 1`;
619
+ // 3. Create Account (idempotent).
620
+ // accountName is a free-form display value (e.g. "O'Brien Rentals & Co.") that
621
+ // legitimately contains punctuation, so it is ESCAPED rather than whitelisted:
622
+ // escape it for the SOQL read here, and create it via Apex below
623
+ // (apexLiteral-escaped) rather than the `--values Name='...'` CLI arg, whose
624
+ // space-separated key=value parsing an escaped name cannot safely satisfy.
625
+ const acctQuery = `SELECT Id FROM Account WHERE Name = '${escapeSoqlString(accountName)}' LIMIT 1`;
608
626
  const acctQueryResult = spawnSync('sf', [
609
627
  'data', 'query',
610
628
  '--query', acctQuery,
@@ -621,24 +639,34 @@ function enableSelfRegistration(selfRegConfig, siteName, targetOrg) {
621
639
  if (accountId) {
622
640
  console.log(` Account "${accountName}" already exists (${accountId}); skipping creation.`);
623
641
  } else {
624
- const createResult = spawnSync('sf', [
625
- 'data', 'create', 'record',
626
- '--sobject', 'Account',
627
- '--values', `Name='${accountName}'`,
628
- '--target-org', targetOrg,
629
- '--json',
630
- ], { cwd: ROOT, encoding: 'utf8' });
631
- if (createResult.status !== 0) {
632
- if (createResult.stderr) console.error(createResult.stderr);
633
- throw new StepError(`failed to create Account "${accountName}"`);
634
- }
635
- try {
636
- const json = JSON.parse(createResult.stdout);
637
- accountId = json.result?.id;
638
- console.log(` Created Account "${accountName}" (${accountId}).`);
639
- } catch {
642
+ // Create via Anonymous Apex (apexLiteral escapes the name) instead of
643
+ // `sf data create record --values`: the CLI parses --values as
644
+ // space-separated key=value pairs, so a name with a space, quote, or `&`
645
+ // either mis-parses or opens a field-injection surface. Apex string
646
+ // escaping closes that off and mirrors the NSR create just below.
647
+ const apex = [
648
+ `Account acct = new Account(Name = ${apexLiteral(accountName)});`,
649
+ `insert acct;`,
650
+ `System.debug('ACCOUNT_CREATED:' + acct.Id);`,
651
+ ].join('\n');
652
+ const apexOut = withApexTempDir((writeApex) => {
653
+ const tmpApex = writeApex('account.apex', apex);
654
+ const apexResult = spawnSync('sf', [
655
+ 'apex', 'run', '--target-org', targetOrg, '--file', tmpApex,
656
+ ], { cwd: ROOT, stdio: 'pipe', shell: true, timeout: 60000 });
657
+ const out = apexResult.stdout?.toString() || '';
658
+ if (apexResult.status !== 0 && !out.includes('Compiled successfully')) {
659
+ process.stderr.write(apexResult.stderr?.toString() || out);
660
+ throw new StepError(`failed to create Account "${accountName}"`);
661
+ }
662
+ return out;
663
+ });
664
+ const acctMatch = apexOut.match(/ACCOUNT_CREATED:(\w+)/);
665
+ if (!acctMatch) {
640
666
  throw new StepError('failed to parse Account creation result');
641
667
  }
668
+ accountId = acctMatch[1];
669
+ console.log(` Created Account "${accountName}" (${accountId}).`);
642
670
  }
643
671
 
644
672
  // 4. Query Network Id
@@ -713,6 +741,7 @@ function enableSelfRegistration(selfRegConfig, siteName, targetOrg) {
713
741
  * works correctly.
714
742
  */
715
743
  function assignRoleToCurrentUser(roleName, targetOrg) {
744
+ validateSoqlName(roleName, 'role name');
716
745
  const roleQuery = `SELECT Id FROM UserRole WHERE Name = '${roleName}'`;
717
746
  const roleResult = spawnSync('sf', [
718
747
  'data', 'query',
@@ -793,10 +822,30 @@ function assignRoleToCurrentUser(roleName, targetOrg) {
793
822
  }
794
823
 
795
824
  /**
796
- * Query the org for a guest user whose profile name matches the given site name.
825
+ * Query the org for the guest user of the given site.
826
+ *
827
+ * Salesforce auto-creates a guest profile named "<Site> Profile" for each
828
+ * Experience Cloud site. The old `Profile.Name LIKE '%<siteName>%'` substring
829
+ * match collided across sites — e.g. site "shop" also matched "shop-admin"'s
830
+ * guest profile — and then blindly took records[0]. This uses an exact match on
831
+ * the profile name instead. SOQL string equality on a text field is
832
+ * case-insensitive by default (it wraps both sides in UPPER()), so a site whose
833
+ * label casing differs from the derived name still matches without explicit
834
+ * normalization. siteName is whitelist-validated at the deriveSiteName()
835
+ * chokepoint, so it is safe to interpolate here.
836
+ *
837
+ * Residual limitation: on a non-English org the auto-generated " Profile" suffix
838
+ * may be localized, in which case no row matches and the assignment soft-skips
839
+ * (returned null) with a clear message — the caller records that as a skip, not
840
+ * a crash.
841
+ *
842
+ * Returns the guest Username, or null (with a logged reason) when the query
843
+ * fails, no guest user exists, or the match is ambiguous.
797
844
  */
798
845
  function resolveGuestUsername(siteName, targetOrg) {
799
- const query = `SELECT Username FROM User WHERE Profile.Name LIKE '%${siteName}%' AND UserType = 'Guest'`;
846
+ const query =
847
+ `SELECT Username FROM User ` +
848
+ `WHERE Profile.Name = '${siteName} Profile' AND UserType = 'Guest'`;
800
849
  const result = spawnSync('sf', [
801
850
  'data', 'query',
802
851
  '--query', query,
@@ -812,7 +861,15 @@ function resolveGuestUsername(siteName, targetOrg) {
812
861
  const json = JSON.parse(result.stdout);
813
862
  const records = json.result?.records;
814
863
  if (!records || records.length === 0) {
815
- console.error(` No guest user found for site "${siteName}".`);
864
+ console.error(` No guest user found for site "${siteName}" (looked for profile "${siteName} Profile").`);
865
+ return null;
866
+ }
867
+ if (records.length > 1) {
868
+ const names = records.map((r) => r.Username).join(', ');
869
+ console.error(
870
+ ` Ambiguous guest user for site "${siteName}": ${records.length} guest users share ` +
871
+ `profile "${siteName} Profile" (${names}); refusing to guess.`,
872
+ );
816
873
  return null;
817
874
  }
818
875
  return records[0].Username;
@@ -823,9 +880,9 @@ function resolveGuestUsername(siteName, targetOrg) {
823
880
  }
824
881
 
825
882
  /**
826
- * M1 license pre-check (spec §5.3). Query the UserLicense that the selfRegProfile
827
- * belongs to and decide whether self-registration can proceed. The profile name
828
- * is validate-and-fail (Q10, AC-8): a SOQL-special character throws a config
883
+ * License pre-check for self-registration. Query the UserLicense that the
884
+ * selfRegProfile belongs to and decide whether self-registration can proceed.
885
+ * The profile name is validate-and-fail: a SOQL-special character throws a config
829
886
  * error rather than being escaped — profile names are developer-authored config,
830
887
  * not user input, so a `'` / `\` / control char is a mistake to surface loudly.
831
888
  *
@@ -887,7 +944,7 @@ function readProfileUserLicense(selfRegProfile) {
887
944
  }
888
945
 
889
946
  /**
890
- * Deploy license pre-check (spec §5.3). `sf project deploy start` fails with a
947
+ * Deploy license pre-check. `sf project deploy start` fails with a
891
948
  * cryptic error when the org lacks the UserLicense the selfRegProfile's metadata
892
949
  * declares. Resolve the required license NAME from the local profile source, then
893
950
  * query the org's UserLicense by that name so a miss names the actual license the
@@ -902,10 +959,9 @@ function checkDeployLicense(selfRegConfig, targetOrg) {
902
959
  // gate on; let deploy proceed and surface any real error on its own.
903
960
  if (!licenseName) return { satisfied: true, reason: null };
904
961
 
905
- const escaped = licenseName.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
906
962
  const query =
907
963
  `SELECT LicenseDefinitionKey, Name, Status, TotalLicenses, UsedLicenses ` +
908
- `FROM UserLicense WHERE Name = '${escaped}'`;
964
+ `FROM UserLicense WHERE Name = '${escapeSoqlString(licenseName)}'`;
909
965
  const result = spawnSync('sf', [
910
966
  'data', 'query',
911
967
  '--query', query,
@@ -958,8 +1014,20 @@ function apexLiteral(value) {
958
1014
  }
959
1015
 
960
1016
  function buildApexInsert(sobject, records, refIds) {
1017
+ // The sobject type and every field key land as bare identifiers inside the
1018
+ // generated Apex (`new ${sobject}()`, `r.put('${key}', …)`), where they can't
1019
+ // be quoted-and-escaped like a value — so they get the strict API-name
1020
+ // whitelist. refIds are the record referenceIds echoed back through a JSON
1021
+ // marker; they carry no Apex-identifier role, so escaping (not whitelisting)
1022
+ // is the right treatment for their single-quoted literal. All three come from
1023
+ // app-shipped seed fixtures, so a violation is a fixture bug surfaced loudly.
1024
+ validateApiName(sobject, 'sobject type');
961
1025
  const lines = [
962
1026
  'Database.DMLOptions dmlOpts = new Database.DMLOptions();',
1027
+ // Intentional: bypass duplicate rules for seed-data import. These are
1028
+ // controlled fixtures the app ships, not user input, and duplicate-rule
1029
+ // blocks (plus matching-service timeouts the REST Sforce-Duplicate-Rule-Action
1030
+ // header can't override) would abort an otherwise-valid setup.
963
1031
  'dmlOpts.DuplicateRuleHeader.allowSave = true;',
964
1032
  `List<${sobject}> recs = new List<${sobject}>();`,
965
1033
  ];
@@ -967,17 +1035,28 @@ function buildApexInsert(sobject, records, refIds) {
967
1035
  lines.push(`{ ${sobject} r = new ${sobject}();`);
968
1036
  for (const [key, val] of Object.entries(rec)) {
969
1037
  if (key === 'attributes') continue;
1038
+ validateApiName(key, `field API name on ${sobject}`);
970
1039
  lines.push(`r.put('${key}', ${apexLiteral(val)});`);
971
1040
  }
972
1041
  lines.push('recs.add(r); }');
973
1042
  }
974
1043
  lines.push('Database.SaveResult[] results = Database.insert(recs, dmlOpts);');
975
- const refArray = refIds.map((r) => `'${r}'`).join(',');
1044
+ const refArray = refIds.map((r) => `'${escapeSoqlString(r)}'`).join(',');
976
1045
  lines.push(`String[] refs = new String[]{${refArray}};`);
1046
+ // Emit ONE machine-readable line instead of one System.debug per record: a
1047
+ // JSON array of {ref, id?|err?}. The Node side parses this with
1048
+ // parseApexInsertResults and treats a missing/unparseable marker as a hard
1049
+ // batch failure (a truncated debug line must not read as "0 errors"). Measured
1050
+ // batching keeps each batch — and thus this payload — well under the log line
1051
+ // size.
1052
+ lines.push('List<Object> setupOut = new List<Object>();');
977
1053
  lines.push('for (Integer i = 0; i < results.size(); i++) {');
978
- lines.push(" if (results[i].isSuccess()) System.debug('REF:' + refs[i] + ':' + results[i].getId());");
979
- lines.push(" else System.debug('ERR:' + refs[i] + ':' + results[i].getErrors()[0].getMessage());");
1054
+ lines.push(' Map<String,Object> m = new Map<String,Object>{ \'ref\' => refs[i] };');
1055
+ lines.push(' if (results[i].isSuccess()) { m.put(\'id\', results[i].getId()); }');
1056
+ lines.push(' else { m.put(\'err\', results[i].getErrors()[0].getMessage()); }');
1057
+ lines.push(' setupOut.add(m);');
980
1058
  lines.push('}');
1059
+ lines.push("System.debug('SETUP_RESULT_JSON:' + JSON.serialize(setupOut));");
981
1060
  return lines.join('\n');
982
1061
  }
983
1062
 
@@ -1242,8 +1321,8 @@ async function main() {
1242
1321
  skipUIBundleBuild: argSkipUIBundleBuild,
1243
1322
  } = parsed;
1244
1323
 
1245
- // Resolve the target org up front (spec §5.4): explicit --target-org is used
1246
- // verbatim; otherwise prompt (TTY) or fall back to the default org / exit.
1324
+ // Resolve the target org up front: explicit --target-org is used verbatim;
1325
+ // otherwise prompt (TTY) or fall back to the default org / exit.
1247
1326
  const targetOrg = await resolveTargetOrg(parsed);
1248
1327
 
1249
1328
  // Per-target-org lock: acquired right after the start-of-run
@@ -1271,11 +1350,11 @@ async function main() {
1271
1350
  const selfRegConfig = loadSelfRegConfig(config);
1272
1351
  const hasSelfRegConfig = selfRegConfig !== null;
1273
1352
 
1274
- // Validate the selfRegProfile name for SOQL-safety up front (spec §5.3, AC-8),
1275
- // alongside the config validation and BEFORE any org mutation (login/deploy).
1276
- // A quote / backslash / control char is a config mistake, not a runtime value
1277
- // to escape — fail fast here with a clean, actionable message rather than
1278
- // letting the M1 gate throw a raw stack trace deep in the run after deploy.
1353
+ // Validate the selfRegProfile name for SOQL-safety up front, alongside the
1354
+ // config validation and BEFORE any org mutation (login/deploy). A quote /
1355
+ // backslash / control char is a config mistake, not a runtime value to
1356
+ // escape — fail fast here with a clean, actionable message rather than
1357
+ // letting the license gate throw a raw stack trace deep in the run after deploy.
1279
1358
  if (hasSelfRegConfig) {
1280
1359
  try {
1281
1360
  validateProfileNameForSoql(selfRegConfig.selfRegProfile);
@@ -1285,14 +1364,14 @@ async function main() {
1285
1364
  }
1286
1365
  }
1287
1366
 
1288
- // failFast is a fixed, implementer-owned classification (spec §5.2) — NOT
1367
+ // failFast is a fixed, implementer-owned classification — NOT
1289
1368
  // user-configurable. A fail-fast failure aborts the run immediately; a
1290
1369
  // skippable failure is recorded and the run continues. The exit code is
1291
1370
  // non-zero on any failure regardless of class.
1292
- // Login is NOT in this picker (spec §5.6, AC-13): it is an unconditional
1293
- // precondition run before deploy, not a toggleable step. The dev step is also
1294
- // gone (spec §5.5): launching the dev server moved to `npm run dev:preview`, so
1295
- // setup terminates cleanly after graphql (AC-11).
1371
+ // Login is NOT in this picker: it is an unconditional precondition run before
1372
+ // deploy, not a toggleable step. The dev step is also gone: launching the dev
1373
+ // server moved to `npm run dev:preview`, so setup terminates cleanly after
1374
+ // graphql.
1296
1375
  const stepDefs = [
1297
1376
  { key: 'uiBundleBuild', label: 'UI Bundle Build — npm install + build (pre-deploy)', enabled: !argSkipUIBundleBuild, available: true, failFast: true },
1298
1377
  { key: 'deploy', label: 'Deploy — sf project deploy start', enabled: !argSkipDeploy, available: true, failFast: true },
@@ -1333,7 +1412,7 @@ async function main() {
1333
1412
  !skipUIBundleBuild
1334
1413
  );
1335
1414
 
1336
- // Login is an unconditional precondition (spec §5.6, AC-13): it is not part of
1415
+ // Login is an unconditional precondition: it is not part of
1337
1416
  // the step picker and cannot be skipped. It still no-ops when the org is already
1338
1417
  // connected, and stays fail-fast — a failed browser login aborts before deploy.
1339
1418
  const loginStep = { key: 'login', label: 'Login — org authentication', failFast: true };
@@ -1389,7 +1468,7 @@ async function main() {
1389
1468
  const deployStep = stepDefs.find((s) => s.key === 'deploy');
1390
1469
  if (!skipDeploy) {
1391
1470
  await runStep(deployStep, targetOrg, () => {
1392
- // License pre-check (spec §5.3): deploy fails with a cryptic error when the
1471
+ // License pre-check: deploy fails with a cryptic error when the
1393
1472
  // org lacks the UserLicense the selfRegProfile's metadata requires. Verify a
1394
1473
  // seat is available BEFORE `sf project deploy start` so the run aborts with a
1395
1474
  // clean message NAMING the missing license. The required license is read from
@@ -1510,7 +1589,7 @@ async function main() {
1510
1589
 
1511
1590
  const selfRegStep = stepDefs.find((s) => s.key === 'selfReg');
1512
1591
  if (!skipSelfReg) {
1513
- // M1 license pre-check (spec §5.3): self-registration requires a seat on the
1592
+ // License pre-check: self-registration requires a seat on the
1514
1593
  // UserLicense the selfRegProfile belongs to. Verify it BEFORE running the step
1515
1594
  // — recordSkipped is only reachable here, ahead of runStep (mirroring the
1516
1595
  // not-selected branch); once inside a step body an unmet precondition could
@@ -1563,6 +1642,10 @@ async function main() {
1563
1642
  // always removed in finally, even if a batch throws.
1564
1643
  withApexTempDir((writeApex) => {
1565
1644
  for (const sobject of sobjectsReversed) {
1645
+ // Bare identifier inside a dynamic-SOQL string literal — whitelist it (the
1646
+ // same guard buildApexInsert applies) so a malformed data-plan sobject
1647
+ // can't alter the delete query.
1648
+ validateApiName(sobject, 'sobject type');
1566
1649
  const apexCode = [
1567
1650
  'try {',
1568
1651
  ` List<SObject> recs = Database.query('SELECT Id FROM ${sobject} LIMIT 10000');`,
@@ -1587,7 +1670,7 @@ async function main() {
1587
1670
  // Import via Anonymous Apex with Database.DMLOptions.duplicateRuleHeader.allowSave = true.
1588
1671
  // This bypasses both duplicate-rule blocks AND matching-service timeouts that the REST
1589
1672
  // API headers (Sforce-Duplicate-Rule-Action) cannot override.
1590
- console.log('\n--- Data import tree ---');
1673
+ console.log('\n--- Data import (Apex) ---');
1591
1674
  const refMap = new Map();
1592
1675
  const APEX_CHAR_LIMIT = 25000;
1593
1676
  const APEX_MAX_BATCH = 200;
@@ -1612,14 +1695,37 @@ async function main() {
1612
1695
  }
1613
1696
  }
1614
1697
 
1698
+ // Measured batching: size each record's rendered Apex (using
1699
+ // the same apexLiteral the insert uses) and pack batches under the char
1700
+ // limit, instead of the old estCharsPerRec = 40 + fields*55 guess that
1701
+ // under-counted long text fields and could overflow the anon-Apex limit.
1702
+ // Overhead is measured from the real builder with zero records so the
1703
+ // fixed boilerplate can't drift from buildApexInsert. Each record also
1704
+ // grows the per-batch `String[] refs = new String[]{'…',…}` line, so its
1705
+ // rendered refId literal is counted here too (mirroring the same
1706
+ // referenceId/_idx fallback the batch emit uses) — otherwise sizing
1707
+ // under-counts and a packed batch could edge past APEX_CHAR_LIMIT.
1708
+ const overhead = buildApexInsert(entry.sobject, [], []).length;
1709
+ const recordSizes = records.map((rec, i) => {
1710
+ let size = `{ ${entry.sobject} r = new ${entry.sobject}();\n`.length + 'recs.add(r); }\n'.length;
1711
+ for (const [key, val] of Object.entries(rec)) {
1712
+ if (key === 'attributes') continue;
1713
+ size += `r.put('${key}', ${apexLiteral(val)});\n`.length;
1714
+ }
1715
+ const refId = rec.attributes?.referenceId || `_idx${i}`;
1716
+ size += `'${escapeSoqlString(refId)}',`.length;
1717
+ return size;
1718
+ });
1719
+ const batches = planApexBatches(recordSizes, {
1720
+ charLimit: APEX_CHAR_LIMIT,
1721
+ maxBatch: APEX_MAX_BATCH,
1722
+ overhead,
1723
+ });
1724
+
1615
1725
  let imported = 0;
1616
- const sampleRec = records[0] || {};
1617
- const fieldsPerRec = Object.keys(sampleRec).filter((k) => k !== 'attributes').length;
1618
- const estCharsPerRec = 40 + fieldsPerRec * 55;
1619
- const batchSize = Math.min(APEX_MAX_BATCH, Math.max(5, Math.floor(APEX_CHAR_LIMIT / estCharsPerRec)));
1620
- for (let i = 0; i < records.length; i += batchSize) {
1621
- const batch = records.slice(i, i + batchSize);
1622
- const refIds = batch.map((r) => r.attributes?.referenceId || `_idx${i}`);
1726
+ for (const { start, count } of batches) {
1727
+ const batch = records.slice(start, start + count);
1728
+ const refIds = batch.map((r, j) => r.attributes?.referenceId || `_idx${start + j}`);
1623
1729
  const apex = buildApexInsert(entry.sobject, batch, refIds);
1624
1730
  const tmpApex = writeApex('data.apex', apex);
1625
1731
  const apexResult = spawnSync(
@@ -1633,19 +1739,24 @@ async function main() {
1633
1739
  process.stderr.write(apexErr || apexOut);
1634
1740
  throw new StepError(`${entry.sobject}: apex execution failed`);
1635
1741
  }
1636
- const okMatches = [...apexOut.matchAll(/\|DEBUG\|REF:([^:\n]+):(\w+)/g)];
1637
- const errMatches = [...apexOut.matchAll(/\|DEBUG\|ERR:([^:\n]+):([^\n]+)/g)];
1638
- if (errMatches.length) {
1639
- for (const m of errMatches.slice(0, 5)) {
1640
- console.error(` ${m[1]}: ${m[2].trim()}`);
1742
+ const parsed = parseApexInsertResults(apexOut);
1743
+ if (!parsed.ok) {
1744
+ // A missing/truncated result marker must fail loudly — never read as
1745
+ // "0 errors" and let a partial import look successful.
1746
+ process.stderr.write(apexErr || apexOut);
1747
+ throw new StepError(`data import (${entry.sobject}) — ${parsed.error}`);
1748
+ }
1749
+ if (parsed.errors.length) {
1750
+ for (const e of parsed.errors.slice(0, 5)) {
1751
+ console.error(` ${e.ref}: ${e.message.trim()}`);
1641
1752
  }
1642
- if (errMatches.length > 5) console.error(` ... and ${errMatches.length - 5} more`);
1643
- throw new StepError(`data import tree (${entry.sobject}) — ${errMatches.length} record error(s)`);
1753
+ if (parsed.errors.length > 5) console.error(` ... and ${parsed.errors.length - 5} more`);
1754
+ throw new StepError(`data import (${entry.sobject}) — ${parsed.errors.length} record error(s)`);
1644
1755
  }
1645
1756
  if (entry.saveRefs) {
1646
- for (const m of okMatches) refMap.set(m[1], m[2]);
1757
+ for (const s of parsed.successes) refMap.set(s.ref, s.id);
1647
1758
  }
1648
- imported += okMatches.length;
1759
+ imported += parsed.successes.length;
1649
1760
  }
1650
1761
  console.log(` ${entry.sobject}: imported ${imported} records`);
1651
1762
  }
@@ -1687,7 +1798,7 @@ async function main() {
1687
1798
  recordOk(uiBundleBuildStep);
1688
1799
  }
1689
1800
 
1690
- // Setup terminates cleanly here (spec §5.5, AC-11). Launching the dev server is
1801
+ // Setup terminates cleanly here. Launching the dev server is
1691
1802
  // no longer part of setup — run `npm run dev:preview` (scripts/org-setup-dev.mjs) for that.
1692
1803
  printSummary(targetOrg);
1693
1804
  if (finalExitCode() === 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@salesforce/ui-bundle-template-feature-react-search",
3
- "version": "11.31.15",
3
+ "version": "11.33.0",
4
4
  "description": "[Beta] Configuration-driven multi-source search for UI Bundles",
5
5
  "license": "SEE LICENSE IN LICENSE.txt",
6
6
  "author": "",