Setting the file. One moment.
Wix Writers · Rp Target Wix · wix/skills · Skills Docs
ContentsBack to the top of the page This file
Number 19.43
Position 43 of 44
Type JavaScript
Size 105 KB
Lines 2,219 lib/ wix-writers.js
JavaScript · 2,219 lines · 105 KB
15 // `queryStoresCategories`, `queryContacts`, `queryCoupons`, `queryOrders`) UNWRAPS the
16 // response to the entity array and returns ONE PAGE, discarding `pagingMetadata`. Two
17 // consequences that generated code has repeatedly got wrong:
18 // 1. **The returned value IS the array.** Reading `.products` / `.categories` off it a
19 // second time yields `undefined`, which `|| []` turns into an empty array — so a dedupe
20 // index or an existing-entity safety net comes back EMPTY instead of failing. It looks
21 // like a working sweep of a fresh site, and the import duplicates everything.
22 // 2. **A cursor loop cannot be built on these at all** — the cursor it would need is inside
23 // the metadata they threw away. Use a `queryAll*` primitive where one exists
24 // (`queryAllStoresCategories`, `queryAllStoresProducts`, `queryAllDataItems`); otherwise
25 // send `wix.send(build<X>Request(body))` and read `pagingMetadata.cursors.next` off the
26 // raw response.
27 // A partial sweep must THROW, never return what it has: "empty net" and "empty store" are
28 // indistinguishable downstream, and it is the latter that a caller assumes.
29 //
30 // Endpoints + request shapes marked `// VERIFIED:` were validated by REAL CALLS against
31 // a live Wix site (not just docs — see SKILL.md "Validate by real call"). Shapes marked
32 // `// UNVERIFIED:` are docs-schema/MCP-derived bootstrap primitives. They must be
33 // surfaced in execution plans until a live contract call promotes them to VERIFIED.
34
35 const fs = require ( 'node:fs/promises' );
36 const path = require ( 'node:path' );
37 const crypto = require ( 'node:crypto' );
38
39 const WIXAPIS = 'https://www.wixapis.com' ;
40
41 const SAFE_MODE_TRUE_VALUES = new Set ([ 'true' , '1' , 'yes' , 'on' ]);
42 const SAFE_MODE_FALSE_VALUES = new Set ([ 'false' , '0' , 'no' , 'off' ]);
43 const DEFAULT_SAFE_MODE_PHONE_NUMBER = '+972 50 0000000' ;
44 const NATIVE_EMAIL_FALLBACK_PATHS = [
45 'info.emails.items[].email' ,
46 'emails.items[].email' ,
47 'buyerInfo.email' ,
48 'billingInfo.email' ,
49 'shippingInfo.email' ,
50 'contact.email' ,
51 'order.buyerInfo.email' ,
52 'order.billingInfo.email' ,
53 'order.shippingInfo.email' ,
54 'member.loginEmail' ,
55 'loginEmail' ,
56 // Contacts V5 (GA) — flat contact shape; single create/update wraps as { contact },
57 // bulk upsert wraps each item as { contact } under contacts[]
58 'contact.email.email' ,
59 'contact.additionalEmails[].email' ,
60 'contacts[].contact.email.email' ,
61 'contacts[].contact.additionalEmails[].email' ,
62 ];
63 const NATIVE_PHONE_FALLBACK_PATHS = [
64 'info.phones.items[].phone' ,
65 'phones.items[].phone' ,
66 'buyerInfo.phone' ,
67 'billingInfo.phone' ,
68 'shippingInfo.phone' ,
69 'order.buyerInfo.phone' ,
70 'order.billingInfo.phone' ,
71 'order.shippingInfo.phone' ,
72 // Import Order (POST /ecom/v1/orders/import) — contact details carry phone, not email
73 'order.billingInfo.contactDetails.phone' ,
74 'order.shippingInfo.logistics.shippingDestination.contactDetails.phone' ,
75 'order.recipientInfo.contactDetails.phone' ,
76 // Contacts V5 (GA) — flat contact shape; single create/update wraps as { contact },
77 // bulk upsert wraps each item as { contact } under contacts[]
78 'contact.phone.phone' ,
79 'contact.additionalPhones[].phone' ,
80 'contact.addresses[].recipient.phone' ,
81 'contacts[].contact.phone.phone' ,
82 'contacts[].contact.additionalPhones[].phone' ,
83 'contacts[].contact.addresses[].recipient.phone' ,
84 ];
85 const EMAIL_PATTERN = / ^ [ ^ \s@] + @ [ ^ \s@] + \. [ ^ \s@] +$ / ;
86
87 class SafeModeBlockedError extends Error {
88 constructor ( message , details = {}) {
89 super (message);
90 this .name = 'SafeModeBlockedError' ;
91 this .code = 'SAFE_MODE_SUSPICIOUS_EMAIL' ;
92 this .safeMode = true ;
93 this .blockedPaths = details.blockedPaths || [];
94 this .sanitizerResult = details.sanitizerResult;
95 }
96 }
97
98 function normalizeSafeModeValue ( value , { defaultValue }) {
99 if (value == null || String (value). trim () === '' ) return defaultValue;
100 const normalized = String (value). trim (). toLowerCase ();
101 if ( SAFE_MODE_TRUE_VALUES . has (normalized)) return true ;
102 if ( SAFE_MODE_FALSE_VALUES . has (normalized)) return false ;
103 throw new Error ( `SAFE_MODE must be one of true, 1, yes, on, false, 0, no, off; got ${ JSON . stringify ( value ) }` );
104 }
105
106 function normalizeDryRunValue ( value , { defaultValue = false } = {}) {
107 if (value == null || String (value). trim () === '' ) return defaultValue;
108 const normalized = String (value). trim (). toLowerCase ();
109 if ( SAFE_MODE_TRUE_VALUES . has (normalized)) return true ;
110 if ( SAFE_MODE_FALSE_VALUES . has (normalized)) return false ;
111 throw new Error ( `DRY_RUN must be one of true, 1, yes, on, false, 0, no, off; got ${ JSON . stringify ( value ) }` );
112 }
113
114 function createDryRunConfig ( env = {}, argv = []) {
115 let dryRun = normalizeDryRunValue (env. DRY_RUN , { defaultValue: false });
116 for ( const arg of argv || []) {
117 if (arg === '--dry-run' ) dryRun = true ;
118 if (arg === '--no-dry-run' ) dryRun = false ;
119 }
120 return { dryRun };
121 }
122
123 function createSafeModeConfig ( env = {}) {
124 const safeMode = normalizeSafeModeValue (env. SAFE_MODE , { defaultValue: true });
125 const configuredPhone = env. SAFE_MODE_PHONE_NUMBER == null ? '' : String (env. SAFE_MODE_PHONE_NUMBER ). trim ();
126 return {
127 safeMode,
128 safeModePhoneNumber: safeMode ? (configuredPhone || DEFAULT_SAFE_MODE_PHONE_NUMBER ) : configuredPhone,
129 };
130 }
131
132 function safeEmailLocalPartComponent ( value , label , { allowHyphen = false } = {}) {
133 const pattern = allowHyphen ? / [ ^ a-z0-9-] + / g : / [ ^ a-z0-9] + / g ;
134 const normalized = String (value == null ? '' : value)
135 . trim ()
136 . toLowerCase ()
137 . replace (pattern, '_' )
138 . replace ( / ^ [_-] +| [_-] +$ / g , '' );
139 if ( ! normalized) throw new Error ( `mockEmailForEntity: ${ label } must normalize to a non-empty value` );
140 return normalized;
141 }
142
143 function mockEmailForEntity ( entityType , entityId ) {
144 const safeEntityType = safeEmailLocalPartComponent (entityType, 'entityType' );
145 const safeEntityId = safeEmailLocalPartComponent (entityId, 'entityId' , { allowHyphen: true });
146 return `replatform+${ safeEntityType }_${ safeEntityId }@wix.com` ;
147 }
148
149 function deepClone ( value ) {
150 if (Array. isArray (value)) return value. map (( item ) => deepClone (item));
151 if (value && typeof value === 'object' ) {
152 const out = {};
153 for ( const [ key , item ] of Object. entries (value)) out[key] = deepClone (item);
154 return out;
155 }
156 return value;
157 }
158
159 function parseSafeModePath ( pathValue ) {
160 if ( ! pathValue || typeof pathValue !== 'string' ) {
161 throw new Error ( 'safe-mode replacement path must be a non-empty string' );
162 }
163 return pathValue. split ( '.' ). map (( rawSegment ) => {
164 const match = rawSegment. match ( / ^ ( [A-Za-z0-9_$-] + )( \[\] ) ?$ / );
165 if ( ! match) throw new Error ( `invalid safe-mode replacement path segment: ${ rawSegment }` );
166 return { key: match[ 1 ], array: Boolean (match[ 2 ]) };
167 });
168 }
169
170 function pathToString ( segments ) {
171 return segments. map (( segment ) => `${ segment . key }${ segment . array ? '[]' : ''}` ). join ( '.' );
172 }
173
174 function setValuesAtPath ( root , pathValue , replacement ) {
175 const segments = parseSafeModePath (pathValue);
176 let changed = 0 ;
177 function visit ( node , index ) {
178 if ( ! node || typeof node !== 'object' ) return ;
179 const segment = segments[index];
180 if ( ! (segment.key in node)) return ;
181 if (segment.array) {
182 const items = node[segment.key];
183 if ( ! Array. isArray (items)) return ;
184 for ( const item of items) {
185 if (index === segments. length - 1 ) {
186 continue ;
187 }
188 visit (item, index + 1 );
189 }
190 return ;
191 }
192 if (index === segments. length - 1 ) {
193 // Primitive-only: a generic path like `contact.email` must not clobber the Contacts
194 // V5 email OBJECT ({ email, subscriptionStatus }) with a mock string. Raw emails
195 // left inside skipped objects are still caught by collectSuspiciousEmailPaths.
196 const current = node[segment.key];
197 if (current !== undefined && current !== null && typeof current !== 'object' ) {
198 if (current !== replacement) {
199 node[segment.key] = replacement;
200 changed += 1 ;
201 }
202 }
203 return ;
204 }
205 visit (node[segment.key], index + 1 );
206 }
207 visit (root, 0 );
208 return changed;
209 }
210
211 function collectSuspiciousEmailPaths ( value , { mockEmail }) {
212 const paths = [];
213 function visit ( node , segments ) {
214 if ( typeof node === 'string' ) {
215 if (node !== mockEmail && EMAIL_PATTERN . test (node. trim ())) paths. push ( pathToString (segments));
216 return ;
217 }
218 if (Array. isArray (node)) {
219 node. forEach (( item , index ) => visit (item, segments. concat ({ key: String (index), array: false })));
220 return ;
221 }
222 if (node && typeof node === 'object' ) {
223 for ( const [ key , item ] of Object. entries (node)) visit (item, segments. concat ({ key, array: false }));
224 }
225 }
226 visit (value, []);
227 return paths;
228 }
229
230 function normalizeReplacePaths ( replacePaths = []) {
231 if ( ! Array. isArray (replacePaths)) throw new Error ( 'safeModeOptions.replacePaths must be an array' );
232 return replacePaths. map (( entry ) => {
233 const kind = entry && entry.kind;
234 const path = entry && (entry.path || entry.targetPath);
235 if (kind !== 'email' && kind !== 'phone' ) throw new Error ( `safe-mode replacement kind must be email or phone; got ${ JSON . stringify ( kind ) }` );
236 parseSafeModePath (path);
237 return { kind, path };
238 });
239 }
240
241 function isSafeModeEnabled ( options = {}) {
242 if (options.safeMode === undefined ) return false ;
243 return normalizeSafeModeValue (options.safeMode, { defaultValue: true });
244 }
245
246 function sanitizeContactFieldsForSafeMode ( value , options = {}) {
247 if ( ! isSafeModeEnabled (options)) {
248 return {
249 value: deepClone (value),
250 blocked: false ,
251 blockedPaths: [],
252 emailFieldsReplaced: 0 ,
253 phoneFieldsReplaced: 0 ,
254 };
255 }
256 if ( ! options.entityType) throw new Error ( 'safe mode requires origin entityType' );
257 if ( ! options.entityId) throw new Error ( 'safe mode requires origin entityId' );
258 const safeModePhoneNumber = options.safeModePhoneNumber || DEFAULT_SAFE_MODE_PHONE_NUMBER ;
259 const mockEmail = mockEmailForEntity (options.entityType, options.entityId);
260 const sanitized = deepClone (value);
261 const replacePaths = normalizeReplacePaths (options.replacePaths || []);
262 let emailFieldsReplaced = 0 ;
263 let phoneFieldsReplaced = 0 ;
264
265 for ( const entry of replacePaths) {
266 if (entry.kind === 'email' ) emailFieldsReplaced += setValuesAtPath (sanitized, entry.path, mockEmail);
267 else phoneFieldsReplaced += setValuesAtPath (sanitized, entry.path, safeModePhoneNumber);
268 }
269 for ( const pathValue of NATIVE_EMAIL_FALLBACK_PATHS ) {
270 emailFieldsReplaced += setValuesAtPath (sanitized, pathValue, mockEmail);
271 }
272 for ( const pathValue of NATIVE_PHONE_FALLBACK_PATHS ) {
273 phoneFieldsReplaced += setValuesAtPath (sanitized, pathValue, safeModePhoneNumber);
274 }
275
276 const blockedPaths = collectSuspiciousEmailPaths (sanitized, { mockEmail });
277 return {
278 value: sanitized,
279 blocked: blockedPaths. length > 0 ,
280 blockedPaths,
281 emailFieldsReplaced,
282 phoneFieldsReplaced,
283 };
284 }
285
286 function sanitizeWixRequestBody ( body , options = {}) {
287 return sanitizeContactFieldsForSafeMode (body, options);
288 }
289
290 function buildSafeModeEvidence ( result , safeModeOptions ) {
291 if ( ! isSafeModeEnabled (safeModeOptions)) return null ;
292 return {
293 enabled: true ,
294 entityType: safeModeOptions.entityType || null ,
295 entityId: safeModeOptions.entityId == null ? null : String (safeModeOptions.entityId),
296 replacePathCount: normalizeReplacePaths (safeModeOptions.replacePaths || []). length ,
297 emailFieldsReplaced: result.emailFieldsReplaced,
298 phoneFieldsReplaced: result.phoneFieldsReplaced,
299 blockedPaths: result.blockedPaths. slice (),
300 };
301 }
302
303 function applySafeModeToRequest ( body , safeModeOptions ) {
304 const result = sanitizeWixRequestBody (body, safeModeOptions);
305 if (result.blocked) {
306 throw new SafeModeBlockedError ( 'safe mode blocked suspicious non-replaced email value before Wix write' , {
307 blockedPaths: result.blockedPaths,
308 sanitizerResult: result,
309 });
310 }
311 return {
312 body: result.value,
313 safeMode: buildSafeModeEvidence (result, safeModeOptions),
314 };
315 }
316
317 function applySafeModeToRequestBody ( body , safeModeOptions ) {
318 return applySafeModeToRequest (body, safeModeOptions).body;
319 }
320
321 // --- client ----------------------------------------------------------------
322 // config: { authToken, siteId }. authToken is an OAuth access token / API key with
323 // scopes for the selected writers, for example Blog manage, Wix Data collections manage,
324 // media import, Contacts manage/schema, and Members manage.
325 //
326 // Auth scheme normalization: Wix API keys (`IST.…`) are sent RAW in the Authorization
327 // header; OAuth access tokens (e.g. a Wix CLI token from `npx @wix/cli@latest token --site …`)
328 // must be sent as `Bearer <token>`. Detect and prefix so both credential kinds work.
329 function authHeaderValue ( token ) {
330 const t = String (token). trim ();
331 if ( / ^ Bearer \s / i . test (t)) return t; // already carries a scheme
332 if ( / ^ IST \. / . test (t)) return t; // Wix API key — sent as-is, no Bearer
333 return `Bearer ${ t }` ; // OAuth / CLI access token
334 }
335
336 function stripWixOrigin ( url ) {
337 const value = String (url || '' );
338 if (value. startsWith ( WIXAPIS )) return value. slice ( WIXAPIS . length ) || '/' ;
339 try {
340 const parsed = new URL (value);
341 return `${ parsed . pathname }${ parsed . search }` ;
342 } catch {
343 return value;
344 }
345 }
346
347 function stableHash ( value , length = 10 ) {
348 return crypto. createHash ( 'sha256' ). update ( String (value)). digest ( 'hex' ). slice ( 0 , length);
349 }
350
351 function safePlaceholderPart ( value , fallback = 'wix' ) {
352 const normalized = String (value || fallback)
353 . toLowerCase ()
354 . replace ( / [ ^ a-z0-9] + / g , '_' )
355 . replace ( / ^ _ +| _ +$ / g , '' );
356 return normalized || fallback;
357 }
358
359 function dryRunPlaceholderId ({ runId , entity , operation , sourceId , method , url , body }) {
360 const entityPart = safePlaceholderPart (entity || 'wix' );
361 const hashInput = JSON . stringify ({
362 runId: runId || 'dry-run' ,
363 entity: entity || null ,
364 operation: operation || null ,
365 sourceId: sourceId || null ,
366 method,
367 endpoint: stripWixOrigin (url),
368 body,
369 });
370 return `dry-run_${ entityPart }_${ stableHash ( hashInput , 8 ) }` ;
371 }
372
373 function responseShapeFromRequest ( request ) {
374 if (request.responseShape) return request.responseShape;
375 const url = String (request.url || '' );
376 const method = String (request.method || '' ). toUpperCase ();
377 if (url. includes ( '/ricos/v1/ricos-document/convert/to-ricos' )) return { type: 'object' , field: 'document' };
378 if (url. includes ( '/site-media/v1/files/import' )) return { type: 'object' , field: 'file' };
379 if (url. includes ( '/site-media/v1/files/' ) && method === 'GET' ) return { type: 'object' , field: 'file' };
380 if (url. includes ( '/blog/v3/categories' )) return method === 'GET' || url. includes ( '/query' ) ? { type: 'array' , field: 'categories' } : { type: 'object' , field: 'category' };
381 if (url. includes ( '/blog/v3/tags' )) return method === 'GET' || url. includes ( '/query' ) ? { type: 'array' , field: 'tags' } : { type: 'object' , field: 'tag' };
382 if (url. includes ( '/blog/v3/draft-posts' ) && url. includes ( '/publish' )) return { type: 'raw' };
383 if (url. includes ( '/blog/v3/draft-posts' )) return { type: 'object' , field: 'draftPost' };
384 if (url. includes ( '/wix-data/v2/items/query' )) return { type: 'array' , field: 'dataItems' };
385 if (url. includes ( '/wix-data/v2/items' )) return { type: 'object' , field: 'dataItem' , idFields: [ 'id' , '_id' ] };
386 if (url. includes ( '/stores/v3/products/query' )) return { type: 'array' , field: 'products' };
387 if (url. includes ( '/stores/v3/products/slug/' ) || (url. includes ( '/stores/v3/products/' ) && method === 'GET' )) return { type: 'object' , field: 'product' };
388 if (url. includes ( '/stores/v3/products' )) return { type: 'object' , field: 'product' };
389 if (url. includes ( '/categories/v1/categories/query' )) return { type: 'array' , field: 'categories' };
390 if (url. includes ( '/categories/v1/categories' )) return { type: 'object' , field: 'category' };
391 if (url. includes ( '/categories/v1/bulk/categories/add-item' )) return { type: 'raw' };
392 if (url. includes ( '/stores/v3/inventory-items' )) return { type: 'object' , field: 'inventoryItem' };
393 if (url. includes ( '/contacts/v5/bulk/contacts/upsert' )) return { type: 'raw' };
394 if (url. includes ( '/contacts/v5/contacts/query' ) || url. includes ( '/contacts/v4/contacts/query' )) return { type: 'array' , field: 'contacts' };
395 if (url. includes ( '/contacts/v5/contacts' ) || url. includes ( '/contacts/v4/contacts' )) return { type: 'object' , field: 'contact' };
396 if (url. includes ( '/stores/v2/coupons/query' )) return { type: 'array' , field: 'coupons' };
397 if (url. includes ( '/stores/v2/coupons' )) return { type: 'object' , field: 'coupon' };
398 if (url. includes ( '/ecom/v1/orders/query' )) return { type: 'array' , field: 'orders' };
399 if (url. includes ( '/ecom/v1/orders' )) return { type: 'object' , field: 'order' };
400 if (url. includes ( '/members/v1/members' ) && method === 'GET' ) return { type: 'array' , field: 'members' };
401 if (url. includes ( '/members/v1/members' )) return { type: 'object' , field: 'member' };
402 return { type: 'raw' };
403 }
404
405 function placeholderPayload ( shape , request , context ) {
406 if ( ! shape || shape.type === 'raw' ) return {};
407 if (shape.type === 'array' ) return { [shape.field]: [] };
408 const id = dryRunPlaceholderId ({ ... request, ... context });
409 const payload = { id, _dryRunPlaceholder: true };
410 for ( const field of shape.idFields || []) payload[field] = id;
411 if (shape.field === 'document' ) return { document: { nodes: [], _dryRunPlaceholder: true } };
412 if (shape.field === 'file' ) return { file: { id, operationStatus: 'PENDING' , _dryRunPlaceholder: true } };
413 return { [shape.field]: payload };
414 }
415
416 function redactHeaders ( headers = {}) {
417 const out = {};
418 for ( const [ key , value ] of Object. entries (headers || {})) {
419 if ( / ^ authorization $ / i . test (key)) continue ;
420 if ( /cookie | token | api [-_] ? key | secret/ i . test (key)) {
421 out[key] = '[REDACTED]' ;
422 } else {
423 out[key] = value;
424 }
425 }
426 return out;
427 }
428
429 function redactSecrets ( value ) {
430 if (Array. isArray (value)) return value. map (( item ) => redactSecrets (item));
431 if (value && typeof value === 'object' ) {
432 const out = {};
433 for ( const [ key , item ] of Object. entries (value)) {
434 out[key] = /authorization | cookie | token | api [-_] ? key | secret | password/ i . test (key) ? '[REDACTED]' : redactSecrets (item);
435 }
436 return out;
437 }
438 return value;
439 }
440
441 async function appendJsonLine ( filePath , row ) {
442 await fs. mkdir (path. dirname (filePath), { recursive: true });
443 await fs. appendFile (filePath, `${ JSON . stringify ( row ) } \n ` , 'utf8' );
444 }
445
446 async function defaultCaptureSink ( capture , config ) {
447 if ( typeof config.captureSink === 'function' ) {
448 await config. captureSink (capture);
449 }
450 if (config.auditSink && typeof config.auditSink.appendRequestCapture === 'function' ) {
451 await config.auditSink. appendRequestCapture (capture);
452 } else if ( typeof config.auditSink === 'function' ) {
453 await config. auditSink (capture);
454 }
455 if (config.requestCapturePath) {
456 await appendJsonLine (config.requestCapturePath, capture);
457 } else if (config.projectDir) {
458 await appendJsonLine (path. join (config.projectDir, 'state' , 'attempts' , 'wix-request-captures.ndjson' ), capture);
459 }
460 }
461
462 async function dryRunSend ( request , config , defaultHeaders ) {
463 const method = String (request.method || '' ). toUpperCase ();
464 if ( ! method) throw new Error ( 'wix.send: method is required' );
465 if ( ! request.url) throw new Error ( 'wix.send: url is required' );
466 const headers = { ... defaultHeaders, ... (request.headers || {}) };
467 const body = request.body === undefined ? undefined : request.body;
468 const runId = config.runContext?.runId || config.runId || 'dry-run' ;
469 const phase = request.phase || config.runContext?.phase || config.phase || 'import' ;
470 const requestCaptureId = `reqcap_${ stableHash ( JSON . stringify ({ runId , method , url: request.url , body , operation: request.operation , sourceId: request.sourceId }), 12 ) }` ;
471 const capture = {
472 schemaVersion: 1 ,
473 requestCaptureId,
474 timestamp: new Date (). toISOString (),
475 runId,
476 dryRun: true ,
477 phase,
478 ... (request.entity ? { entity: request.entity } : {}),
479 ... (request.operation ? { operation: request.operation } : {}),
480 ... (request.sourceId ? { sourceId: String (request.sourceId) } : {}),
481 method,
482 endpoint: stripWixOrigin (request.url),
483 headers: redactHeaders (headers),
484 body: redactSecrets (body),
485 verification: request.verification || request.verificationLevel || 'unverified' ,
486 expectedLiveBehavior: request.expectedLiveBehavior || request.operation || method. toLowerCase (),
487 result: 'dry_run_skipped_wix_call' ,
488 authTokenStatus: config.authToken ? 'present' : 'would_block_live' ,
489 siteIdStatus: config.siteId ? 'present' : 'would_block_live' ,
490 ... (request.safeMode ? { safeMode: request.safeMode } : {}),
491 };
492 await defaultCaptureSink (capture, config);
493 const shape = responseShapeFromRequest (request);
494 return {
495 dryRun: true ,
496 result: 'dry_run_skipped_wix_call' ,
497 requestCaptureId,
498 ... (shape.type === 'array' ? { stateKnown: false , kind: 'wix_call_skipped' } : {}),
499 ... placeholderPayload (shape, request, {
500 runId,
501 entity: request.entity || shape.field,
502 operation: request.operation || request.expectedLiveBehavior,
503 sourceId: request.sourceId,
504 }),
505 };
506 }
507
508 function createWixClient ( config ) {
509 const dryRun = normalizeDryRunValue (config && config.dryRun, { defaultValue: false });
510 if ( ! dryRun && ( ! config || ! config.authToken)) {
511 throw new Error (
512 'createWixClient: no Wix write credentials. Provide an OAuth access token / API ' +
513 'key with the scopes required by the selected writers. In an autonomous run this ' +
514 'is injected at provisioning time.' ,
515 );
516 }
517 const headers = {
518 'Content-Type' : 'application/json' ,
519 ... (config && config.authToken ? { Authorization: authHeaderValue (config.authToken) } : {}),
520 ... (config && config.siteId ? { 'wix-site-id' : config.siteId } : {}),
521 };
522 const fetchImpl = config.fetch || fetch;
523 return {
524 async send ( request ) {
525 if (dryRun) return dryRunSend (request, config, headers);
526 const { method , url , body } = request;
527 const requestHeaders = { ... headers, ... (request.headers || {}) };
528 const res = await fetchImpl (url, { method, headers: requestHeaders, body: body ? JSON . stringify (body) : undefined });
529 const text = await res. text ();
530 const json = text ? JSON . parse (text) : null ;
531 if ( ! res.ok) throw new Error ( `${ method } ${ url } → ${ res . status }: ${ text . slice ( 0 , 400 ) }` );
532 return json;
533 },
534 };
535 }
536
537 function intentToWixRequest ( intent ) {
538 if ( ! intent || typeof intent !== 'object' ) {
539 throw new Error ( 'setup intent must be an object' );
540 }
541 if (intent.type === 'rest' ) {
542 return {
543 method: intent.method,
544 url: intent.url || `${ WIXAPIS }${ String ( intent . path || '' ). startsWith ( '/' ) ? intent . path : `/${ intent . path }`}` ,
545 body: intent.body,
546 headers: intent.headers,
547 phase: 'setup' ,
548 operation: intent.operation,
549 entity: intent.entity,
550 sourceId: intent.sourceId,
551 verification: intent.verification,
552 expectedLiveBehavior: intent.expectedLiveBehavior,
553 responseShape: intent.responseShape,
554 };
555 }
556 return {
557 method: intent.method || intent.type || 'SETUP' ,
558 url: intent.url || `wix-${ intent . type || 'setup'}:${ intent . operation || intent . command || intent . tool || 'step'}` ,
559 body: intent.body || intent.args || intent.commandArgs || {},
560 headers: intent.headers || {},
561 phase: 'setup' ,
562 operation: intent.operation || intent.command || intent.tool,
563 entity: intent.entity,
564 sourceId: intent.sourceId,
565 verification: intent.verification,
566 expectedLiveBehavior: intent.expectedLiveBehavior || intent.type,
567 responseShape: intent.responseShape || { type: 'raw' },
568 };
569 }
570
571 function createWixSetupExecutor ( config = {}) {
572 const dryRun = normalizeDryRunValue (config.dryRun, { defaultValue: false });
573 let wixClient = config.wixClient || (dryRun ? createWixClient ({
574 ... config,
575 dryRun,
576 runContext: { ... (config.runContext || {}), phase: 'setup' },
577 }) : null );
578 const transports = config.transports || {};
579
580 return {
581 async executeSetupStep ( step ) {
582 if ( ! step || typeof step !== 'object' ) {
583 throw new Error ( 'setup step must be an object' );
584 }
585 const intent = step.intent || ( typeof step.buildIntent === 'function' ? await step. buildIntent (step) : step);
586 const request = intentToWixRequest (intent);
587 if (dryRun) {
588 const response = await wixClient. send (request);
589 return {
590 dryRun: true ,
591 status: 'planned_dry_run' ,
592 stepId: step.id || intent.id || null ,
593 intent,
594 requestCaptureId: response.requestCaptureId,
595 result: response.result,
596 };
597 }
598 if (intent.type === 'rest' ) {
599 if ( ! wixClient) {
600 wixClient = createWixClient ({
601 ... config,
602 dryRun,
603 runContext: { ... (config.runContext || {}), phase: 'setup' },
604 });
605 }
606 return wixClient. send (request);
607 }
608 if (intent.type === 'mcp' && typeof transports.mcp === 'function' ) {
609 return transports. mcp (intent);
610 }
611 if (intent.type === 'cli' && typeof transports.cli === 'function' ) {
612 return transports. cli (intent);
613 }
614 if (intent.type === 'sdk' && typeof transports.sdk === 'function' ) {
615 return transports. sdk (intent);
616 }
617 throw new Error ( `unsupported setup transport: ${ intent . type || '<missing>'}` );
618 },
619 };
620 }
621
622 // --- missing-writer bootstrap ---------------------------------------------
623 // Generated migrations use this when Wix has a native entity but rp-target-wix does not
624 // yet ship a dedicated writer primitive. This keeps the write path explicit and logged
625 // without pretending generic CMS is an acceptable substitute for a native Wix entity.
626 function buildDirectRestRequest ({ method , path , url , body }, safeModeOptions ) {
627 if ( ! method) throw new Error ( 'buildDirectRestRequest: method is required' );
628 if ( ! path && ! url) throw new Error ( 'buildDirectRestRequest: path or url is required' );
629 const prepared = applySafeModeToRequest (body, safeModeOptions);
630 return {
631 method,
632 url: url || `${ WIXAPIS }${ path . startsWith ( '/' ) ? path : `/${ path }`}` ,
633 body: prepared.body,
634 ... (prepared.safeMode ? { safeMode: prepared.safeMode } : {}),
635 };
636 }
637 async function sendDirectRest ( wix , request , safeModeOptions ) {
638 return wix. send ( buildDirectRestRequest (request, safeModeOptions));
639 }
640 async function notifyMissingWriter ({ sourceEntity , wixEntity , method , path , reason }) {
641 // NOOP for now. Replace with Slack/Jira/telemetry once the RePlatform team chooses a
642 // destination. Keep the return value structured so callers can log/report it.
643 return {
644 notified: false ,
645 noop: true ,
646 sourceEntity,
647 wixEntity,
648 method,
649 path,
650 reason,
651 };
652 }
653
654 // --- slugs ------------------------------------------------------------------
655 // Slug sanitizing lives in `wix-build.js` (`toWixSlug`, applied automatically by the
656 // `coerce: 'slug'` rule on `product.slug` in wix-target-spec.js) — NOT here, and deliberately
657 // not inside normalizeStoresProductV3. Two reasons it stays in the build layer: URL preservation
658 // needs the caller to record the original source slug alongside the `plannedTargetSlug` it
659 // derived, which a silent rewrite inside the writer would falsify; and the build layer is where
660 // the canonical→Wix payload rules are regression-locked. Do not add a second copy here.
661
662 // --- rich content: HTML → Ricos document -----------------------------------
663 // VERIFIED: POST /ricos/v1/ricos-document/convert/to-ricos with HTML input.
664 // VERIFIED-TRAP: `options.plugins` enum values are UPPERCASE. The public
665 // docs example shows lowercase (["image","link"]); lowercase returns HTTP 400.
666 // VERIFIED-TRAP: `source.html` is capped at 30000 chars (400 MAX_LENGTH).
667 // `convertHtmlToRichContent` transparently chunks larger HTML and merges the Ricos
668 // node arrays, so callers never have to think about the cap.
669 const RICOS_PLUGINS = [ 'IMAGE' , 'LINK' , 'VIDEO' , 'AUDIO' , 'HEADING' , 'DIVIDER' , 'CODE_BLOCK' , 'TABLE' , 'GALLERY' ];
670 const RICOS_HTML_CAP = 30000 ; // hard limit on source.html (400 MAX_LENGTH above this)
671 const RICOS_CHUNK_TARGET = 28000 ; // headroom under the cap
672 function buildConvertToRicosRequest ( html , plugins = RICOS_PLUGINS ) {
673 return { method: 'POST' , url: `${ WIXAPIS }/ricos/v1/ricos-document/convert/to-ricos` , body: { html, options: { plugins } } };
674 }
675 // split HTML at block-level close tags so each chunk stays under the cap
676 // without slicing through an element. A single block bigger than `max` is hard-split
677 // as a last resort (rare; logged by the caller).
678 function splitHtmlIntoChunks ( html , max = RICOS_CHUNK_TARGET ) {
679 if (html. length <= max) return [html];
680 const parts = html. split ( /(?<=< \/ (?:p | div | section | article | h [1-6] | ul | ol | li | blockquote | pre | figure | table | tbody | thead | tr)>)/ i );
681 const chunks = [];
682 let cur = '' ;
683 for ( const part of parts) {
684 if (part. length > max) {
685 if (cur) { chunks. push (cur); cur = '' ; }
686 for ( let i = 0 ; i < part. length ; i += max) chunks. push (part. slice (i, i + max));
687 continue ;
688 }
689 if (cur && cur. length + part. length > max) { chunks. push (cur); cur = '' ; }
690 cur += part;
691 }
692 if (cur) chunks. push (cur);
693 return chunks;
694 }
695 // OBSERVED (2026-07-29): this endpoint throttles a sustained burst with **403** (empty message,
696 // empty details) rather than 429. A 50-product bulk create converts one description per product,
697 // and the run died partway with 49 products unwritten; a single call and a burst of 12 succeeded
698 // moments later, so the condition is transient. Retry with backoff instead of failing the batch.
699 // A genuine permission 403 still surfaces, just after the attempts are exhausted.
700 const RICOS_RETRY_DELAYS_MS = [ 500 , 1500 , 4000 , 9000 , 20000 ];
701 function isRetryableRicosError ( err ) {
702 return / \b (403 | 429 | 500 | 502 | 503 | 504) \b / . test (err && err.message ? err.message : '' );
703 }
704 async function convertHtmlToRichContent ( wix , html , { plugins , mediaBySourceUrl } = {}) {
705 const chunks = splitHtmlIntoChunks (html || '' );
706 let merged = null ;
707 for ( const chunk of chunks) {
708 let document;
709 for ( let attempt = 0 ; ; attempt += 1 ) {
710 try {
711 ({ document } = await wix. send ( buildConvertToRicosRequest (chunk, plugins)));
712 break ;
713 } catch (err) {
714 if (attempt >= RICOS_RETRY_DELAYS_MS . length || ! isRetryableRicosError (err)) throw err;
715 await new Promise (( resolve ) => setTimeout (resolve, RICOS_RETRY_DELAYS_MS [attempt]));
716 }
717 }
718 if ( ! merged) merged = document;
719 else merged.nodes = (merged.nodes || []). concat (document.nodes || []);
720 }
721 return mediaBySourceUrl ? rewriteInlineMedia (merged, mediaBySourceUrl) : merged;
722 }
723 // VERIFIED-TRAP (2026-08-04, live to-ricos call): the converter nests the media object
724 // under a type-named key — `imageData.image.src.url`, `videoData.video.src.url`,
725 // `audioData.audio.src.url`. The earlier `media.src` / bare `src` paths matched nothing,
726 // so inline rewrites were silently a no-op (posts kept hot-linking the source host).
727 function rewriteInlineMedia ( ricosDocument , mediaBySourceUrl ) {
728 const MEDIA_KEYS = { imageData: 'image' , videoData: 'video' , audioData: 'audio' };
729 const visit = ( node ) => {
730 if ( ! node || typeof node !== 'object' ) return ;
731 for ( const [ key , inner ] of Object. entries ( MEDIA_KEYS )) {
732 const holder = node[key]?.[inner] || node[key]?.media || node[key];
733 const src = holder?.src?.url;
734 if (src && mediaBySourceUrl. has (src)) {
735 holder.src = { id: mediaBySourceUrl. get (src) };
736 }
737 }
738 (node.nodes || []). forEach (visit);
739 };
740 (ricosDocument?.nodes || []). forEach (visit);
741 return ricosDocument;
742 }
743
744 // --- media (import-from-URL) -----------------------------------------------
745 // VERIFIED: POST /site-media/v1/files/import. ASYNC — the response file has
746 // operationStatus PENDING. VERIFIED (2026-08-04, live): a PENDING id is immediately
747 // referenceable in BLOG content (heroImage.id + inline Ricos src.id) — create/publish
748 // succeed while PENDING, the reference survives, and the CDN URL serves pre-READY —
749 // so blog writers must NOT block on waitUntilFileReady per file. Poll only when the
750 // flow reads the descriptor back (dimensions land at READY) or must surface a FAILED
751 // import before content ships. Unverified for product media / CMS reference fields —
752 // keep the wait there (README Part 5 item 20).
753 function buildImportMediaRequest ({ sourceUrl , displayName , mimeType , mediaType , wpId }) {
754 return {
755 method: 'POST' ,
756 url: `${ WIXAPIS }/site-media/v1/files/import` ,
757 body: {
758 url: sourceUrl,
759 displayName,
760 mimeType: mimeType || undefined ,
761 mediaType: mediaType ? String (mediaType). toUpperCase () : undefined , // IMAGE | AUDIO | VIDEO | DOCUMENT
762 externalInfo: wpId != null ? { origin: 'wordpress' , externalId: String (wpId) } : undefined ,
763 },
764 };
765 }
766 async function importMedia ( wix , payload ) {
767 const { file } = await wix. send ( buildImportMediaRequest (payload));
768 return file; // { id, url, operationStatus, ... }
769 }
770 // VERIFIED: GET /site-media/v1/files/{id} returns the descriptor; poll until ready.
771 async function waitUntilFileReady ( wix , fileId , { tries = 10 , delayMs = 1500 } = {}) {
772 for ( let i = 0 ; i < tries; i ++ ) {
773 const r = await wix. send ({ method: 'GET' , url: `${ WIXAPIS }/site-media/v1/files/${ fileId }` });
774 const status = r?.file?.operationStatus;
775 if (status === 'READY' ) return r.file;
776 if (status === 'FAILED' ) throw new Error ( `media import failed for ${ fileId }` );
777 await new Promise (( res ) => setTimeout (res, delayMs));
778 }
779 return null ; // caller decides whether to proceed with a still-PENDING file
780 }
781
782 // --- blog taxonomies -------------------------------------------------------
783 // VERIFIED: POST /blog/v3/categories with { category: { label, slug, description } }.
784 function buildCreateCategoryRequest ({ label , slug , description }, safeModeOptions ) {
785 const prepared = applySafeModeToRequest ({ category: { label, slug, description: description || '' } }, safeModeOptions);
786 return {
787 method: 'POST' ,
788 url: `${ WIXAPIS }/blog/v3/categories` ,
789 body: prepared.body,
790 ... (prepared.safeMode ? { safeMode: prepared.safeMode } : {}),
791 };
792 }
793 async function createBlogCategory ( wix , payload , safeModeOptions ) {
794 return ( await wix. send ( buildCreateCategoryRequest (payload, safeModeOptions))).category;
795 }
796 // VERIFIED: POST /blog/v3/tags. Body is TOP-LEVEL { label, language } — NOT
797 // { tag: { label, slug } }. `slug` is derived by Wix from the label.
798 function buildCreateTagRequest ({ label , language = 'en' }, safeModeOptions ) {
799 const prepared = applySafeModeToRequest ({ label, language }, safeModeOptions);
800 return {
801 method: 'POST' ,
802 url: `${ WIXAPIS }/blog/v3/tags` ,
803 body: prepared.body,
804 ... (prepared.safeMode ? { safeMode: prepared.safeMode } : {}),
805 };
806 }
807 async function createBlogTag ( wix , payload , safeModeOptions ) {
808 return ( await wix. send ( buildCreateTagRequest (payload, safeModeOptions))).tag;
809 }
810 // VERIFIED: GET /blog/v3/tags lists tags as { id, label, slug, ... }. Used to resolve a
811 // tag id after a 409 ALREADY_EXISTS so it can still be attached to a post.
812 async function listBlogTags ( wix , { limit = 500 } = {}) {
813 const r = await wix. send ({ method: 'GET' , url: `${ WIXAPIS }/blog/v3/tags?paging.limit=${ limit }` });
814 return r.tags || [];
815 }
816
817 // --- blog posts ------------------------------------------------------------
818 // VERIFIED: POST /blog/v3/draft-posts then POST /blog/v3/draft-posts/{id}/publish.
819 // memberId is REQUIRED for 3rd-party app creates. Visible custom cover media requires
820 // BOTH `heroImage.id` and `media.{displayed,custom,wixMedia.image.id}` — `heroImage.id`
821 // alone leaves the cover hidden in Wix Blog.
822 // VERIFIED (2026-08-02): the site owner's auto-created user-member (present on our
823 // API-provisioned test site with zero Members-area interaction — single-site
824 // observation; resolve it via listMembers + loginEmail, never derive it from
825 // the account/user GUID — the observed id equality is undocumented) is accepted as
826 // memberId — attribute-to-owner needs no member provisioning. Author is re-assignable
827 // AFTER publish: PATCH
828 // /blog/v3/draft-posts/{id} { draftPost: { memberId } } then republish updates the
829 // published post (post id == draft id). Republish events are NOT suppressed by
830 // saveType=IMPORT — run author-upgrade passes inside the notification-mute window.
831 // VERIFIED (2026-06-10): tags attach via `tagIds` (array of tag GUIDs) on create — the
832 // builder must pass them or tags are created but never linked (postCount stays 0).
833 // VERIFIED-TRAP (2026-07-19): the draft-post REQUEST field for the slug is `seoSlug` —
834 // a `slug` key is silently ignored and Wix derives the slug from the title (only the
835 // RESPONSE carries `slug`). Fix-up after the fact: PATCH /blog/v3/draft-posts/{id} with
836 // { draftPost: { seoSlug } } then republish. Wix also reserves some slugs and coerces
837 // them (e.g. `pts` → `__pts`), which no request shape can override.
838 // VERIFIED-TRAP (2026-07-21, coffeeshop51): Wix rejects seoSlug whose percent-encoded
839 // form exceeds 100 chars (common for non-ASCII/Hebrew slugs: a 10-char Hebrew slug
840 // encodes to ~60 chars, so anything over ~15 chars blows the limit). Omit the slug
841 // when it is too long and let Wix derive it from the title.
842 function safeSeoslug ( slug ) {
843 if ( ! slug) return undefined ;
844 try { return encodeURIComponent (slug). length <= 100 ? slug : undefined ; } catch { return undefined ; }
845 }
846 function toDraftPostBody ({ title , memberId , richContent , excerpt , slug , categoryIds , tagIds , firstPublishedDate , heroImageId }) {
847 return {
848 title,
849 memberId, // REQUIRED
850 richContent, // Ricos document
851 excerpt: excerpt || undefined ,
852 seoSlug: safeSeoslug (slug),
853 categoryIds: categoryIds || [],
854 tagIds: tagIds && tagIds. length ? tagIds : undefined ,
855 firstPublishedDate: firstPublishedDate || undefined ,
856 heroImage: heroImageId ? { id: heroImageId } : undefined ,
857 media: heroImageId ? { displayed: true , custom: true , wixMedia: { image: { id: heroImageId } } } : undefined ,
858 };
859 }
860 function buildCreateDraftPostRequest ( payload ) {
861 return {
862 method: 'POST' ,
863 url: `${ WIXAPIS }/blog/v3/draft-posts` ,
864 body: { draftPost: toDraftPostBody (payload) },
865 };
866 }
867 async function createDraftPost ( wix , payload ) {
868 return ( await wix. send ( buildCreateDraftPostRequest (payload))).draftPost;
869 }
870 async function publishDraftPost ( wix , draftPostId ) {
871 return wix. send ({ method: 'POST' , url: `${ WIXAPIS }/blog/v3/draft-posts/${ draftPostId }/publish` , body: {} });
872 }
873
874 // UNVERIFIED: POST /blog/v3/bulk/draft-posts/create — bulk draft-post create, max 20
875 // posts per call (docs `draftPosts` validation: minItems 1, maxItems 20). Surfaced by the
876 // wix/skills `wix-manage` recipe (which recommends it "for any N ≥ 2", citing ~25–30s per
877 // single-post call) and confirmed against the public docs page; no live call yet, so per
878 // adapter policy it must be surfaced in the execution plan until the contract test
879 // promotes it. Whether the bulk create can publish directly (a `publish` flag) is
880 // unverified — publish remains per-post via publishDraftPost until proven otherwise.
881 const BLOG_BULK_CREATE_MAX = 20 ;
882 function buildBulkCreateDraftPostsRequest ( payloads ) {
883 if ( ! Array. isArray (payloads) || payloads. length < 1 || payloads. length > BLOG_BULK_CREATE_MAX ) {
884 throw new Error ( `buildBulkCreateDraftPostsRequest: expected 1..${ BLOG_BULK_CREATE_MAX } payloads, got ${ Array . isArray ( payloads ) ? payloads . length : typeof payloads }` );
885 }
886 return {
887 method: 'POST' ,
888 url: `${ WIXAPIS }/blog/v3/bulk/draft-posts/create` ,
889 body: { draftPosts: payloads. map (toDraftPostBody) },
890 };
891 }
892 // Chunks any number of payloads into ≤20-post calls, sequentially, and returns the
893 // concatenated raw per-call responses (response item shape unverified — callers must
894 // inspect until the live contract call pins it down).
895 async function bulkCreateDraftPosts ( wix , payloads ) {
896 const responses = [];
897 for ( let i = 0 ; i < payloads. length ; i += BLOG_BULK_CREATE_MAX ) {
898 responses. push ( await wix. send ( buildBulkCreateDraftPostsRequest (payloads. slice (i, i + BLOG_BULK_CREATE_MAX ))));
899 }
900 return responses;
901 }
902
903 // --- CMS items (Wix Data) --------------------------------------------------
904 // VERIFIED: POST /wix-data/v2/items with { dataCollectionId, dataItem: { data } }.
905 // Requires Wix Data enabled on the site (WDE0110 otherwise — see rp-execute-setup).
906 // `data` is project-specific (the generated writer supplies the field map).
907 function buildInsertItemRequest ( collectionId , data , safeModeOptions ) {
908 const prepared = applySafeModeToRequest ({ dataCollectionId: collectionId, dataItem: { data } }, safeModeOptions);
909 return {
910 method: 'POST' ,
911 url: `${ WIXAPIS }/wix-data/v2/items` ,
912 body: prepared.body,
913 ... (prepared.safeMode ? { safeMode: prepared.safeMode } : {}),
914 };
915 }
916 async function insertDataItem ( wix , collectionId , data , safeModeOptions ) {
917 return ( await wix. send ( buildInsertItemRequest (collectionId, data, safeModeOptions))).dataItem;
918 }
919 // VERIFIED: POST /wix-data/v2/items/query with { dataCollectionId, query }. Paginates via
920 // query.paging {limit,offset}; returns dataItems[] (we return their `.data`). Required for
921 // optional CMS mirror fetch: only for pre-execution seeding when an existing-site flow has
922 // site-local reference data and valid local crosswalk state does not already exist. Runtime
923 // resume/idempotency is owned by state/crosswalk/crosswalk.ndjson, not CMS.
924 async function queryAllDataItems ( wix , collectionId , { pageSize = 100 } = {}) {
925 const out = [];
926 let offset = 0 ;
927 for (;;) {
928 const r = await wix. send ({ method: 'POST' , url: `${ WIXAPIS }/wix-data/v2/items/query` ,
929 body: { dataCollectionId: collectionId, query: { paging: { limit: pageSize, offset } } } });
930 const items = (r.dataItems || []). map (( d ) => d.data);
931 out. push ( ... items);
932 if (items. length < pageSize) break ;
933 offset += pageSize;
934 }
935 return out;
936 }
937
938 // --- Stores catalog (Catalog V3 ONLY) --------------------------------------
939 // Catalog V1 is NOT a supported destination: these primitives target V3 exclusively, there is
940 // no V1 fallback (it only masked real V3 errors as spurious 428s), and none should be added.
941 // A Stores install is not automatically V3 — a `blank`-scaffolded site comes up V1_CATALOG and
942 // cannot be converted, so callers must confirm V3_CATALOG (GET /stores/v3/provision/version)
943 // before the first write and halt on V1. Wix Stores app id (installing it pulls in Wix
944 // eCommerce): 215238eb-…
945 const WIX_STORES_APP_ID = '215238eb-22a5-4c36-9e7b-e7c08025e04e' ;
946 // Categories V3 require a top-level treeReference; appNamespace is always "@wix/stores".
947 const STORES_TREE_REFERENCE = { appNamespace: '@wix/stores' };
948 const PRODUCT_NAME_MAX = 80 ;
949 const CHOICE_NAME_MAX = 50 ;
950 // Products V3 schema: plainDescription is `string, maxLength 16000`. Unlike the Ricos path — which
951 // chunked at 28k and merged node arrays, so it was effectively unbounded — this is a hard cap.
952 const PLAIN_DESCRIPTION_MAX = 16000 ;
953 const STORES_SUBSCRIPTION_DESCRIPTION_MAX = 60 ;
954 const STORES_SUBSCRIPTION_FREQUENCIES = [ 'DAY' , 'WEEK' , 'MONTH' , 'YEAR' ];
955 const STORES_SUBSCRIPTION_CONTRACT = {
956 domain: 'stores' ,
957 entity: 'product' ,
958 surface: 'catalog-v3' ,
959 operation: 'createProduct' ,
960 path: 'product.subscriptionDetails' ,
961 verificationLevel: 'live-create-and-readback' ,
962 lastVerified: '2026-07-26' ,
963 verifiedBy: 'migration-20260726-01' ,
964 requiredPaths: [
965 'product.subscriptionDetails.allowOneTimePurchases' ,
966 'product.subscriptionDetails.subscriptions[]' ,
967 'product.subscriptionDetails.subscriptions[].title' ,
968 'product.subscriptionDetails.subscriptions[].description' ,
969 'product.subscriptionDetails.subscriptions[].frequency' ,
970 'product.subscriptionDetails.subscriptions[].interval' ,
971 'product.subscriptionDetails.subscriptions[].autoRenewal' ,
972 ],
973 constraints: [
974 {
975 path: 'product.subscriptionDetails.subscriptions[].description' ,
976 maxLength: STORES_SUBSCRIPTION_DESCRIPTION_MAX ,
977 source: 'live-validation' ,
978 },
979 {
980 path: 'product.subscriptionDetails.subscriptions[].frequency' ,
981 enum: STORES_SUBSCRIPTION_FREQUENCIES ,
982 source: 'live-create' ,
983 },
984 {
985 path: 'product.subscriptionDetails.subscriptions[].interval' ,
986 minimum: 1 ,
987 integer: true ,
988 source: 'live-create' ,
989 },
990 ],
991 readback: {
992 'product.subscriptionDetails' : 'returned-after-create' ,
993 'product.subscriptionDetails.subscriptions[].id' : 'server-assigned' ,
994 'product.subscriptionDetails.subscriptions[].title' : 'preserved' ,
995 'product.subscriptionDetails.subscriptions[].description' : 'preserved' ,
996 'product.subscriptionDetails.subscriptions[].frequency' : 'preserved' ,
997 'product.subscriptionDetails.subscriptions[].interval' : 'preserved' ,
998 'product.subscriptionDetails.subscriptions[].autoRenewal' : 'preserved' ,
999 },
1000 };
1001
1002 function omitEmptyStringFields ( input , fields ) {
1003 const out = { ... input };
1004 for ( const field of fields) {
1005 if ( typeof out[field] === 'string' && out[field]. trim () === '' ) delete out[field];
1006 }
1007 return out;
1008 }
1009
1010 // Normalize a Catalog V3 product payload so callers never hit the known create traps.
1011 // All rules below are VERIFIED by live calls (2026-07-05, ilovecupcakes + suteka2):
1012 // - product name is capped at 80 chars; longer names 400 MAX_LENGTH.
1013 // - productType PHYSICAL requires a product-level physicalProperties object present
1014 // (400 ONE_OF_ALIGNMENT otherwise), even though the docs create example omits it.
1015 // - Option choice `name` is capped at 50 chars; option and variant choice names must be
1016 // truncated IDENTICALLY or the variant fails MISSING_VARIANT_OPTION_CHOICE.
1017 // - Variant optionChoiceNames require a `renderType` (default TEXT_CHOICES); omitting it
1018 // 428s MISSING_VARIANT_OPTION_CHOICE.
1019 // - compareAtPrice must be strictly greater than actualPrice; drop it otherwise (Wix
1020 // rejects a compare-at <= the actual price).
1021 function clampChoiceName ( name ) {
1022 const s = String (name);
1023 return s. length > CHOICE_NAME_MAX ? s. slice ( 0 , CHOICE_NAME_MAX ) : s;
1024 }
1025 function clampProductName ( name ) {
1026 const s = String (name || '' );
1027 return s. length > PRODUCT_NAME_MAX ? s. slice ( 0 , PRODUCT_NAME_MAX ) : s;
1028 }
1029 function isPublicHttpUrl ( value ) {
1030 try {
1031 const url = new URL ( String (value));
1032 return url.protocol === 'http:' || url.protocol === 'https:' ;
1033 } catch {
1034 return false ;
1035 }
1036 }
1037 function normalizeStoresProductMediaItems ( items = []) {
1038 return items
1039 . map (( item ) => {
1040 if ( ! item) return null ;
1041 if ( typeof item === 'string' ) {
1042 return isPublicHttpUrl (item) ? { url: item } : { id: item };
1043 }
1044 if (item.id) return { id: item.id };
1045 if (item.mediaId) return { id: item.mediaId };
1046 if (item.url && isPublicHttpUrl (item.url)) return { url: item.url };
1047 if (item.image?.id) return { id: item.image.id };
1048 return null ;
1049 })
1050 . filter (Boolean);
1051 }
1052 function buildStoresProductMedia ( items = []) {
1053 const normalizedItems = normalizeStoresProductMediaItems (items);
1054 return normalizedItems. length ? { itemsInfo: { items: normalizedItems } } : undefined ;
1055 }
1056 function compactText ( value ) {
1057 return String (value || '' ). replace ( /< [ ^ >] * >/ g , ' ' ). replace ( / \s + / g , ' ' ). trim ();
1058 }
1059 function clampStoresSubscriptionDescription ( value ) {
1060 const text = compactText (value);
1061 if (text. length <= STORES_SUBSCRIPTION_DESCRIPTION_MAX ) return text;
1062 return `${ text . slice ( 0 , STORES_SUBSCRIPTION_DESCRIPTION_MAX - 3 ). trimEnd () }...` ;
1063 }
1064 function normalizeStoresSubscriptionFrequency ( value ) {
1065 if (value == null ) return value;
1066 const frequency = String (value). trim (). toUpperCase ();
1067 return STORES_SUBSCRIPTION_FREQUENCIES . includes (frequency) ? frequency : value;
1068 }
1069 function normalizeStoresSubscriptionInterval ( value ) {
1070 if (value == null || value === '' ) return value;
1071 const interval = Number (value);
1072 return Number. isInteger (interval) && interval >= 1 ? interval : value;
1073 }
1074 function synthesizeStoresSubscriptionDescription ( subscription ) {
1075 if (subscription.description) return subscription.description;
1076 if (subscription.title) return subscription.title;
1077 const interval = normalizeStoresSubscriptionInterval (subscription.interval);
1078 const frequency = normalizeStoresSubscriptionFrequency (subscription.frequency);
1079 if (Number. isInteger (interval) && STORES_SUBSCRIPTION_FREQUENCIES . includes (frequency)) {
1080 const unit = frequency. toLowerCase ();
1081 return interval === 1 ? `Every ${ unit }` : `Every ${ interval } ${ unit }s` ;
1082 }
1083 return 'Subscription' ;
1084 }
1085 function normalizeStoresProductSubscriptions ( subscriptionDetails ) {
1086 if ( ! subscriptionDetails || typeof subscriptionDetails !== 'object' ) return subscriptionDetails;
1087 const normalized = { ... subscriptionDetails };
1088 if ( typeof normalized.allowOneTimePurchases !== 'boolean' ) normalized.allowOneTimePurchases = Boolean (normalized.allowOneTimePurchases);
1089 if (Array. isArray (subscriptionDetails.subscriptions)) {
1090 normalized.subscriptions = subscriptionDetails.subscriptions
1091 . filter (Boolean)
1092 . map (( subscription ) => ({
1093 ... subscription,
1094 description: clampStoresSubscriptionDescription ( synthesizeStoresSubscriptionDescription (subscription)),
1095 frequency: normalizeStoresSubscriptionFrequency (subscription.frequency),
1096 interval: normalizeStoresSubscriptionInterval (subscription.interval),
1097 }));
1098 }
1099 return normalized;
1100 }
1101 function validateStoresProductSubscriptionDetails ( product ) {
1102 const details = product && product.subscriptionDetails;
1103 const errors = [];
1104 const add = ( path , code , message ) => errors. push ({ path, code, message });
1105 if ( ! details || typeof details !== 'object' ) return { ok: true , errors };
1106 if ( typeof details.allowOneTimePurchases !== 'boolean' ) {
1107 add ( 'product.subscriptionDetails.allowOneTimePurchases' , 'required_boolean' , 'allowOneTimePurchases must be boolean' );
1108 }
1109 if ( ! Array. isArray (details.subscriptions) || details.subscriptions. length === 0 ) {
1110 add ( 'product.subscriptionDetails.subscriptions[]' , 'required_array' , 'subscriptions must contain at least one entry' );
1111 return { ok: false , errors };
1112 }
1113 details.subscriptions. forEach (( subscription , index ) => {
1114 const base = `product.subscriptionDetails.subscriptions[${ index }]` ;
1115 if ( ! compactText (subscription.title)) add ( `${ base }.title` , 'required' , 'title is required' );
1116 if ( ! compactText (subscription.description)) {
1117 add ( `${ base }.description` , 'required' , 'description is required' );
1118 } else if ( compactText (subscription.description). length > STORES_SUBSCRIPTION_DESCRIPTION_MAX ) {
1119 add ( `${ base }.description` , 'max_length' , `description must be at most ${ STORES_SUBSCRIPTION_DESCRIPTION_MAX } characters` );
1120 }
1121 if ( ! STORES_SUBSCRIPTION_FREQUENCIES . includes (subscription.frequency)) {
1122 add ( `${ base }.frequency` , 'enum' , `frequency must be one of ${ STORES_SUBSCRIPTION_FREQUENCIES . join ( ', ' ) }` );
1123 }
1124 if ( ! Number. isInteger (subscription.interval) || subscription.interval < 1 ) {
1125 add ( `${ base }.interval` , 'minimum' , 'interval must be an integer >= 1' );
1126 }
1127 if ( typeof subscription.autoRenewal !== 'boolean' ) add ( `${ base }.autoRenewal` , 'required_boolean' , 'autoRenewal must be boolean' );
1128 });
1129 return { ok: errors. length === 0 , errors };
1130 }
1131 // VERIFIED-TRAP (2026-07-19, nopong migration): variant `price` must be a MONEY OBJECT
1132 // ({ actualPrice: { amount: "14.95" } }) — a bare string/number 400s "Expected an object".
1133 // Generated transforms kept emitting scalars, so coerce here instead of failing at create.
1134 function toMoneyObject ( price ) {
1135 if (price == null || typeof price === 'object' ) return price;
1136 return { actualPrice: { amount: String (price) } };
1137 }
1138 function normalizeStoresProductV3 ( input ) {
1139 const product = { ... input };
1140 if (product.name != null ) product.name = clampProductName (product.name);
1141
1142 // A `description` STRING is HTML that belongs in plainDescription; `description` proper is a
1143 // Ricos document object. Callers that hand-build a product (or predate the plainDescription
1144 // switch) still pass the string, so route it here rather than sending HTML where an object
1145 // is expected.
1146 if ( typeof product.description === 'string' ) {
1147 const html = product.description. trim ();
1148 delete product.description;
1149 if (html && product.plainDescription == null ) product.plainDescription = html;
1150 }
1151 // TRAP (Products V3 schema): "plainDescription is ignored when a value is also passed to the
1152 // description field." Sending both is a SILENT failure — a 200 with an empty description — so
1153 // it is rejected here rather than discovered on a live site.
1154 if (product.plainDescription != null && product.description != null ) {
1155 throw new Error (
1156 `normalizeStoresProductV3: "${ product . name }" sets both description and plainDescription; Wix ignores plainDescription when description is present. Set exactly one.` ,
1157 );
1158 }
1159 if ( typeof product.plainDescription === 'string' && product.plainDescription. length > PLAIN_DESCRIPTION_MAX ) {
1160 throw new Error (
1161 `normalizeStoresProductV3: "${ product . name }" has a ${ product . plainDescription . length }-character plainDescription; Wix caps it at ${ PLAIN_DESCRIPTION_MAX }. Truncate it or move the overflow into an info section, and record the loss in mapping-gaps.json.` ,
1162 );
1163 }
1164 if (product.productType) product.productType = String (product.productType). toUpperCase ();
1165 if (product.subscriptionDetails) {
1166 // Catalog V3 carries recurring offers directly on the product object. Keep the
1167 // nested shape stable here so create/patch flows preserve subscription payloads
1168 // instead of relying on incidental shallow-copy behavior.
1169 product.subscriptionDetails = normalizeStoresProductSubscriptions (product.subscriptionDetails);
1170 }
1171
1172 const topLevelPrice = product.price;
1173 const topLevelSku = product.sku;
1174 const topLevelPhysicalProperties = product.physicalProperties;
1175 delete product.price;
1176 delete product.sku;
1177 if (product.media && Array. isArray (product.media.itemsInfo?.items || product.media.items)) {
1178 product.media = buildStoresProductMedia (product.media.itemsInfo?.items || product.media.items);
1179 }
1180
1181 if ( ! product.variantsInfo && (topLevelPrice || topLevelSku || topLevelPhysicalProperties)) {
1182 product.variantsInfo = {
1183 variants: [{
1184 visible: product.visible !== false ,
1185 ... (topLevelSku ? { sku: topLevelSku } : {}),
1186 ... (topLevelPrice ? { price: toMoneyObject (topLevelPrice) } : {}),
1187 ... (topLevelPhysicalProperties ? { physicalProperties: topLevelPhysicalProperties } : {}),
1188 }],
1189 };
1190 }
1191
1192 if ( String (product.productType || '' ). toUpperCase () === 'PHYSICAL' ) {
1193 product.physicalProperties = {};
1194 }
1195 if (Array. isArray (product.options)) {
1196 product.options = product.options. map (( o ) => ({
1197 ... o,
1198 optionRenderType: o.optionRenderType || 'TEXT_CHOICES' ,
1199 choicesSettings: o.choicesSettings && Array. isArray (o.choicesSettings.choices)
1200 // VERIFIED-TRAP (2026-07-21, coffeeshop51): `choiceType` is required on every choice — omitting it returns PRODUCT_OPTION_CHOICE_NAME_AND_TYPE_REQUIRED.
1201 ? { ... o.choicesSettings, choices: o.choicesSettings.choices. map (( c ) => ({ ... c, name: clampChoiceName (c.name), choiceType: c.choiceType || 'CHOICE_TEXT' })) }
1202 : o.choicesSettings,
1203 }));
1204 }
1205 const variants = product.variantsInfo && Array. isArray (product.variantsInfo.variants) ? product.variantsInfo.variants : null ;
1206 if (variants) {
1207 product.variantsInfo = {
1208 ... product.variantsInfo,
1209 variants: variants. map (( v ) => {
1210 const nv = { ... v };
1211 if (nv.price != null ) nv.price = toMoneyObject (nv.price);
1212 const price = nv.price;
1213 if (price && price.compareAtPrice && price.actualPrice) {
1214 const cmp = Number (price.compareAtPrice.amount);
1215 const act = Number (price.actualPrice.amount);
1216 if ( ! (cmp > act)) { const { compareAtPrice , ... rest } = price; nv.price = rest; }
1217 }
1218 if (Array. isArray (nv.choices)) {
1219 nv.choices = nv.choices. map (( ch ) => ch.optionChoiceNames
1220 ? { ... ch, optionChoiceNames: { renderType: 'TEXT_CHOICES' , ... ch.optionChoiceNames, choiceName: clampChoiceName (ch.optionChoiceNames.choiceName) } }
1221 : ch);
1222 }
1223 return nv;
1224 }),
1225 };
1226 }
1227 return product;
1228 }
1229
1230 // VERIFIED (Products V3 schema): `plainDescription` is a STRING of HTML (max 16000) that Wix
1231 // converts to rich content SERVER-SIDE. It is not a plain-text flattening and costs no fidelity
1232 // against `description` — it is the same conversion, just not ours to run.
1233 //
1234 // So an HTML string never routes through /ricos/v1/... on the product path. That matters at
1235 // scale: the previous behaviour converted one description PER PRODUCT before a bulk create, so a
1236 // 100-product batch was 100 serial round-trips plus the bulk call, and that burst is exactly what
1237 // the endpoint throttles with a 403 (see convertHtmlToRichContent above). It is now one call.
1238 // convertHtmlToRichContent stays for the blog path, where `richContent` really is a Ricos document.
1239 //
1240 // `wix` is retained (unused) so the (wix, input) call shape stays valid: flipping the signature
1241 // would make an existing `f(wix, product)` call normalize the CLIENT object and silently return
1242 // garbage. Now synchronous — `await` on the result is harmless.
1243 // eslint-disable-next-line no-unused-vars
1244 function normalizeStoresProductV3ForCreate ( wix , input ) {
1245 return normalizeStoresProductV3 (input);
1246 }
1247
1248 // VERIFIED (2026-07-05): POST /stores/v3/products with { product } (+ optional fields[]).
1249 function buildCreateStoresProductRequest ( product , safeModeOptions , fields ) {
1250 const prepared = applySafeModeToRequest ({ product: normalizeStoresProductV3 (product) }, safeModeOptions);
1251 const body = prepared.body;
1252 if (fields) body.fields = fields;
1253 return {
1254 method: 'POST' ,
1255 url: `${ WIXAPIS }/stores/v3/products` ,
1256 body,
1257 ... (prepared.safeMode ? { safeMode: prepared.safeMode } : {}),
1258 };
1259 }
1260 async function createStoresProduct ( wix , product , safeModeOptions , fields ) {
1261 const normalized = normalizeStoresProductV3ForCreate (wix, product);
1262 return ( await wix. send ( buildCreateStoresProductRequest (normalized, safeModeOptions, fields))).product;
1263 }
1264 // --- bulk product create (the scale path) ----------------------------------
1265 // UNVERIFIED: POST /stores/v3/bulk/products-with-inventory/create — up to 100 products with
1266 // their options, variants, inline brand/ribbon/infoSections AND per-variant inventory items
1267 // in ONE request. This is the path a migration of any real size must use; creating products
1268 // one at a time is only acceptable for a handful.
1269 //
1270 // PER-REQUEST LIMITS (all of them, simultaneously — exceeding ANY ONE rejects the whole
1271 // request, so batch with ndjson.readBatchesByLimits, not on record count alone):
1272 // products <= 100
1273 // variantsInfo.variants <= 1000 (total across the request)
1274 // options <= 100 (total; 2 options per product caps a batch at 50)
1275 // modifiers <= 100 (total)
1276 // infoSections <= 100 (total)
1277 // BULK_LIMITS below is the machine-readable copy — use it rather than re-typing the numbers.
1278 //
1279 // TRAP: bulk is NOT atomic. Each item succeeds or fails independently via
1280 // `results[i].itemMetadata.success`; a 200 response can still contain failures. Callers MUST
1281 // walk the per-item results and never infer success from the HTTP status.
1282 //
1283 // TRAP: `itemMetadata.originalIndex` correlates a result back to the request array. Do not
1284 // assume the response preserves request order — key on originalIndex, and fall back to
1285 // position only when it is absent.
1286 //
1287 // TRAP: `bulkActionMetadata.undetailedFailures` counts failures whose detail was dropped
1288 // because the threshold was exceeded. Ignoring it silently loses failed records.
1289 //
1290 // `returnEntity: false` (the default) still returns `itemMetadata.id`, which is all a
1291 // crosswalk needs — pass `returnEntity: true` only when the caller must inspect the created
1292 // entity (e.g. a contract probe verifying variant counts), because the payload is large.
1293 const BULK_PRODUCT_LIMITS = { records: 100 , variants: 1000 , options: 100 , modifiers: 100 , infoSections: 100 };
1294
1295 // Cost of one product against those limits, for readBatchesByLimits.
1296 function storesProductBulkCost ( product ) {
1297 const v = product && product.variantsInfo && product.variantsInfo.variants;
1298 return {
1299 variants: Array. isArray (v) ? Math. max ( 1 , v. length ) : 1 ,
1300 options: Array. isArray (product && product.options) ? product.options. length : 0 ,
1301 modifiers: Array. isArray (product && product.modifiers) ? product.modifiers. length : 0 ,
1302 infoSections: Array. isArray (product && product.infoSections) ? product.infoSections. length : 0 ,
1303 };
1304 }
1305
1306 function buildBulkCreateStoresProductsRequest ( products , { returnEntity = false , fields } = {}) {
1307 if ( ! Array. isArray (products) || products. length === 0 ) {
1308 throw new Error ( 'buildBulkCreateStoresProductsRequest: products must be a non-empty array' );
1309 }
1310 if (products. length > BULK_PRODUCT_LIMITS .records) {
1311 throw new Error (
1312 `buildBulkCreateStoresProductsRequest: ${ products . length } products exceeds the per-request limit of ${ BULK_PRODUCT_LIMITS . records }. ` +
1313 'Batch with ndjson.readBatchesByLimits using BULK_PRODUCT_LIMITS.' ,
1314 );
1315 }
1316 const body = { products: products. map (( p ) => normalizeStoresProductV3 (p)), returnEntity };
1317 if (fields) body.fields = fields;
1318 return { method: 'POST' , url: `${ WIXAPIS }/stores/v3/bulk/products-with-inventory/create` , body };
1319 }
1320
1321 // Normalizes each product, sends ONE bulk request, and returns a per-item outcome list already
1322 // correlated back to the input index. Callers get a flat shape they cannot accidentally read as
1323 // all-or-nothing.
1324 //
1325 // Normalization is local — HTML descriptions travel as `plainDescription` and Wix converts them
1326 // server-side, so this is one HTTP call, not one-per-product plus the bulk call.
1327 async function bulkCreateStoresProductsWithInventory ( wix , products , { returnEntity = false , fields } = {}) {
1328 const normalized = products. map (( product ) => normalizeStoresProductV3ForCreate (wix, product));
1329
1330 const response = await wix. send ( buildBulkCreateStoresProductsRequest (normalized, { returnEntity, fields }));
1331 // VERIFIED (2026-07-29) against the BulkCreateProductsWithInventoryResponse schema:
1332 // TRAP: products-with-inventory nests the per-item results ONE LEVEL DEEPER than its
1333 // sibling /stores/v3/bulk/products/create. Here they are `productResults.results` +
1334 // `productResults.bulkActionMetadata`; only `inventoryResults` is top-level. Reading
1335 // `response.results` yields undefined, which the unaccounted guard correctly reports as a
1336 // correlation failure AFTER the products have already been created. The flat fallback keeps
1337 // this tolerant of the sibling envelope.
1338 const productResults = (response && response.productResults) || {};
1339 const rawResults = productResults.results || (response && response.results) || [];
1340 const meta = productResults.bulkActionMetadata || (response && response.bulkActionMetadata) || {};
1341
1342 const results = rawResults. map (( r , position ) => {
1343 const im = (r && r.itemMetadata) || {};
1344 // originalIndex is authoritative; position is the documented fallback only.
1345 const index = Number. isInteger (im.originalIndex) ? im.originalIndex : position;
1346 return {
1347 index,
1348 inputProduct: products[index],
1349 success: im.success === true ,
1350 productId: im.id || (r.item && r.item.id) || null ,
1351 revision: (r.item && r.item.revision) || null ,
1352 product: r.item || null ,
1353 errorCode: im.error && im.error.code ? im.error.code : null ,
1354 errorDescription: im.error && im.error.description ? im.error.description : null ,
1355 };
1356 });
1357
1358 const succeeded = results. filter (( r ) => r.success);
1359 const failed = results. filter (( r ) => ! r.success);
1360 const undetailedFailures = meta.undetailedFailures || 0 ;
1361
1362 // A result set that does not account for every input is a correlation bug, not a partial
1363 // success — surface it rather than silently crosswalking the wrong ids.
1364 const unaccounted = products. length - results. length - undetailedFailures;
1365
1366 return {
1367 results,
1368 succeeded,
1369 failed,
1370 totalSuccesses: meta.totalSuccesses !== undefined ? meta.totalSuccesses : succeeded. length ,
1371 totalFailures: meta.totalFailures !== undefined ? meta.totalFailures : failed. length ,
1372 undetailedFailures,
1373 unaccounted: unaccounted > 0 ? unaccounted : 0 ,
1374 inventoryResults: (response && response.inventoryResults) || null ,
1375 };
1376 }
1377
1378 function buildQueryStoresProductsRequest ( query = { paging: { limit: 100 } }) {
1379 return { method: 'POST' , url: `${ WIXAPIS }/stores/v3/products/query` , body: { query } };
1380 }
1381 // ONE PAGE, unwrapped to the products array, pagingMetadata discarded — see the READ/RETURN
1382 // CONTRACT at the top of this file. Do not build a dedupe index or a safety net on this.
1383 async function queryStoresProducts ( wix , query ) {
1384 return ( await wix. send ( buildQueryStoresProductsRequest (query))).products || [];
1385 }
1386 // OBSERVED (2026-07-29, shopify-mysite1): the only correct way to sweep the catalog, and the
1387 // primitive any crosswalk-recovery / name-match safety net must use. The unwrapping executor
1388 // above cannot be cursor-paged (it discards the cursor), and the hand-rolled loop that reads
1389 // `.products` off its already-unwrapped return value produces an EMPTY set — which reads as
1390 // "the store is empty" and is exactly the state under which an import re-creates the whole
1391 // catalog it already imported. Hence: throw on an incomplete sweep, never return a partial index.
1392 //
1393 // UNVERIFIED for the first-page request form: the live shopify-mysite1 sweep opened with
1394 // `paging: { limit }` and only switched to `cursorPaging` once it held a cursor. This mirrors
1395 // `queryAllStoresCategories` instead and sends `cursorPaging` from the start, which is the
1396 // documented Wix convention (`paging` and `cursorPaging` are mutually exclusive) but has not
1397 // been confirmed against /stores/v3/products/query by a real call. Promote on first live run.
1398 async function queryAllStoresProducts ( wix , { pageSize = 100 , maxPages = 200 } = {}) {
1399 const all = [];
1400 const seen = new Set ();
1401 let cursor = null ;
1402 let pages = 0 ;
1403 do {
1404 const query = cursor ? { cursorPaging: { limit: pageSize, cursor } } : { cursorPaging: { limit: pageSize } };
1405 const response = await wix. send ( buildQueryStoresProductsRequest (query));
1406 for ( const product of response.products || []) {
1407 if (product && ! seen. has (product.id)) { seen. add (product.id); all. push (product); }
1408 }
1409 const meta = response.pagingMetadata || {};
1410 cursor = (meta.cursors && meta.cursors.next) || null ;
1411 pages += 1 ;
1412 } while (cursor && pages < maxPages);
1413 if (cursor) throw new Error ( `queryAllStoresProducts: still paging after ${ maxPages } pages; refusing to return a partial product index.` );
1414 return all;
1415 }
1416 // VERIFIED (migration-20260715-01): PATCH /stores/v3/products/{id} with
1417 // { product: { revision, media: { itemsInfo: { items: [{id}|{url}] } } } } updates
1418 // product gallery media. Prefer external URLs here when the source media is publicly
1419 // reachable: the Stores product API ingests them in the background, which avoids the
1420 // slower, heavily-throttled Media Manager pre-import path.
1421 function buildPatchStoresProductMediaRequest ({ productId , revision , items = [] }) {
1422 return {
1423 method: 'PATCH' ,
1424 url: `${ WIXAPIS }/stores/v3/products/${ productId }` ,
1425 body: {
1426 product: {
1427 revision,
1428 media: buildStoresProductMedia (items),
1429 },
1430 },
1431 };
1432 }
1433 async function patchStoresProductMedia ( wix , payload ) {
1434 return wix. send ( buildPatchStoresProductMediaRequest (payload));
1435 }
1436
1437 // UNVERIFIED: GET /stores/v3/products/{id} and GET /stores/v3/products/slug/{slug}.
1438 // Used by upsert flows to check whether a product already exists before creating it.
1439 // Both return 404 when the product is not found — callers should catch and treat as null.
1440 function buildGetStoresProductRequest ( id ) {
1441 return { method: 'GET' , url: `${ WIXAPIS }/stores/v3/products/${ encodeURIComponent ( id ) }` };
1442 }
1443 async function getStoresProduct ( wix , id ) {
1444 return ( await wix. send ( buildGetStoresProductRequest (id))).product;
1445 }
1446 function buildGetStoresProductBySlugRequest ( slug ) {
1447 return { method: 'GET' , url: `${ WIXAPIS }/stores/v3/products/slug/${ encodeURIComponent ( slug ) }` };
1448 }
1449 async function getStoresProductBySlug ( wix , slug ) {
1450 return ( await wix. send ( buildGetStoresProductBySlugRequest (slug))).product;
1451 }
1452 function buildDeleteStoresProductRequest ( id ) {
1453 return { method: 'DELETE' , url: `${ WIXAPIS }/stores/v3/products/${ encodeURIComponent ( id ) }` };
1454 }
1455 async function deleteStoresProduct ( wix , id ) {
1456 return wix. send ( buildDeleteStoresProductRequest (id));
1457 }
1458
1459 // UNVERIFIED (endpoint VERIFIED via patchStoresProductMedia): PATCH /stores/v3/products/{id}
1460 // with arbitrary product fields. The `revision` from the existing product is required.
1461 // A string `description` is moved to `plainDescription` by normalizeStoresProductV3 (same as
1462 // createStoresProduct); Wix converts that HTML to rich content server-side.
1463 // Do not use this for media-only updates — patchStoresProductMedia is the verified path for that.
1464 function buildPatchStoresProductRequest ({ productId , revision , ... productFields }, safeModeOptions ) {
1465 const prepared = applySafeModeToRequest ({ product: { revision, ... normalizeStoresProductV3 (productFields) } }, safeModeOptions);
1466 return {
1467 method: 'PATCH' ,
1468 url: `${ WIXAPIS }/stores/v3/products/${ productId }` ,
1469 body: prepared.body,
1470 ... (prepared.safeMode ? { safeMode: prepared.safeMode } : {}),
1471 };
1472 }
1473 async function patchStoresProduct ( wix , { productId , revision , ... productFields }, safeModeOptions ) {
1474 // No description handling here: buildPatchStoresProductRequest runs normalizeStoresProductV3,
1475 // which moves a string `description` to `plainDescription` for Wix to convert server-side.
1476 return ( await wix. send ( buildPatchStoresProductRequest ({ productId, revision, ... productFields }, safeModeOptions))).product;
1477 }
1478
1479 // UNVERIFIED: POST /categories/v1/categories/query returns ONE PAGE of Stores categories —
1480 // NOT all of them, whatever this comment used to say. treeReference is TOP-LEVEL (same trap as
1481 // create). Used to seed a name→id cache for upsert flows so existing categories are reused
1482 // instead of duplicated — which means the cache must be built with queryAllStoresCategories,
1483 // since a truncated cache duplicates exactly the categories it failed to read.
1484 function buildQueryStoresCategoriesRequest ( query = { paging: { limit: 100 } }) {
1485 return {
1486 method: 'POST' ,
1487 url: `${ WIXAPIS }/categories/v1/categories/query` ,
1488 body: { query, treeReference: STORES_TREE_REFERENCE },
1489 };
1490 }
1491 // ONE PAGE, unwrapped to the categories array, pagingMetadata discarded — see the READ/RETURN
1492 // CONTRACT at the top of this file. Use queryAllStoresCategories below for any dedupe index.
1493 async function queryStoresCategories ( wix , query ) {
1494 return ( await wix. send ( buildQueryStoresCategoriesRequest (query))).categories || [];
1495 }
1496 // OBSERVED (2026-07-29): `queryStoresCategories` returns ONE PAGE (100 max) and, by unwrapping to
1497 // the array, discards the pagingMetadata needed to fetch the rest. A dedupe index built from it is
1498 // silently truncated once a site passes 100 categories — a site with 119 read as 100, which would
1499 // duplicate the missing 19 on the next import. Any upsert/dedupe flow must use this instead.
1500 async function queryAllStoresCategories ( wix , { pageSize = 100 , maxPages = 200 } = {}) {
1501 const all = [];
1502 const seen = new Set ();
1503 let cursor = null ;
1504 let pages = 0 ;
1505 do {
1506 const query = cursor ? { cursorPaging: { limit: pageSize, cursor } } : { cursorPaging: { limit: pageSize } };
1507 const response = await wix. send ( buildQueryStoresCategoriesRequest (query));
1508 for ( const category of response.categories || []) {
1509 if (category && ! seen. has (category.id)) { seen. add (category.id); all. push (category); }
1510 }
1511 const meta = response.pagingMetadata || {};
1512 cursor = (meta.cursors && meta.cursors.next) || null ;
1513 pages += 1 ;
1514 } while (cursor && pages < maxPages);
1515 if (cursor) throw new Error ( `queryAllStoresCategories: still paging after ${ maxPages } pages; refusing to return a partial category index.` );
1516 return all;
1517 }
1518
1519 // VERIFIED (2026-07-05): POST /categories/v1/categories with { category, treeReference }.
1520 // treeReference is TOP-LEVEL (sibling of category), NOT a category property — nesting it
1521 // 400s "treeReference must not be empty".
1522 function buildCreateStoresCategoryRequest ( category , safeModeOptions ) {
1523 const prepared = applySafeModeToRequest ({
1524 category: omitEmptyStringFields (category, [ 'description' ]),
1525 treeReference: STORES_TREE_REFERENCE ,
1526 }, safeModeOptions);
1527 return {
1528 method: 'POST' ,
1529 url: `${ WIXAPIS }/categories/v1/categories` ,
1530 body: prepared.body,
1531 ... (prepared.safeMode ? { safeMode: prepared.safeMode } : {}),
1532 };
1533 }
1534 async function createStoresCategory ( wix , category , safeModeOptions ) {
1535 return ( await wix. send ( buildCreateStoresCategoryRequest (category, safeModeOptions))).category;
1536 }
1537
1538 // VERIFIED (2026-07-05): add one product to categories in bulk —
1539 // POST /categories/v1/bulk/categories/add-item with
1540 // { item:{ catalogItemId, appId }, categoryIds[], treeReference }. catalogItemId is the Wix
1541 // product id; appId is the Wix Stores app id.
1542 function buildBulkAddItemToCategoriesRequest ({ productId , categoryIds }) {
1543 return {
1544 method: 'POST' ,
1545 url: `${ WIXAPIS }/categories/v1/bulk/categories/add-item` ,
1546 body: { item: { catalogItemId: productId, appId: WIX_STORES_APP_ID }, categoryIds, treeReference: STORES_TREE_REFERENCE },
1547 };
1548 }
1549 async function bulkAddItemToCategories ( wix , payload ) {
1550 return wix. send ( buildBulkAddItemToCategoriesRequest (payload));
1551 }
1552
1553 // --- Contacts --------------------------------------------------------------
1554 // Contacts V5 is GA (verified in public docs 2026-08-04). The GA contract
1555 // is FLAT: one main `email`/`phone` (matching + subscription live on the main entries),
1556 // `additionalEmails`/`additionalPhones` arrays, an `addresses` array with the postal
1557 // fields NESTED under `address`, and `company: { name, jobTitle }`. There is no `info`
1558 // wrapper and no V4-style `emails.items` list wrapper anywhere in V5 requests. Create and
1559 // update both take `{ contact, allowDuplicates }`; update requires the current `revision`
1560 // and has no fieldMask. Live create/query/update verification is still pending a token
1561 // with Contacts permissions (the 2026-07-26 probe got 403), so writers stay UNVERIFIED
1562 // until a contract test promotes them — but the target shape is now the documented GA one.
1563 //
1564 // Custom fields: the GA V5 contact carries `extendedFields.namespaces.<ns>` and the V5
1565 // docs route field DEFINITIONS through the Data Extension Schema API with FQDN
1566 // `wix.contacts.*.contact` (values under the `_user_fields` namespace). The V4 Contacts
1567 // Extended Fields API (`POST /contacts/v4/extended-fields`, values under
1568 // `info.extendedFields`) still exists but pairs with the V4 write surface only — do not
1569 // mix the two. Labels are likewise a V4 concept; V5 exposes `tags.privateTags.tagIds`
1570 // managed through the Tags API (same FQDN). NOTE: the Data Extension Schema intro's
1571 // supported-objects table does not list contacts yet — docs inconsistency at GA cutover;
1572 // treat the V5 contact-object statement as authoritative but verify live during setup.
1573 const V5_CONTACT_PHONE_TAGS = new Set ([ 'OTHER' , 'MAIN' , 'HOME' , 'MOBILE' , 'WORK' , 'FAX' ]);
1574 const V5_CONTACT_ADDRESS_TAGS = new Set ([ 'OTHER' , 'HOME' , 'WORK' , 'BILLING' , 'SHIPPING' ]);
1575 function normalizeV5PhoneTag ( tag ) {
1576 const normalized = String (tag || '' ). trim (). toUpperCase ();
1577 if ( ! normalized) return undefined ;
1578 if ( V5_CONTACT_PHONE_TAGS . has (normalized)) return normalized;
1579 if (normalized === 'PRIMARY' || normalized === 'SOURCE_PRIMARY' || normalized === 'BILLING' ) return 'MAIN' ;
1580 if (normalized === 'SHIPPING' ) return 'HOME' ;
1581 return 'OTHER' ;
1582 }
1583 function normalizeV5AddressTag ( tag ) {
1584 const normalized = String (tag || '' ). trim (). toUpperCase ();
1585 if ( ! normalized) return undefined ;
1586 return V5_CONTACT_ADDRESS_TAGS . has (normalized) ? normalized : 'OTHER' ;
1587 }
1588 // GA ContactAddress keeps postal fields nested under `address`; anything else found flat
1589 // on the item (city, country, streetAddress, …) is moved into `address` so legacy flat
1590 // items survive the shape change.
1591 const V5_ADDRESS_ITEM_KEYS = new Set ([ 'id' , 'tag' , 'address' , 'defaultAddress' , 'recipient' ]);
1592 function normalizeV5AddressItem ( item ) {
1593 if ( ! item || typeof item !== 'object' || Array. isArray (item)) return item;
1594 const out = {};
1595 const address = item.address && typeof item.address === 'object' && ! Array. isArray (item.address)
1596 ? { ... item.address }
1597 : {};
1598 for ( const [ key , value ] of Object. entries (item)) {
1599 if (key === 'address' ) continue ;
1600 if ( V5_ADDRESS_ITEM_KEYS . has (key)) out[key] = value;
1601 else address[key] = value;
1602 }
1603 const tag = normalizeV5AddressTag (out.tag);
1604 if (tag) out.tag = tag;
1605 if (Object. keys (address). length ) out.address = address;
1606 return out;
1607 }
1608 function normalizeV5Contact ( contact = {}) {
1609 const normalized = { ... contact };
1610 if (normalized.phone && typeof normalized.phone === 'object' ) {
1611 const tag = normalizeV5PhoneTag (normalized.phone.tag);
1612 normalized.phone = { ... normalized.phone, ... (tag ? { tag } : {}) };
1613 }
1614 if (Array. isArray (normalized.additionalPhones)) {
1615 normalized.additionalPhones = normalized.additionalPhones. map (( item ) => {
1616 if ( ! item || typeof item !== 'object' ) return item;
1617 const tag = normalizeV5PhoneTag (item.tag);
1618 return { ... item, ... (tag ? { tag } : {}) };
1619 });
1620 }
1621 if (Array. isArray (normalized.addresses)) {
1622 normalized.addresses = normalized.addresses. map (( item ) => normalizeV5AddressItem (item));
1623 }
1624 return normalized;
1625 }
1626 // Legacy V4-style `info` payloads (pre-GA generated transforms) convert through this
1627 // STRICT whitelist: unknown keys throw instead of silently dropping source data.
1628 // `extendedFields` and `labelKeys` throw because they have no mechanical V5 equivalent —
1629 // V5 custom fields live under extendedFields.namespaces (Data Extension Schema) and
1630 // labels became tags (Tags API); both need a setup-time decision, not a converter guess.
1631 const V4_INFO_CONVERTIBLE_KEYS = new Set ([
1632 'name' , 'emails' , 'phones' , 'addresses' , 'company' , 'jobTitle' , 'birthdate' , 'locale' ,
1633 ]);
1634 function contactListItems ( value ) {
1635 if ( ! value) return [];
1636 if (Array. isArray (value)) return value;
1637 if (Array. isArray (value.items)) return value.items;
1638 return [];
1639 }
1640 function pickMainListItem ( items ) {
1641 if ( ! items. length ) return { main: undefined , rest: [] };
1642 const mainIndex = Math. max ( 0 , items. findIndex (( item ) => item
1643 && typeof item === 'object'
1644 && (item.primary === true || String (item.tag || '' ). trim (). toUpperCase () === 'MAIN' )));
1645 return { main: items[mainIndex], rest: items. filter (( _ , index ) => index !== mainIndex) };
1646 }
1647 function contactInfoToV5Contact ( info = {}) {
1648 const unknownKeys = Object. keys (info). filter (( key ) => ! V4_INFO_CONVERTIBLE_KEYS . has (key));
1649 if (unknownKeys. length ) {
1650 throw new Error (
1651 `contactInfoToV5Contact: cannot convert V4-style info key(s) ${ JSON . stringify ( unknownKeys ) } to the GA Contacts V5 contact shape. `
1652 + 'extendedFields values belong under contact.extendedFields.namespaces (Data Extension Schema, FQDN wix.contacts.*.contact); '
1653 + 'labels became tags (Tags API). Regenerate the transform against the flat GA contact shape.' ,
1654 );
1655 }
1656 const contact = {};
1657 if (info.name !== undefined ) contact.name = info.name;
1658 const emails = pickMainListItem ( contactListItems (info.emails));
1659 if (emails.main) contact.email = { email: emails.main.email };
1660 if (emails.rest. length ) contact.additionalEmails = emails.rest. map (( item ) => ({ email: item.email }));
1661 const phones = pickMainListItem ( contactListItems (info.phones));
1662 if (phones.main) {
1663 const tag = normalizeV5PhoneTag (phones.main.tag);
1664 contact.phone = { phone: phones.main.phone, ... (tag ? { tag } : {}) };
1665 }
1666 if (phones.rest. length ) {
1667 contact.additionalPhones = phones.rest. map (( item ) => {
1668 const tag = normalizeV5PhoneTag (item.tag);
1669 return { phone: item.phone, ... (tag ? { tag } : {}) };
1670 });
1671 }
1672 const addresses = contactListItems (info.addresses);
1673 if (addresses. length ) contact.addresses = addresses. map (( item ) => normalizeV5AddressItem (item));
1674 if (info.company !== undefined || info.jobTitle !== undefined ) {
1675 contact.company = {
1676 ... (info.company !== undefined ? { name: info.company } : {}),
1677 ... (info.jobTitle !== undefined ? { jobTitle: info.jobTitle } : {}),
1678 };
1679 }
1680 if (info.birthdate !== undefined ) contact.birthdate = info.birthdate;
1681 if (info.locale !== undefined ) contact.locale = info.locale;
1682 return contact;
1683 }
1684 function toEpochMilliseconds ( value ) {
1685 if (value == null || value === '' ) return undefined ;
1686 if ( typeof value === 'number' && Number. isFinite (value)) {
1687 return value >= 1e12 ? value : value * 1000 ;
1688 }
1689 const parsed = Date. parse ( String (value));
1690 if ( ! Number. isFinite (parsed)) return value;
1691 return parsed;
1692 }
1693 function normalizeCouponSpecification ( specification = {}) {
1694 const normalized = { ... specification };
1695 normalized.startTime = toEpochMilliseconds (specification.startTime);
1696 normalized.expirationTime = toEpochMilliseconds (specification.expirationTime);
1697 if (specification.moneyOffRate != null && specification.percentOffRate == null ) {
1698 normalized.percentOffRate = specification.moneyOffRate;
1699 delete normalized.moneyOffRate;
1700 }
1701 if (normalized.percentOffRate != null ) {
1702 normalized.percentOffRate = Number (normalized.percentOffRate);
1703 }
1704 if (normalized.moneyOffAmount != null && typeof normalized.moneyOffAmount === 'object' ) {
1705 normalized.moneyOffAmount = Number (normalized.moneyOffAmount.amount);
1706 } else if (normalized.moneyOffAmount != null ) {
1707 normalized.moneyOffAmount = Number (normalized.moneyOffAmount);
1708 }
1709 if (normalized.fixedPriceAmount != null && typeof normalized.fixedPriceAmount === 'object' ) {
1710 normalized.fixedPriceAmount = Number (normalized.fixedPriceAmount.amount);
1711 } else if (normalized.fixedPriceAmount != null ) {
1712 normalized.fixedPriceAmount = Number (normalized.fixedPriceAmount);
1713 }
1714 if (normalized.minimumSubtotal != null ) {
1715 normalized.minimumSubtotal = Number (normalized.minimumSubtotal);
1716 }
1717 if (normalized.usageLimit != null ) {
1718 normalized.usageLimit = Number (normalized.usageLimit);
1719 }
1720 if (normalized.limitPerCustomer != null ) {
1721 normalized.limitPerCustomer = Number (normalized.limitPerCustomer);
1722 }
1723 if (normalized.scope && Object. keys (normalized.scope). length === 0 ) {
1724 delete normalized.scope;
1725 }
1726 return normalized;
1727 }
1728 // GA request: POST /contacts/v5/contacts { contact: <flat contact>, allowDuplicates }.
1729 // Accepts the flat GA `contact` directly; a legacy V4-style `info` payload is converted
1730 // via contactInfoToV5Contact (strict — throws on non-mechanical keys). At least one of
1731 // name.first, name.last, email.email, or phone.phone is required by the API.
1732 function buildCreateContactRequest ({ contact , info , allowDuplicates = false }, safeModeOptions ) {
1733 if (contact !== undefined && info !== undefined ) {
1734 throw new Error ( 'buildCreateContactRequest: pass either the flat GA `contact` or a legacy `info`, not both' );
1735 }
1736 const flatContact = info !== undefined ? contactInfoToV5Contact (info) : contact;
1737 if ( ! flatContact || typeof flatContact !== 'object' || Array. isArray (flatContact)) {
1738 throw new Error ( 'buildCreateContactRequest: contact must be a flat GA Contacts V5 contact object' );
1739 }
1740 const safeModeEnabled = isSafeModeEnabled (safeModeOptions);
1741 const prepared = applySafeModeToRequest ({
1742 contact: normalizeV5Contact (flatContact),
1743 allowDuplicates: safeModeEnabled ? true : allowDuplicates,
1744 }, safeModeOptions);
1745 return {
1746 method: 'POST' ,
1747 url: `${ WIXAPIS }/contacts/v5/contacts` ,
1748 body: prepared.body,
1749 ... (prepared.safeMode ? { safeMode: prepared.safeMode } : {}),
1750 };
1751 }
1752 async function createContact ( wix , payload , safeModeOptions ) {
1753 return ( await wix. send ( buildCreateContactRequest (payload, safeModeOptions))).contact;
1754 }
1755
1756 // UNVERIFIED writer (no live run yet). DOCUMENTED endpoint: POST
1757 // /contacts/v5/bulk/contacts/upsert — the CONT-01 import path: 1-100 contacts per call,
1758 // synchronous, per-item results. Contact matching (main email, or main phone when no
1759 // email) decides create vs update, so re-runs upsert instead of duplicating; `externalId`
1760 // (set-once, max 100 chars) carries the source-system id for the crosswalk.
1761 // `upsertMode`: OVERWRITE (default) | APPEND | OVERWRITE_APPEND_ARRAYS.
1762 // Contacts use the same flat GA shape as createContact; each array item wraps as
1763 // `{ contact }`.
1764 const CONTACTS_BULK_UPSERT_MAX = 100 ;
1765 function buildBulkUpsertContactsRequest ( contacts , { upsertMode , returnEntity = false , updateMember } = {}, safeModeOptions ) {
1766 if ( ! Array. isArray (contacts) || contacts. length === 0 ) {
1767 throw new Error ( 'buildBulkUpsertContactsRequest: contacts must be a non-empty array' );
1768 }
1769 if (contacts. length > CONTACTS_BULK_UPSERT_MAX ) {
1770 throw new Error (
1771 `buildBulkUpsertContactsRequest: ${ contacts . length } contacts exceeds the per-request limit of ${ CONTACTS_BULK_UPSERT_MAX } — batch upstream` ,
1772 );
1773 }
1774 const prepared = applySafeModeToRequest ({
1775 contacts: contacts. map (( contact ) => ({ contact: normalizeV5Contact (contact) })),
1776 ... (upsertMode ? { upsertMode } : {}),
1777 ... (returnEntity ? { returnEntity: true } : {}),
1778 ... ( typeof updateMember === 'boolean' ? { updateMember } : {}),
1779 }, safeModeOptions);
1780 return {
1781 method: 'POST' ,
1782 url: `${ WIXAPIS }/contacts/v5/bulk/contacts/upsert` ,
1783 body: prepared.body,
1784 ... (prepared.safeMode ? { safeMode: prepared.safeMode } : {}),
1785 };
1786 }
1787 // Returns per-item outcomes correlated back to the input index — the same flat shape as
1788 // bulkCreateStoresProductsWithInventory, so callers cannot misread partial failure as
1789 // all-or-nothing.
1790 async function bulkUpsertContacts ( wix , contacts , options = {}, safeModeOptions ) {
1791 const response = await wix. send ( buildBulkUpsertContactsRequest (contacts, options, safeModeOptions));
1792 const rawResults = (response && response.results) || [];
1793 const meta = (response && response.bulkActionMetadata) || {};
1794 const results = rawResults. map (( r , position ) => {
1795 const im = (r && r.itemMetadata) || {};
1796 // originalIndex is authoritative; position is the documented fallback only.
1797 const index = Number. isInteger (im.originalIndex) ? im.originalIndex : position;
1798 return {
1799 index,
1800 inputContact: contacts[index],
1801 success: im.success === true ,
1802 contactId: im.id || (r.item && r.item.id) || null ,
1803 action: r.action || null , // CREATED | UPDATED
1804 contact: r.item || null , // populated only with returnEntity: true
1805 errorCode: im.error && im.error.code ? im.error.code : null ,
1806 errorDescription: im.error && im.error.description ? im.error.description : null ,
1807 };
1808 });
1809 const succeeded = results. filter (( r ) => r.success);
1810 const failed = results. filter (( r ) => ! r.success);
1811 const undetailedFailures = meta.undetailedFailures || 0 ;
1812 // A result set that does not account for every input is a correlation bug, not a partial
1813 // success — surface it rather than silently crosswalking the wrong ids.
1814 const unaccounted = contacts. length - results. length - undetailedFailures;
1815 return {
1816 results,
1817 succeeded,
1818 failed,
1819 totalSuccesses: meta.totalSuccesses !== undefined ? meta.totalSuccesses : succeeded. length ,
1820 totalFailures: meta.totalFailures !== undefined ? meta.totalFailures : failed. length ,
1821 undetailedFailures,
1822 unaccounted: unaccounted > 0 ? unaccounted : 0 ,
1823 };
1824 }
1825 function buildQueryContactsRequest ( query = { paging: { limit: 100 , offset: 0 } }) {
1826 return { method: 'POST' , url: `${ WIXAPIS }/contacts/v5/contacts/query` , body: { query } };
1827 }
1828 // ONE PAGE, unwrapped to the contacts array — see the READ/RETURN CONTRACT at the top of this
1829 // file. Contacts pages by `paging.{limit,offset}`, so a full sweep advances the offset off the
1830 // raw response rather than following a cursor; there is no queryAll* helper yet.
1831 async function queryContacts ( wix , query ) {
1832 return ( await wix. send ( buildQueryContactsRequest (query))).contacts || [];
1833 }
1834 function buildGetContactRequest ( contactId ) {
1835 if ( ! contactId) throw new Error ( 'buildGetContactRequest: contactId is required' );
1836 return { method: 'GET' , url: `${ WIXAPIS }/contacts/v5/contacts/${ contactId }` };
1837 }
1838 async function getContact ( wix , contactId ) {
1839 return ( await wix. send ( buildGetContactRequest (contactId))).contact;
1840 }
1841 // GA request: PATCH /contacts/v5/contacts/{id} { contact: { id, revision, <flat fields> },
1842 // allowDuplicates? }. The current revision is REQUIRED (optimistic concurrency); there is
1843 // no fieldMask in the GA contract — passing one throws so stale pre-GA call sites fail
1844 // loudly instead of sending an unrecognized parameter.
1845 function buildUpdateContactRequest ({ contactId , revision , contact , info , allowDuplicates , fieldMask }) {
1846 if (fieldMask !== undefined ) {
1847 throw new Error ( 'buildUpdateContactRequest: GA Contacts V5 update has no fieldMask; send the flat fields to change on `contact`' );
1848 }
1849 if (contact !== undefined && info !== undefined ) {
1850 throw new Error ( 'buildUpdateContactRequest: pass either the flat GA `contact` or a legacy `info`, not both' );
1851 }
1852 const id = contactId || contact?.id;
1853 if ( ! id) throw new Error ( 'buildUpdateContactRequest: contactId is required' );
1854 const rev = revision ?? contact?.revision;
1855 if (rev === undefined || rev === null ) {
1856 throw new Error ( 'buildUpdateContactRequest: revision is required (read the contact first and pass its current revision)' );
1857 }
1858 const flatContact = info !== undefined ? contactInfoToV5Contact (info) : (contact || {});
1859 const nextContact = {
1860 ... normalizeV5Contact (flatContact),
1861 id,
1862 revision: rev,
1863 };
1864 return {
1865 method: 'PATCH' ,
1866 url: `${ WIXAPIS }/contacts/v5/contacts/${ id }` ,
1867 body: {
1868 contact: nextContact,
1869 ... (allowDuplicates !== undefined ? { allowDuplicates } : {}),
1870 },
1871 };
1872 }
1873 async function updateContact ( wix , payload ) {
1874 return ( await wix. send ( buildUpdateContactRequest (payload))).contact;
1875 }
1876 // V4-surface setup helper. Find Or Create Extended Field defines V4 `info.extendedFields`
1877 // custom fields and pairs with V4 contact writers only. For the GA V5 surface, custom
1878 // field definitions go through the Data Extension Schema API (FQDN wix.contacts.*.contact)
1879 // and values are written under `contact.extendedFields.namespaces._user_fields` — see
1880 // specs/0012-wix-extended-fields-setup-contract.md.
1881 function buildFindOrCreateContactExtendedFieldRequest ({ displayName , dataType = 'TEXT' }) {
1882 if ( ! displayName) throw new Error ( 'buildFindOrCreateContactExtendedFieldRequest: displayName is required' );
1883 return {
1884 method: 'POST' ,
1885 url: `${ WIXAPIS }/contacts/v4/extended-fields` ,
1886 body: { displayName, dataType },
1887 };
1888 }
1889 async function findOrCreateContactExtendedField ( wix , payload ) {
1890 return ( await wix. send ( buildFindOrCreateContactExtendedFieldRequest (payload))).field;
1891 }
1892
1893 // --- Coupons ---------------------------------------------------------------
1894 // UNVERIFIED: read-only probe showed /stores/v2/coupons/query reaches the Coupons service
1895 // but returned app-not-installed/unauthorized on the target site. The specification must
1896 // contain exactly one coupon type; generated code must decide per source coupon whether
1897 // native Wix Coupons can represent the source coupon exactly. CMS is not a fallback for a
1898 // missing writer; it is only for coupons whose semantics do not fit Wix Coupons.
1899 function buildCreateCouponRequest ( specification , safeModeOptions ) {
1900 const prepared = applySafeModeToRequest ({ specification: normalizeCouponSpecification (specification) }, safeModeOptions);
1901 return {
1902 method: 'POST' ,
1903 url: `${ WIXAPIS }/stores/v2/coupons` ,
1904 body: prepared.body,
1905 ... (prepared.safeMode ? { safeMode: prepared.safeMode } : {}),
1906 };
1907 }
1908 async function createCoupon ( wix , specification , safeModeOptions ) {
1909 const response = await wix. send ( buildCreateCouponRequest (specification, safeModeOptions));
1910 if (response?.coupon?.id) return response.coupon;
1911 const code = String (specification?.code || '' ). trim ();
1912 if (code) {
1913 for ( let attempt = 0 ; attempt < 4 ; attempt += 1 ) {
1914 const coupons = await queryCoupons (wix, { paging: { limit: 200 , offset: 0 } });
1915 const matched = coupons. find (( coupon ) => String (coupon?.specification?.code || '' ). trim () === code);
1916 if (matched?.id) return matched;
1917 if (attempt < 3 ) {
1918 await new Promise (( resolve ) => setTimeout (resolve, 750 ));
1919 }
1920 }
1921 }
1922 return response.coupon;
1923 }
1924 function buildQueryCouponsRequest ( query = { paging: { limit: 100 , offset: 0 } }) {
1925 return { method: 'POST' , url: `${ WIXAPIS }/stores/v2/coupons/query` , body: { query } };
1926 }
1927 // ONE PAGE, unwrapped to the coupons array — see the READ/RETURN CONTRACT at the top of this file.
1928 async function queryCoupons ( wix , query ) {
1929 return ( await wix. send ( buildQueryCouponsRequest (query))).coupons || [];
1930 }
1931
1932 // --- eCom orders -----------------------------------------------------------
1933 // WARNING — createOrder is NOT for import. POST /ecom/v1/orders is the LIVE-commerce
1934 // Create Order (ECOM-02 in the owner tracker: Not import-suited): it decrements catalog
1935 // inventory, emails the buyer a confirmation, and auto-creates a contact. Historical
1936 // orders MUST go through importOrder below. createOrder remains only for creating a
1937 // genuine live/test order on purpose.
1938 function buildCreateOrderRequest ( order , safeModeOptions ) {
1939 const prepared = applySafeModeToRequest ({ order }, safeModeOptions);
1940 return {
1941 method: 'POST' ,
1942 url: `${ WIXAPIS }/ecom/v1/orders` ,
1943 body: prepared.body,
1944 ... (prepared.safeMode ? { safeMode: prepared.safeMode } : {}),
1945 };
1946 }
1947 async function createOrder ( wix , order , safeModeOptions ) {
1948 return ( await wix. send ( buildCreateOrderRequest (order, safeModeOptions))).order;
1949 }
1950 function buildQueryOrdersRequest ( query = { paging: { limit: 100 } }) {
1951 return { method: 'POST' , url: `${ WIXAPIS }/ecom/v1/orders/query` , body: { query } };
1952 }
1953 // ONE PAGE, unwrapped to the orders array — see the READ/RETURN CONTRACT at the top of this file.
1954 async function queryOrders ( wix , query ) {
1955 return ( await wix. send ( buildQueryOrdersRequest (query))).orders || [];
1956 }
1957
1958 // UNVERIFIED writer (no live run yet). DOCUMENTED endpoint: POST /ecom/v1/orders/import —
1959 // the dedicated migration path (Beta, scope SCOPE.ECOM.IMPORT-ORDERS, ECOM-01 in the owner
1960 // tracker). Values are stored AS-IS (no total/status recalculation). No side effects: no
1961 // buyer notifications, no inventory adjustment, no contact/invoice/receipt/subscription
1962 // creation; standard order webhooks don't fire — a single `OrderImported` event is emitted
1963 // instead (that event has exactly one consumer, so imported orders stay invisible to
1964 // contacts/loyalty and other event-driven views; see ECOM-01).
1965 // Required: lineItems (1-300; each needs quantity, productName.original, itemType, price,
1966 // and catalogItemId+appId when catalogReference is present), billingInfo.contactDetails,
1967 // channelInfo (no SHOPIFY/WOOCOMMERCE enum values — use OTHER_PLATFORM), priceSummary,
1968 // status, paymentStatus (full enum, incl. PAID without a real payment).
1969 // History: purchasedDate/createdDate/number are settable on import (immutable after).
1970 // Re-runs: sending an existing imported order's `id` fully replaces it; overwriting a
1971 // non-imported order fails with CANNOT_OVERWRITE_NON_IMPORTED_ORDER. Cleanup exists via
1972 // Bulk Delete Imported Orders; live-order numbering continues via Set Order Number Counter.
1973 function buildImportOrderRequest ( order , safeModeOptions ) {
1974 const prepared = applySafeModeToRequest ({ order }, safeModeOptions);
1975 return {
1976 method: 'POST' ,
1977 url: `${ WIXAPIS }/ecom/v1/orders/import` ,
1978 body: prepared.body,
1979 ... (prepared.safeMode ? { safeMode: prepared.safeMode } : {}),
1980 };
1981 }
1982 async function importOrder ( wix , order , safeModeOptions ) {
1983 return ( await wix. send ( buildImportOrderRequest (order, safeModeOptions))).order;
1984 }
1985
1986 // --- Stores inventory (Catalog V3 Inventory Items API) ----------------------
1987 // UNVERIFIED: POST /stores/v3/inventory-items creates one inventory item per variant.
1988 // Inventory items are NOT created automatically when a product is created — a separate
1989 // call is required for each variant (per productId + variantId combination).
1990 // To mark a variant as in stock without quantity tracking: set `inStock: true`.
1991 // Omit `locationId` to target the default location (the one Wix's standard checkout
1992 // deducts from). The combination of variantId + locationId must be unique.
1993 //
1994 // How to determine variantIds: `createStoresProduct` returns the full product object;
1995 // the variant IDs are at `product.variantsInfo.variants[].id`.
1996 function buildCreateInventoryItemRequest ({ variantId , productId , locationId , inStock , quantity , trackQuantity , preorderInfo }) {
1997 const item = {
1998 variantId,
1999 productId,
2000 ... (locationId ? { locationId } : {}),
2001 ... ( typeof inStock === 'boolean' ? { inStock } : {}),
2002 ... (quantity != null ? { quantity } : {}),
2003 ... ( typeof trackQuantity === 'boolean' ? { trackQuantity } : {}),
2004 ... (preorderInfo ? { preorderInfo } : {}),
2005 };
2006 return { method: 'POST' , url: `${ WIXAPIS }/stores/v3/inventory-items` , body: { inventoryItem: item } };
2007 }
2008 async function createInventoryItem ( wix , payload ) {
2009 return ( await wix. send ( buildCreateInventoryItemRequest (payload))).inventoryItem;
2010 }
2011 // Convenience: mark all variants of a product as in stock (untracked mode) at the
2012 // default location. Pass the product object returned by `createStoresProduct`.
2013 async function setProductVariantsInStock ( wix , { productId , variantIds , locationId } = {}) {
2014 const results = [];
2015 for ( const variantId of (variantIds || [])) {
2016 results. push ( await createInventoryItem (wix, { variantId, productId, inStock: true , locationId }));
2017 }
2018 return results;
2019 }
2020
2021 // --- members ---------------------------------------------------------------
2022 // VERIFIED: GET /members/v1/members (reconcile), POST /members/v1/members (create).
2023 // Dedup by loginEmail — gated PII; null email cannot dedup/create (use a fallback).
2024 // DOCUMENTED: no bulk create; >=1s spacing between Create Member calls is the
2025 // documented rate-limit floor — space sequential creates and resume via crosswalk.
2026 // DOCUMENTED: create sends no email and does not fire the signup automations
2027 // trigger — member import is silent by default. Passwords are NEVER imported
2028 // (project decision 2026-08-03). Activation (decided): passwordless members
2029 // complete the standard forgot-password flow (confirmed 2026-08-03); delivery is
2030 // a post-import label-wave automation (owner-created, label-added trigger,
2031 // branded email pointing at Log in -> Forgot password; importer labels contacts
2032 // in API batches), enabled only after the import window. There is deliberately
2033 // no send-set-password-email writer here: its link dies in 3h and mass-sending
2034 // it is the exact notification-blast this lib exists to avoid.
2035 // VERIFIED-TRAP (2026-07-19): the default (PUBLIC) fieldset OMITS loginEmail, which
2036 // silently breaks dedupe-by-loginEmail; request fieldsets=FULL so the field is present.
2037 // VERIFIED (2026-08-02, single-site observation): the member list can already contain
2038 // AUTO-CREATED user-members for the site owner / contributing Wix users (status
2039 // APPROVED) even on an API-provisioned site nobody ever visited — seen on our test
2040 // site. Never dedupe or reconcile these against source-site
2041 // members. The owner's user-member is a valid blog author memberId — attribute-to-owner
2042 // blog imports need no member provisioning. Resolve it from THIS list by loginEmail:
2043 // the observed id equality (member id == account GUID) is n=1 on a solo account and
2044 // undocumented — never construct a memberId from the account/user id.
2045 async function listMembers ( wix , { limit = 50 } = {}) {
2046 return wix. send ({ method: 'GET' , url: `${ WIXAPIS }/members/v1/members?fieldsets=FULL&paging.limit=${ limit }` });
2047 }
2048 function buildCreateMemberRequest ({ email , name , slug }, safeModeOptions ) {
2049 if ( ! email) return { skipped: true , reason: 'no email — gated PII; authenticated source re-run required' };
2050 const prepared = applySafeModeToRequest ({ member: { loginEmail: email, contact: { firstName: name }, profile: { nickname: name, slug } } }, safeModeOptions);
2051 return {
2052 method: 'POST' ,
2053 url: `${ WIXAPIS }/members/v1/members` ,
2054 body: prepared.body,
2055 ... (prepared.safeMode ? { safeMode: prepared.safeMode } : {}),
2056 };
2057 }
2058 async function createMember ( wix , payload , safeModeOptions ) {
2059 const request = buildCreateMemberRequest (payload, safeModeOptions);
2060 if (request.skipped) return request;
2061 return ( await wix. send (request)).member;
2062 }
2063
2064 // --- site notifications mute (Notification Preferences V1) ------------------
2065 // VERIFIED (2026-08-04, full cycle live on a test target): mute → state read →
2066 // idempotent re-mute → unmute → state restored, all HTTP 200. All three calls
2067 // return { siteMuteState: { muted, reason?, mutedBy: { wixUserId } } } — the
2068 // executors unwrap to `siteMuteState`.
2069 // Scope (proto doc comment, confirmed by Ping): mutes ALL notifications of the site
2070 // in context, for all recipients and all channels — sendability is denied regardless
2071 // of recipient-level preferences.
2072 // Spec 0012 hard invariant: when mute is in effect (always for new sites; explicit
2073 // opt-in for existing), a failed mute call means the run NEVER proceeds to import
2074 // writes — halt, no degraded mode.
2075 // AUTH TRAP (verified 2026-08-04): the permission grant covers USER tokens only.
2076 // The CLI-minted OauthNG site token (WIX_AUTH_TOKEN from config/wix.env) works; an
2077 // account API key gets a uniform empty-body 403 on all three endpoints.
2078 // IDEMPOTENCY TRAP (verified 2026-08-04): re-muting an already-muted site succeeds
2079 // but OVERWRITES `reason` (last caller wins) — the import preflight's re-call must
2080 // pass the same project-identifying reason as setup, or the audit trail degrades.
2081 // `unmuteSiteNotifications` is NEVER called by the flow itself — explicit owner
2082 // request only (spec 0012); after an on-request unmute, confirm with
2083 // getSiteMuteState (muted: false).
2084 const SITE_MUTE_REASON_MAX = 500 ;
2085 function buildMuteSiteNotificationsRequest ({ reason } = {}) {
2086 const body = reason ? { reason: String (reason). slice ( 0 , SITE_MUTE_REASON_MAX ) } : {};
2087 return { method: 'POST' , url: `${ WIXAPIS }/notification-preferences/v1/site-mute/mute` , body };
2088 }
2089 async function muteSiteNotifications ( wix , payload ) {
2090 return ( await wix. send ( buildMuteSiteNotificationsRequest (payload))).siteMuteState;
2091 }
2092 function buildUnmuteSiteNotificationsRequest () {
2093 return { method: 'POST' , url: `${ WIXAPIS }/notification-preferences/v1/site-mute/unmute` , body: {} };
2094 }
2095 async function unmuteSiteNotifications ( wix ) {
2096 return ( await wix. send ( buildUnmuteSiteNotificationsRequest ())).siteMuteState;
2097 }
2098 function buildGetSiteMuteStateRequest () {
2099 return { method: 'GET' , url: `${ WIXAPIS }/notification-preferences/v1/site-mute` };
2100 }
2101 async function getSiteMuteState ( wix ) {
2102 return ( await wix. send ( buildGetSiteMuteStateRequest ())).siteMuteState;
2103 }
2104
2105 module . exports = {
2106 WIXAPIS,
2107 RICOS_PLUGINS,
2108 RICOS_HTML_CAP,
2109 DEFAULT_SAFE_MODE_PHONE_NUMBER,
2110 SafeModeBlockedError,
2111 createSafeModeConfig,
2112 createDryRunConfig,
2113 normalizeDryRunValue,
2114 createWixSetupExecutor,
2115 mockEmailForEntity,
2116 sanitizeContactFieldsForSafeMode,
2117 sanitizeWixRequestBody,
2118 createWixClient,
2119 buildDirectRestRequest,
2120 sendDirectRest,
2121 notifyMissingWriter,
2122 buildConvertToRicosRequest,
2123 splitHtmlIntoChunks,
2124 convertHtmlToRichContent,
2125 rewriteInlineMedia,
2126 buildImportMediaRequest,
2127 importMedia,
2128 waitUntilFileReady,
2129 buildCreateCategoryRequest,
2130 createBlogCategory,
2131 buildCreateTagRequest,
2132 createBlogTag,
2133 listBlogTags,
2134 buildCreateDraftPostRequest,
2135 createDraftPost,
2136 publishDraftPost,
2137 BLOG_BULK_CREATE_MAX,
2138 buildBulkCreateDraftPostsRequest,
2139 bulkCreateDraftPosts,
2140 buildInsertItemRequest,
2141 insertDataItem,
2142 queryAllDataItems,
2143 WIX_STORES_APP_ID,
2144 STORES_TREE_REFERENCE,
2145 STORES_SUBSCRIPTION_CONTRACT,
2146 STORES_SUBSCRIPTION_DESCRIPTION_MAX,
2147 STORES_SUBSCRIPTION_FREQUENCIES,
2148 normalizeStoresProductV3,
2149 normalizeStoresProductV3ForCreate,
2150 normalizeStoresProductMediaItems,
2151 normalizeStoresProductSubscriptions,
2152 clampStoresSubscriptionDescription,
2153 validateStoresProductSubscriptionDetails,
2154 buildStoresProductMedia,
2155 buildCreateStoresProductRequest,
2156 createStoresProduct,
2157 BULK_PRODUCT_LIMITS,
2158 storesProductBulkCost,
2159 buildBulkCreateStoresProductsRequest,
2160 bulkCreateStoresProductsWithInventory,
2161 buildQueryStoresProductsRequest,
2162 queryStoresProducts,
2163 queryAllStoresProducts,
2164 buildPatchStoresProductMediaRequest,
2165 patchStoresProductMedia,
2166 buildGetStoresProductRequest,
2167 getStoresProduct,
2168 buildGetStoresProductBySlugRequest,
2169 getStoresProductBySlug,
2170 buildDeleteStoresProductRequest,
2171 deleteStoresProduct,
2172 buildPatchStoresProductRequest,
2173 patchStoresProduct,
2174 buildQueryStoresCategoriesRequest,
2175 queryStoresCategories,
2176 queryAllStoresCategories,
2177 buildCreateStoresCategoryRequest,
2178 createStoresCategory,
2179 buildBulkAddItemToCategoriesRequest,
2180 bulkAddItemToCategories,
2181 buildCreateInventoryItemRequest,
2182 createInventoryItem,
2183 setProductVariantsInStock,
2184 normalizeV5Contact,
2185 contactInfoToV5Contact,
2186 buildCreateContactRequest,
2187 createContact,
2188 CONTACTS_BULK_UPSERT_MAX,
2189 buildBulkUpsertContactsRequest,
2190 bulkUpsertContacts,
2191 buildQueryContactsRequest,
2192 queryContacts,
2193 buildGetContactRequest,
2194 getContact,
2195 buildUpdateContactRequest,
2196 updateContact,
2197 buildFindOrCreateContactExtendedFieldRequest,
2198 findOrCreateContactExtendedField,
2199 buildCreateCouponRequest,
2200 createCoupon,
2201 buildQueryCouponsRequest,
2202 queryCoupons,
2203 buildCreateOrderRequest,
2204 createOrder,
2205 buildImportOrderRequest,
2206 importOrder,
2207 buildQueryOrdersRequest,
2208 queryOrders,
2209 listMembers,
2210 buildCreateMemberRequest,
2211 createMember,
2212 SITE_MUTE_REASON_MAX,
2213 buildMuteSiteNotificationsRequest,
2214 muteSiteNotifications,
2215 buildUnmuteSiteNotificationsRequest,
2216 unmuteSiteNotifications,
2217 buildGetSiteMuteStateRequest,
2218 getSiteMuteState,
2219 };