Setting the file. One moment.
Plugin Cms Fallback · Wix Replatform · wix/skills · Skills Docs
ContentsBack to the top of the page 99
function eligibilityByCapability
— line 99
This file
Number 45.123
Position 123 of 182
Type JavaScript
Size 35 KB
Lines 747 lib/ plugin-cms-fallback.js
JavaScript · 747 lines · 35 KB
14
15 const fs = require ( 'node:fs' );
16 const path = require ( 'node:path' );
17 const crypto = require ( 'node:crypto' );
18
19 const DECISION_PREFIX = 'pluginCmsFallback:' ;
20 const DECISION_VALUES = new Set ([ 'approved' , 'declined' ]);
21 const DIGEST_VERSION = 1 ;
22
23 const DEFAULT_PLUGINS_ROOT = path. resolve (__dirname, '..' , 'resources' , 'rp-source-wordpress' , 'plugins' );
24 const DEFAULT_TARGET_KNOWLEDGE_ROOT = path. resolve (__dirname, '..' , 'resources' , 'rp-target-wix' , 'domains' );
25
26 function pluginKnowledge () {
27 return require ( '../resources/rp-source-wordpress/lib/plugin-knowledge.js' );
28 }
29
30 function isPlainObject ( value ) {
31 return Boolean (value) && typeof value === 'object' && ! Array. isArray (value);
32 }
33
34 // Canonical JSON: object keys sorted at every depth, arrays kept in order (callers sort the
35 // arrays whose order carries no meaning before hashing).
36 function canonicalize ( value ) {
37 if (Array. isArray (value)) return value. map (canonicalize);
38 if ( isPlainObject (value)) {
39 const out = {};
40 for ( const key of Object. keys (value). sort ()) {
41 if (value[key] !== undefined ) out[key] = canonicalize (value[key]);
42 }
43 return out;
44 }
45 return value === undefined ? null : value;
46 }
47
48 function sha256 ( value ) {
49 return `sha256:${ crypto . createHash ( 'sha256' ). update ( JSON . stringify ( canonicalize ( value ))). digest ( 'hex' ) }` ;
50 }
51
52 function decisionKey ( capability ) {
53 return `${ DECISION_PREFIX }${ capability }` ;
54 }
55
56 // Exact, case-sensitive prefix. A look-alike key is not this family and keeps the generic
57 // fail-closed decision checks.
58 function isCmsFallbackDecisionKey ( key ) {
59 return typeof key === 'string' && key. startsWith ( DECISION_PREFIX );
60 }
61
62 function capabilityOfKey ( key ) {
63 return isCmsFallbackDecisionKey (key) ? key. slice ( DECISION_PREFIX . length ) : null ;
64 }
65
66 function canonicalTargetRefsByEntity ( map ) {
67 const out = {};
68 if ( ! isPlainObject (map)) return out;
69 for ( const key of Object. keys (map). sort ()) {
70 out[key] = Array. isArray (map[key]) ? [ ... map[key]]. map (String). sort () : [];
71 }
72 return out;
73 }
74
75 // The proposal binding: what the merchant was shown. A changed ref, a ref moved between
76 // entities, an added/removed entity, or a changed featureLoss sentence changes the digest.
77 function cmsFallbackProposalDigest ( entry ) {
78 return sha256 ({
79 capability: entry && entry.capability ? String (entry.capability) : null ,
80 targetRefsByEntity: canonicalTargetRefsByEntity (entry && entry.targetRefsByEntity),
81 featureLoss: entry && typeof entry.featureLoss === 'string' ? entry.featureLoss : null ,
82 });
83 }
84
85 function unionTargetRefs ( targetRefsByEntity ) {
86 return Array. from ( new Set (Object. values ( canonicalTargetRefsByEntity (targetRefsByEntity)). flat ())). sort ();
87 }
88
89 // A register entry the runtime may use. Unsigned or structurally incomplete entries are
90 // ignored (the row stays pending), exactly like the other signed registers.
91 function isUsableEligibilityEntry ( entry ) {
92 if ( ! isPlainObject (entry) || ! entry.capability || ! entry.decidedBy || ! entry.decidedOn) return false ;
93 if ( typeof entry.featureLoss !== 'string' || entry.featureLoss. length === 0 ) return false ;
94 if ( ! isPlainObject (entry.targetRefsByEntity) || Object. keys (entry.targetRefsByEntity). length === 0 ) return false ;
95 return Object. values (entry.targetRefsByEntity). every (( refs ) => Array. isArray (refs) && refs. length > 0
96 && refs. every (( ref ) => typeof ref === 'string' && ref. startsWith ( 'cms/' )));
97 }
98
99 function eligibilityByCapability ( entries = []) {
100 const map = new Map ();
101 for ( const entry of entries || []) {
102 if ( isUsableEligibilityEntry (entry)) map. set (entry.capability, entry);
103 }
104 return map;
105 }
106
107 // The audit timestamp the writer records (`Date#toISOString()`): a full ISO-8601 UTC/offset
108 // date-time that is also a real calendar instant. Anything else is a hand edit.
109 const ISO_DATE_TIME = / ^ \d {4} - \d {2} - \d {2} T \d {2} : \d {2} (?:: \d {2} (?: \. \d + ) ? ) ? (?:Z | [+-]\d {2} : \d {2} ) $ / ;
110 function isValidDecidedAt ( decidedAt ) {
111 if ( typeof decidedAt !== 'string' || ! ISO_DATE_TIME . test (decidedAt)) return false ;
112 const time = Date. parse (decidedAt);
113 if (Number. isNaN (time)) return false ;
114 // Reject calendar roll-over such as 2026-02-31 (Date.parse would accept it as March 3).
115 const [ year , month , day ] = decidedAt. slice ( 0 , 10 ). split ( '-' ). map (Number);
116 const probe = new Date (Date. UTC (year, month - 1 , day));
117 return probe. getUTCFullYear () === year && probe. getUTCMonth () === month - 1 && probe. getUTCDate () === day;
118 }
119
120 // Shape check for one `pluginCmsFallback:*` entry. Shared by validateDecisions (quarantine),
121 // the classifier, the writer's stale check and the write-time guard.
122 function cmsFallbackDecisionShape ( key , value ) {
123 if ( ! isPlainObject (value)) return { ok: false , problem: 'not-object' };
124 const capability = capabilityOfKey (key);
125 if ( ! capability) return { ok: false , problem: 'capability-missing' };
126 if ( ! DECISION_VALUES . has (value.value)) return { ok: false , problem: 'value-invalid' };
127 if (value.source !== 'user' ) return { ok: false , problem: 'source-not-user' };
128 if ( ! isValidDecidedAt (value.decidedAt)) return { ok: false , problem: 'decided-at-invalid' };
129 if ( typeof value.approvedBy !== 'string' || value.approvedBy. trim (). length === 0 ) {
130 return { ok: false , problem: 'approved-by-missing' };
131 }
132 if ( ! isPlainObject (value.proposal) || ! isPlainObject (value.proposal.targetRefsByEntity)
133 || typeof value.proposal.featureLoss !== 'string' ) {
134 return { ok: false , problem: 'proposal-missing' };
135 }
136 const rehash = cmsFallbackProposalDigest ({ capability, ... value.proposal });
137 if ( typeof value.proposalDigest !== 'string' || rehash !== value.proposalDigest) {
138 return { ok: false , problem: 'digest-mismatch' };
139 }
140 return { ok: true , problem: null };
141 }
142
143 // What the stored answer means against the current register entry:
144 // none | approved | declined | stale (valid shape, different proposal) | invalid (quarantined)
145 function evaluateCmsFallbackDecision ({ capability , entry = null , decisions = {} }) {
146 const key = decisionKey (capability);
147 const raw = decisions ? decisions[key] : undefined ;
148 if (raw === undefined ) return { state: 'none' , key, raw: null };
149 const shape = cmsFallbackDecisionShape (key, raw);
150 if ( ! shape.ok) return { state: 'invalid' , key, raw, problem: shape.problem };
151 if ( ! entry || raw.proposalDigest !== cmsFallbackProposalDigest (entry)) return { state: 'stale' , key, raw };
152 return { state: raw.value, key, raw };
153 }
154
155 function cmsFallbackDecisionKeys ( decisions = {}) {
156 return Object. keys (decisions || {}). filter (isCmsFallbackDecisionKey). sort ();
157 }
158
159 // ---- operator override: CMS instead of a native target -------------------------------
160 //
161 // The signed register above covers the DEFAULT fallback only: capabilities nothing native fits.
162 // An operator may also choose a private CMS archive for a capability a native Wix target claims.
163 // That answer is `mappingTargetOverride:<capability>` = { value: 'cms', source: 'user',
164 // approvedBy, decidedAt, rationale, proposal, proposalDigest }, bound to the offer the native
165 // coverage row shows (`mappingTargetOverride` on the row). No register entry, agent or
166 // automation mode supplies it; one-click never records it.
167
168 const OVERRIDE_PREFIX = 'mappingTargetOverride:' ;
169 const OVERRIDE_VALUES = new Set ([ 'cms' ]);
170 const OVERRIDE_TARGET_REFS = Object. freeze ([ 'cms/collection' , 'cms/data-item' ]);
171
172 function overrideDecisionKey ( capability ) {
173 return `${ OVERRIDE_PREFIX }${ capability }` ;
174 }
175
176 function isMappingTargetOverrideKey ( key ) {
177 return typeof key === 'string' && key. startsWith ( OVERRIDE_PREFIX );
178 }
179
180 function overrideCapabilityOfKey ( key ) {
181 return isMappingTargetOverrideKey (key) ? key. slice ( OVERRIDE_PREFIX . length ) : null ;
182 }
183
184 function mappingTargetOverrideKeys ( decisions = {}) {
185 return Object. keys (decisions || {}). filter (isMappingTargetOverrideKey). sort ();
186 }
187
188 // The offer for one natively routed capability: every entity it resolves to goes to a private
189 // CMS collection, and the featureLoss sentence names the native target given up.
190 function mappingTargetOverrideProposal ({ capability , entityKeys = [], nativeTargetRefs = [] } = {}) {
191 const targetRefsByEntity = canonicalTargetRefsByEntity (Object. fromEntries (
192 [ ... entityKeys]. map (( key ) => [key, [ ... OVERRIDE_TARGET_REFS ]]),
193 ));
194 const native = [ ... nativeTargetRefs]. map (String). sort ();
195 const featureLoss = `Records are kept as private Wix CMS data instead of ${ native . length ? native . join ( ', ' ) : 'the native Wix target'}; that Wix feature does not receive them or act on them.` ;
196 return { capability, targetRefsByEntity, featureLoss, nativeTargetRefs: native };
197 }
198
199 function mappingTargetOverrideShape ( key , value ) {
200 if ( ! isPlainObject (value)) return { ok: false , problem: 'not-object' };
201 const capability = overrideCapabilityOfKey (key);
202 if ( ! capability) return { ok: false , problem: 'capability-missing' };
203 if ( ! OVERRIDE_VALUES . has (value.value)) return { ok: false , problem: 'value-invalid' };
204 if (value.source !== 'user' ) return { ok: false , problem: 'source-not-user' };
205 if ( ! isValidDecidedAt (value.decidedAt)) return { ok: false , problem: 'decided-at-invalid' };
206 if ( typeof value.approvedBy !== 'string' || value.approvedBy. trim (). length === 0 ) {
207 return { ok: false , problem: 'approved-by-missing' };
208 }
209 if ( ! isPlainObject (value.proposal) || ! isPlainObject (value.proposal.targetRefsByEntity)
210 || typeof value.proposal.featureLoss !== 'string' ) {
211 return { ok: false , problem: 'proposal-missing' };
212 }
213 const rehash = cmsFallbackProposalDigest ({ capability, ... value.proposal });
214 if ( typeof value.proposalDigest !== 'string' || rehash !== value.proposalDigest) {
215 return { ok: false , problem: 'digest-mismatch' };
216 }
217 return { ok: true , problem: null };
218 }
219
220 // What a stored override means against the offer computed now:
221 // none | cms | stale (valid shape, different offer) | invalid (quarantined)
222 function evaluateMappingTargetOverride ({ capability , proposal = null , decisions = {} }) {
223 const key = overrideDecisionKey (capability);
224 const raw = decisions ? decisions[key] : undefined ;
225 if (raw === undefined ) return { state: 'none' , key, raw: null };
226 const shape = mappingTargetOverrideShape (key, raw);
227 if ( ! shape.ok) return { state: 'invalid' , key, raw, problem: shape.problem };
228 if ( ! proposal || raw.proposalDigest !== cmsFallbackProposalDigest (proposal)) return { state: 'stale' , key, raw };
229 return { state: raw.value, key, raw };
230 }
231
232 // The mapping-review notice for a native route whose target has a side effect the operator
233 // should weigh against the CMS override. Keyed by target ref; null when none applies.
234 const NATIVE_ROUTE_NOTICES = Object. freeze ({
235 'forms/form-submission' : 'Old entries will be added to Wix Forms. While they import, the toolkit tries to turn off the form \' s submission emails to you and turns them back on afterwards. If it can \' t, you may get one email per old entry. Choose a private CMS archive instead to avoid Wix Forms entirely.' ,
236 });
237
238 function nativeRouteNotices ( targetRefs = []) {
239 return Array. from ( new Set (targetRefs || [])). sort ()
240 . map (( ref ) => NATIVE_ROUTE_NOTICES [ref]). filter (Boolean);
241 }
242
243 // ---- resolution-input digest -----------------------------------------------------------
244
245 function entitiesForCapability ( entities , capability ) {
246 const list = entities || [];
247 const hasAttribution = list. some (( entity ) => entity && entity.capability);
248 return hasAttribution ? list. filter (( entity ) => entity.capability === capability) : list;
249 }
250
251 // The `<plugin>/<entity>` keys the register must cover for this capability, from the entity
252 // declarations the classifier will actually use.
253 function entityKeysForCapability ( plugins , capability ) {
254 const keys = [];
255 for ( const plugin of plugins || []) {
256 for ( const entity of entitiesForCapability (plugin.entities, capability)) {
257 keys. push ( `${ plugin . plugin }/${ entity . entity }` );
258 }
259 }
260 return keys. sort ();
261 }
262
263 function signedRegisterEntry ( entries , capability , { requireSignature = true } = {}) {
264 const entry = (entries || []). find (( candidate ) => candidate && candidate.capability === capability);
265 if ( ! entry) return null ;
266 if (requireSignature && ! (entry.decidedBy && entry.decidedOn)) return null ;
267 return entry;
268 }
269
270 function resolveTargetKnowledge ( targetKnowledge ) {
271 return typeof targetKnowledge === 'function' ? targetKnowledge () : targetKnowledge;
272 }
273
274 /**
275 * Build the canonical input set that decides every in-scope capability's route.
276 *
277 * In scope: declared by a detected plugin AND (has a register entry OR is named by a
278 * `pluginCmsFallback:*` key). Ordinary plugin rows are out of scope, so they never trigger the
279 * freshness check. `targetKnowledge` may be a value or a loader function; it is only loaded when
280 * at least one capability is in scope.
281 */
282 function cmsFallbackResolutionInputs ({
283 detectedPlugins = [],
284 profiles = [],
285 targetKnowledge = null ,
286 registers = {},
287 decisions = {},
288 } = {}) {
289 const profileBySlug = new Map ((profiles || []). map (( profile ) => [profile.plugin, profile]));
290 const eligibleEntries = registers.cmsFallbackEligible || [];
291 const requiresDevelopment = registers.requiresDevelopment || [];
292 const noMigrationCapabilities = (registers.noMigrationNeeded && registers.noMigrationNeeded.capabilities)
293 || registers.noMigrationCapabilities || [];
294 const keys = cmsFallbackDecisionKeys (decisions);
295 const overrideKeys = mappingTargetOverrideKeys (decisions);
296 const keyedCapabilities = new Set ([ ... keys. map (capabilityOfKey), ... overrideKeys. map (overrideCapabilityOfKey)]. filter (Boolean));
297 const registeredCapabilities = new Set (eligibleEntries. filter (Boolean). map (( entry ) => entry.capability));
298
299 const declaredBy = new Map ();
300 for ( const detected of detectedPlugins || []) {
301 const slug = detected && detected.plugin;
302 if ( ! slug) continue ;
303 const profile = profileBySlug. get (slug) || null ;
304 const capabilities = new Set ([ ... (detected.capabilities || []), ... ((profile && profile.capabilities) || [])]);
305 for ( const capability of capabilities) {
306 if ( ! declaredBy. has (capability)) declaredBy. set (capability, []);
307 declaredBy. get (capability). push ({ slug, profile });
308 }
309 }
310
311 const inScope = Array. from (declaredBy. keys ())
312 . filter (( capability ) => registeredCapabilities. has (capability) || keyedCapabilities. has (capability))
313 . sort ();
314
315 let knowledge = null ;
316 if (inScope. length > 0 ) {
317 try {
318 knowledge = resolveTargetKnowledge (targetKnowledge);
319 } catch (error) {
320 knowledge = { knownRefs: new Set (), capabilityRefs: new Map (), manualStepsByRef: new Map (), loadError: error.message };
321 }
322 }
323 const knownRefs = (knowledge && knowledge.knownRefs) || new Set ();
324 const capabilityRefs = (knowledge && knowledge.capabilityRefs) || new Map ();
325 const manualStepsByRef = (knowledge && knowledge.manualStepsByRef) || new Map ();
326 const loaded = Boolean (knowledge) && ! knowledge.loadError;
327 const refFacts = ( refs ) => Object. fromEntries ([ ... refs]. sort (). map (( ref ) => [ref, {
328 known: knownRefs. has (ref),
329 manualSteps: manualStepsByRef. has (ref),
330 }]));
331
332 const capabilities = {};
333 for ( const capability of inScope) {
334 const nativeRefs = [ ... (capabilityRefs. get (capability) || [])]. sort ();
335 capabilities[capability] = {
336 targetKnowledge: { loaded, nativeRefs: refFacts (nativeRefs) },
337 profiles: declaredBy. get (capability)
338 . sort (( a , b ) => a.slug. localeCompare (b.slug))
339 . map (({ slug , profile }) => (profile ? {
340 plugin: slug,
341 status: 'present' ,
342 capabilities: [ ... (profile.capabilities || [])]. sort (),
343 entities: (profile.entities || []). map (( entity ) => ({
344 entity: entity.entity || null ,
345 route: entity.route || null ,
346 channel: entity.channel || null ,
347 capability: entity.capability || null ,
348 candidateTargetRefs: [ ... (entity.candidateTargetRefs || [])],
349 refs: refFacts (entity.candidateTargetRefs || []),
350 })). sort (( a , b ) => String (a.entity). localeCompare ( String (b.entity))),
351 } : { plugin: slug, status: 'profile-missing' })),
352 requiresDevelopment: signedRegisterEntry (requiresDevelopment, capability, { requireSignature: false }),
353 noMigrationNeeded: signedRegisterEntry (noMigrationCapabilities, capability),
354 eligibility: signedRegisterEntry (eligibleEntries, capability, { requireSignature: false }),
355 decision: decisions && decisions[ decisionKey (capability)] !== undefined ? decisions[ decisionKey (capability)] : null ,
356 // Only present when an override is stored, so projects without one keep their digest.
357 ... (decisions && decisions[ overrideDecisionKey (capability)] !== undefined
358 ? { mappingTargetOverride: decisions[ overrideDecisionKey (capability)] } : {}),
359 };
360 }
361
362 // Every key whose capability is not in scope is carried raw, so writing, editing or removing
363 // any such key changes the digest too.
364 const otherDecisions = {};
365 for ( const key of keys) {
366 if ( ! capabilities[ capabilityOfKey (key)]) otherDecisions[key] = decisions[key];
367 }
368 for ( const key of overrideKeys) {
369 if ( ! capabilities[ overrideCapabilityOfKey (key)]) otherDecisions[key] = decisions[key];
370 }
371
372 return { version: DIGEST_VERSION , capabilities, otherDecisions };
373 }
374
375 function cmsFallbackInputsDigest ( inputs ) {
376 return sha256 (inputs);
377 }
378
379 // ---- project loading (router, write-time guard, reclassifier) ---------------------------
380
381 function readJsonOrNull ( filePath ) {
382 try {
383 return JSON . parse (fs. readFileSync (filePath, 'utf8' ));
384 } catch {
385 return null ;
386 }
387 }
388
389 function loadTargetKnowledgeSummary ( targetKnowledgeRoot = DEFAULT_TARGET_KNOWLEDGE_ROOT ) {
390 try {
391 const { knowledgeSummary } = require ( '../resources/rp-target-wix/lib/domain-knowledge.js' );
392 return knowledgeSummary (targetKnowledgeRoot);
393 } catch (error) {
394 return { knownRefs: new Set (), capabilityRefs: new Map (), manualStepsByRef: new Map (), loadError: error.message };
395 }
396 }
397
398 function loadCmsFallbackRegisters ( pluginsRoot = DEFAULT_PLUGINS_ROOT ) {
399 const knowledge = pluginKnowledge ();
400 return {
401 cmsFallbackEligible: knowledge. loadCmsFallbackEligible (pluginsRoot).capabilities || [],
402 requiresDevelopment: knowledge. loadRequiresDevelopment (pluginsRoot).capabilities || [],
403 noMigrationNeeded: knowledge. loadNoMigrationNeeded (pluginsRoot),
404 };
405 }
406
407 // The canonical artifacts a WordPress project carries: the promoted coverage copy the mapper
408 // reads, and the adapter's raw capture that holds the detection snapshot.
409 function projectPaths ( projectDir ) {
410 return {
411 coverage: path. join (projectDir, 'discovery' , 'plugin-coverage.json' ),
412 rawCoverage: path. join (projectDir, 'data' , 'wp-discovery' , 'plugin-coverage.json' ),
413 inventory: path. join (projectDir, 'data' , 'wp-discovery' , 'plugin-inventory.json' ),
414 canonicalInventory: path. join (projectDir, 'discovery' , 'plugin-inventory.json' ),
415 mappingPlan: path. join (projectDir, 'mapping' , 'mapping-plan.json' ),
416 setupPlan: path. join (projectDir, 'setup' , 'setup-plan.json' ),
417 };
418 }
419
420 function detectedPluginsOf ( inventory ) {
421 return ((inventory && inventory.detected) || []). map (( plugin ) => ({
422 plugin: plugin.plugin,
423 capabilities: [ ... (plugin.capabilities || [])],
424 }));
425 }
426
427 function computeCurrentDigest ({ inventory , decisions = {}, pluginsRoot = DEFAULT_PLUGINS_ROOT , targetKnowledgeRoot = DEFAULT_TARGET_KNOWLEDGE_ROOT , targetKnowledge = null }) {
428 const inputs = cmsFallbackResolutionInputs ({
429 detectedPlugins: detectedPluginsOf (inventory),
430 profiles: pluginKnowledge (). loadProfiles (pluginsRoot),
431 targetKnowledge: targetKnowledge || (() => loadTargetKnowledgeSummary (targetKnowledgeRoot)),
432 registers: loadCmsFallbackRegisters (pluginsRoot),
433 decisions: decisions || {},
434 });
435 return { inputs, digest: cmsFallbackInputsDigest (inputs) };
436 }
437
438 function digestOf ( artifact ) {
439 return artifact && artifact.inputs && typeof artifact.inputs.cmsFallbackDigest === 'string'
440 ? artifact.inputs.cmsFallbackDigest
441 : null ;
442 }
443
444 /**
445 * The router's freshness chain. Returns null when the project has no plugin coverage (nothing
446 * to check), otherwise { digest, stale: null | 'coverage' | 'mapping' | 'setup' }.
447 */
448 function checkCmsFallbackFreshness ( projectDir , { decisions = {}, pluginsRoot , targetKnowledgeRoot } = {}) {
449 const paths = projectPaths (projectDir);
450 if ( ! fs. existsSync (paths.coverage)) return null ;
451 const coverage = readJsonOrNull (paths.coverage);
452 const inventory = readJsonOrNull (paths.inventory) || readJsonOrNull (paths.canonicalInventory);
453 if ( ! coverage || ! inventory) return { digest: null , stale: 'coverage' , missing: ! inventory ? 'plugin-inventory' : 'plugin-coverage' };
454 const { digest } = computeCurrentDigest ({
455 inventory,
456 decisions,
457 pluginsRoot: pluginsRoot || DEFAULT_PLUGINS_ROOT ,
458 targetKnowledgeRoot: targetKnowledgeRoot || DEFAULT_TARGET_KNOWLEDGE_ROOT ,
459 });
460 if ( digestOf (coverage) !== digest) return { digest, stale: 'coverage' };
461 if (fs. existsSync (paths.mappingPlan) && digestOf ( readJsonOrNull (paths.mappingPlan)) !== digest) {
462 return { digest, stale: 'mapping' };
463 }
464 if (fs. existsSync (paths.setupPlan) && digestOf ( readJsonOrNull (paths.setupPlan)) !== digest) {
465 return { digest, stale: 'setup' };
466 }
467 return { digest, stale: null };
468 }
469
470 function readJsonStrict ( filePath ) {
471 return JSON . parse (fs. readFileSync (filePath, 'utf8' ));
472 }
473
474 function writeJsonAtomicSync ( filePath , value ) {
475 const tempPath = `${ filePath }.${ process . pid }.tmp` ;
476 fs. writeFileSync (tempPath, `${ JSON . stringify ( value , null , 2 ) } \n ` , 'utf8' );
477 fs. renameSync (tempPath, filePath);
478 }
479
480 // rp-mapper and rp-setup-discovery bind the plans they write to the coverage they were built
481 // from: mapping-plan.json copies discovery/plugin-coverage.json's digest, setup-plan.json copies
482 // the mapping plan's.
483 function stampPlanDigest ( projectDir , plan ) {
484 const paths = projectPaths (projectDir);
485 const [ sourcePath , targetPath ] = plan === 'mapping'
486 ? [paths.coverage, paths.mappingPlan]
487 : plan === 'setup'
488 ? [paths.mappingPlan, paths.setupPlan]
489 : [ null , null ];
490 if ( ! sourcePath) throw new Error ( 'stamp-plan requires --plan mapping|setup' );
491 if ( ! fs. existsSync (targetPath)) throw new Error ( `${ path . relative ( projectDir , targetPath ) } does not exist; write the plan first` );
492 if ( ! fs. existsSync (sourcePath)) {
493 // No plugin coverage in this project: nothing to bind the plan to.
494 return { stamped: false , reason: `${ path . relative ( projectDir , sourcePath ) } not present` };
495 }
496 const source = readJsonStrict (sourcePath);
497 const digest = source && source.inputs && source.inputs.cmsFallbackDigest;
498 if ( typeof digest !== 'string' && plan === 'setup' && ! fs. existsSync (paths.coverage)) {
499 // No plugin coverage: the mapping plan has no digest to copy, and the router's freshness
500 // chain (checkCmsFallbackFreshness) checks nothing either. A plugin-covered project with a
501 // missing digest still falls through to the error below.
502 return { stamped: false , reason: 'no plugin coverage; nothing to bind the setup plan to' };
503 }
504 if ( typeof digest !== 'string' ) {
505 throw new Error ( `${ path . relative ( projectDir , sourcePath ) } carries no inputs.cmsFallbackDigest; regenerate it before stamping` );
506 }
507 const target = readJsonStrict (targetPath);
508 target.inputs = { ... (target.inputs || {}), cmsFallbackDigest: digest };
509 writeJsonAtomicSync (targetPath, target);
510 return { stamped: true , plan, cmsFallbackDigest: digest };
511 }
512
513 // ---- coverage rows -----------------------------------------------------------------------
514
515 function totalRecords ( row ) {
516 return Object. values ((row && row.recordCounts) || {}). reduce (( sum , value ) => sum + (Number. isFinite (value) ? value : 0 ), 0 );
517 }
518
519 function isApprovedCmsFallbackRow ( row ) {
520 return Boolean (row) && row.status === 'migration-planned' && row.via === 'cms'
521 && Boolean (row.cmsFallback) && row.cmsFallback.functionalityRestored === false ;
522 }
523
524 function isEligibleCmsFallbackRow ( row ) {
525 return Boolean (row) && Boolean (row.cmsFallback) && typeof row.cmsFallback.proposalDigest === 'string' ;
526 }
527
528 // The proposal exactly as the coverage row shows it (pending: proposed refs; approved: the refs
529 // it was approved with). The writer reads it from here so nobody retypes it.
530 function proposalFromRow ( row ) {
531 const fallback = (row && row.cmsFallback) || {};
532 return {
533 targetRefsByEntity: canonicalTargetRefsByEntity (fallback.proposedTargetRefsByEntity || fallback.targetRefsByEntity),
534 featureLoss: typeof fallback.featureLoss === 'string' ? fallback.featureLoss : null ,
535 };
536 }
537
538 // ---- write-time guard -------------------------------------------------------------------
539
540 /**
541 * Defence in depth behind the router: may the importer write CMS records for each eligible
542 * capability? Allowed only when the coverage row is an approved CMS fallback whose approval
543 * passes the shape check and matches the current register entry, AND the current resolution
544 * digest equals the one carried by plugin-coverage, the mapping plan and the setup plan.
545 */
546 //
547 // `refusals` lists the capabilities a write was actually refused for: those in `attempted` (the
548 // capabilities the caller's plan writes to CMS) when given, otherwise those whose coverage row
549 // still claims an approved CMS plan. A pending row with nothing planned is not a refusal.
550 function cmsFallbackWriteGuard ( projectDir , { decisions = null , pluginsRoot , targetKnowledgeRoot , attempted = null } = {}) {
551 const paths = projectPaths (projectDir);
552 const root = pluginsRoot || DEFAULT_PLUGINS_ROOT ;
553 const coverage = readJsonOrNull (paths.coverage) || readJsonOrNull (paths.rawCoverage);
554 const decisionsFile = decisions || readJsonOrNull (path. join (projectDir, 'orchestration' , 'decisions.json' )) || {};
555 const rows = (coverage && coverage.capabilities) || [];
556 const eligible = eligibilityByCapability ( loadCmsFallbackRegisters (root).cmsFallbackEligible);
557
558 const capabilities = new Set ();
559 for ( const row of rows) {
560 if (row && row.recognized && (eligible. has (row.capability) || isEligibleCmsFallbackRow (row))) capabilities. add (row.capability);
561 }
562 const attemptedSet = attempted ? new Set (attempted) : null ;
563 // Derived/unrecognized CMS rows (confidence: proposed, decided at the mapping review) are not
564 // this gate's business; a RECOGNIZED capability written to CMS always is.
565 if (attemptedSet) {
566 for ( const capability of attemptedSet) {
567 if (eligible. has (capability) || rows. some (( row ) => row && row.recognized && row.capability === capability)) capabilities. add (capability);
568 }
569 }
570 if (capabilities.size === 0 ) return { capabilities: {}, refusals: [] };
571
572 const inventory = readJsonOrNull (paths.inventory) || readJsonOrNull (paths.canonicalInventory);
573 const current = inventory
574 ? computeCurrentDigest ({ inventory, decisions: decisionsFile, pluginsRoot: root, targetKnowledgeRoot: targetKnowledgeRoot || DEFAULT_TARGET_KNOWLEDGE_ROOT }).digest
575 : null ;
576 const chain = {
577 coverage: digestOf (coverage),
578 mappingPlan: digestOf ( readJsonOrNull (paths.mappingPlan)),
579 setupPlan: digestOf ( readJsonOrNull (paths.setupPlan)),
580 };
581 const fresh = Boolean (current) && chain.coverage === current && chain.mappingPlan === current && chain.setupPlan === current;
582
583 const result = {};
584 const refusals = [];
585 for ( const capability of Array. from (capabilities). sort ()) {
586 const row = rows. find (( candidate ) => candidate.capability === capability && candidate.recognized) || null ;
587 const entry = eligible. get (capability) || null ;
588 const evaluation = evaluateCmsFallbackDecision ({ capability, entry, decisions: decisionsFile });
589 const entryDigest = entry ? cmsFallbackProposalDigest (entry) : null ;
590 let reason = null ;
591 if ( ! isApprovedCmsFallbackRow (row)) reason = 'coverage-row-not-approved-cms' ;
592 else if (row.cmsFallback.override === true ) reason = overrideRowRefusal (capability, row, decisionsFile, fresh);
593 else if ( ! entry) reason = 'eligibility-missing' ;
594 else if (evaluation.state !== 'approved' ) reason = `decision-${ evaluation . state }` ;
595 else if (row.cmsFallback.proposalDigest !== entryDigest) reason = 'coverage-proposal-stale' ;
596 else if ( ! fresh) reason = 'resolution-inputs-changed' ;
597 const allowed = reason === null ;
598 result[capability] = { allowed, reason, targetRefs: allowed ? [ ... (row.targetRefs || [])] : [] };
599 const wasAttempted = attemptedSet ? attemptedSet. has (capability) : isApprovedCmsFallbackRow (row);
600 if ( ! allowed && wasAttempted) {
601 refusals. push ({ capability, reason, recordCount: totalRecords (row), targetRefs: unionTargetRefs ( proposalFromRow (row).targetRefsByEntity) });
602 }
603 }
604 return { capabilities: result, refusals, digest: { current, ... chain } };
605 }
606
607 // An operator-override CMS row: the stored override must be valid and bound to the offer the row
608 // carries, and the resolution chain must be fresh (it covers profiles, target knowledge and the
609 // override itself, so a row built from other inputs is refused).
610 function overrideRowRefusal ( capability , row , decisions , fresh ) {
611 const proposal = { capability, targetRefsByEntity: row.cmsFallback.targetRefsByEntity, featureLoss: row.cmsFallback.featureLoss };
612 if (row.cmsFallback.proposalDigest !== cmsFallbackProposalDigest (proposal)) return 'coverage-proposal-stale' ;
613 const evaluation = evaluateMappingTargetOverride ({ capability, proposal, decisions });
614 if (evaluation.state !== 'cms' ) return `override-${ evaluation . state }` ;
615 return fresh ? null : 'resolution-inputs-changed' ;
616 }
617
618 // ---- completion operator actions --------------------------------------------------------
619
620 const DEADLINE = 'before DNS change' ;
621
622 function refsText ( refs ) {
623 return refs && refs. length ? refs. join ( ', ' ) : 'CMS collections' ;
624 }
625
626 /**
627 * Operator actions for every eligible capability, derived from the current coverage rows, the
628 * write-time guard's refusals, and the CMS writes recorded by the previous completion report.
629 * Never deletes or rewrites CMS data; withdrawn data is handed to the Cutover Operator.
630 */
631 function cmsFallbackOperatorActions ({ coverage = null , refusals = [], previousWrites = [], writes = [] } = {}) {
632 const rows = ((coverage && coverage.capabilities) || []). filter (( row ) => row && row.recognized && isEligibleCmsFallbackRow (row));
633 const refused = new Map ((refusals || []). map (( refusal ) => [refusal.capability, refusal]));
634 const actions = [];
635
636 for ( const row of rows) {
637 const capability = row.capability;
638 const records = totalRecords (row);
639 const proposal = proposalFromRow (row);
640 if (refused. has (capability)) continue ;
641 if ( isApprovedCmsFallbackRow (row) && row.cmsFallback.override === true ) {
642 actions. push ({
643 code: 'mapping-target-override-archived-in-cms' ,
644 severity: 'medium' ,
645 action: `${ capability }: records were archived in a private Wix CMS collection (${ refsText ( row . targetRefs ) }) because the operator chose CMS over ${ refsText ( row . cmsFallback . nativeTargetRefs ) }. ${ proposal . featureLoss || ''} Review it; dismiss if that is still what you want.` . replace ( / \s + / g , ' ' ). trim (),
646 deadlineReason: DEADLINE ,
647 entity: capability,
648 affectedCount: records,
649 });
650 continue ;
651 }
652 if ( isApprovedCmsFallbackRow (row)) {
653 actions. push ({
654 code: 'plugin-cms-fallback-feature-absent' ,
655 severity: 'medium' ,
656 action: `${ capability }: records were archived in Wix CMS (${ refsText ( row . targetRefs ) }) with the merchant's approval; the plugin's feature is not restored. ${ proposal . featureLoss || ''} Rebuild or replace that feature in Wix; a reconciled CMS collection does not resolve this item.` . replace ( / \s + / g , ' ' ). trim (),
657 deadlineReason: DEADLINE ,
658 entity: capability,
659 affectedCount: records,
660 });
661 continue ;
662 }
663 const declined = row.reason === 'cms-fallback-declined' ;
664 actions. push ({
665 code: declined ? 'plugin-cms-fallback-declined' : 'plugin-cms-fallback-undecided' ,
666 severity: records > 0 ? 'high' : 'low' ,
667 action: declined
668 ? `${ capability }: the merchant declined archiving these records in CMS, so ${ records } record(s) were not migrated. Settle the capability at the mapping review (another verdict) or accept that the data stays in the source.`
669 : `${ capability }: archiving these records in CMS is awaiting the merchant's decision (${ row . reason || 'undecided'}), so ${ records } record(s) were not migrated. Ask the merchant at the mapping review to approve or decline the CMS fallback.` ,
670 deadlineReason: DEADLINE ,
671 entity: capability,
672 affectedCount: records,
673 });
674 }
675
676 for ( const refusal of refusals || []) {
677 actions. push ({
678 code: 'plugin-cms-fallback-write-refused' ,
679 severity: 'high' ,
680 action: `Requires Import Operator: CMS writes for ${ refusal . capability } were refused (${ refusal . reason }); zero records were written. Rerun from the orchestration router so the coverage, mapping plan and setup plan are regenerated, then import again.` ,
681 deadlineReason: DEADLINE ,
682 entity: refusal.capability,
683 affectedCount: Number. isFinite (refusal.recordCount) ? refusal.recordCount : 0 ,
684 });
685 }
686
687 const currentlyApproved = new Set (rows. filter (( row ) => isApprovedCmsFallbackRow (row) && ! refused. has (row.capability)). map (( row ) => row.capability));
688 const writtenNow = new Set ((writes || []). map (( write ) => write.capability));
689 for ( const write of previousWrites || []) {
690 if ( ! write || ! write.capability || ! (write.count > 0 )) continue ;
691 if (currentlyApproved. has (write.capability) || writtenNow. has (write.capability)) continue ;
692 actions. push ({
693 code: 'plugin-cms-fallback-withdrawn' ,
694 severity: 'high' ,
695 action: `${ write . capability }: ${ write . count } record(s) were written to CMS (${ refsText ( write . collections ) }) under an approval that no longer applies. Nothing was deleted. Cutover Operator: keep or remove that CMS data by hand in Business Manager.` ,
696 deadlineReason: DEADLINE ,
697 entity: write.capability,
698 affectedCount: write.count,
699 });
700 }
701 return actions;
702 }
703
704 module . exports = {
705 DECISION_PREFIX,
706 DECISION_VALUES,
707 DEFAULT_PLUGINS_ROOT,
708 DEFAULT_TARGET_KNOWLEDGE_ROOT,
709 canonicalize,
710 decisionKey,
711 isCmsFallbackDecisionKey,
712 capabilityOfKey,
713 canonicalTargetRefsByEntity,
714 cmsFallbackProposalDigest,
715 unionTargetRefs,
716 isUsableEligibilityEntry,
717 eligibilityByCapability,
718 cmsFallbackDecisionShape,
719 evaluateCmsFallbackDecision,
720 cmsFallbackDecisionKeys,
721 entitiesForCapability,
722 entityKeysForCapability,
723 cmsFallbackResolutionInputs,
724 cmsFallbackInputsDigest,
725 loadTargetKnowledgeSummary,
726 loadCmsFallbackRegisters,
727 projectPaths,
728 detectedPluginsOf,
729 computeCurrentDigest,
730 checkCmsFallbackFreshness,
731 stampPlanDigest,
732 totalRecords,
733 isApprovedCmsFallbackRow,
734 isEligibleCmsFallbackRow,
735 proposalFromRow,
736 cmsFallbackWriteGuard,
737 cmsFallbackOperatorActions,
738 OVERRIDE_TARGET_REFS,
739 overrideDecisionKey,
740 isMappingTargetOverrideKey,
741 overrideCapabilityOfKey,
742 mappingTargetOverrideKeys,
743 mappingTargetOverrideProposal,
744 mappingTargetOverrideShape,
745 evaluateMappingTargetOverride,
746 nativeRouteNotices,
747 };