Setting the file. One moment.
Imported Redirect Targets · Wix Replatform · wix/skills · Skills Docs
ContentsBack to the top of the page const identity
— line 236
This file
Number 45.89
Position 89 of 182
Type JavaScript
Size 32 KB
Lines 608 lib/ imported-redirect-targets.js
JavaScript · 608 lines · 32 KB
,
loadRedirects
,
appendRedirectRow
,
validateRedirectRow
,
10 } = require ( './url-preservation-state' );
11
12 const AUDIT_FILE = 'url-rewrite-occurrences.ndjson' ;
13 const REPORT_FILE = 'url-rewrite-report.json' ;
14 const DEFAULT_PORT = { http: 80 , https: 443 };
15
16 function domainIdentity ( value ) {
17 const protocol = value.protocol. slice ( 0 , - 1 );
18 return `${ protocol } \t ${ value . hostname } \t ${ value . port || DEFAULT_PORT [ protocol ] }` ;
19 }
20
21 function validateDomains ( document ) {
22 // The cutover helper already owns source-domains.json. Accept its explicit origin
23 // declarations as website membership without interpreting observed source hosts as
24 // aliases or deriving aliases from suffixes.
25 if (document?.schemaVersion === 1 && Array. isArray (document.origins)) {
26 const { validateSourceDomains } = require ( './url-preservation-state' );
27 const validated = validateSourceDomains (document);
28 document = { schemaVersion: 1 , domains: validated.origins. map (( origin ) => ({
29 hostname: origin.hostname, schemes: [origin.scheme],
30 ... (origin.effectivePort !== DEFAULT_PORT [origin.scheme] ? { port: origin.effectivePort } : {}),
31 cutover: true , declaredBy: 'source-domains' , evidence: origin.role,
32 })). concat (validated.domains || []) };
33 }
34 if ( ! document || document.schemaVersion !== 1 || ! Array. isArray (document.domains)) {
35 throw new Error ( 'source-domains.json requires schemaVersion 1 and domains array' );
36 }
37 const identities = new Map ();
38 document.domains. forEach (( entry , index ) => {
39 if ( ! entry || typeof entry.hostname !== 'string' || ! entry.hostname ||
40 ! Array. isArray (entry.schemes) || ! entry.schemes. length ||
41 typeof entry.cutover !== 'boolean' || ! entry.declaredBy || ! entry.evidence) {
42 throw new Error ( `source-domains.json entry ${ index } is incomplete` );
43 }
44 if ( new URL ( `https://${ entry . hostname }/` ).hostname !== entry.hostname ||
45 entry.hostname. includes ( ':' ) || entry.hostname. includes ( '/' ) || entry.hostname. endsWith ( '.' )) {
46 throw new Error ( `source-domains.json entry ${ index } has noncanonical hostname` );
47 }
48 if (entry.port !== undefined && ( ! Number. isInteger (entry.port) || entry.port < 1 || entry.port > 65535 )) {
49 throw new Error ( `source-domains.json entry ${ index } has invalid port` );
50 }
51 const schemes = new Set ();
52 for ( const scheme of entry.schemes) {
53 if ( ! DEFAULT_PORT [scheme] || schemes. has (scheme) || entry.port === DEFAULT_PORT [scheme]) {
54 throw new Error ( `source-domains.json entry ${ index } has invalid scheme or default port` );
55 }
56 schemes. add (scheme);
57 const key = `${ scheme } \t ${ entry . hostname } \t ${ entry . port || DEFAULT_PORT [ scheme ] }` ;
58 if (identities. has (key)) {
59 throw new Error ( `source-domains.json entries ${ identities . get ( key ). index } and ${ index } duplicate identity` );
60 }
61 identities. set (key, { ... entry, index });
62 }
63 });
64 return identities;
65 }
66
67 function parseHttpUrl ( raw , context ) {
68 if ( typeof raw !== 'string' || ! raw || /%(?! [0-9a-fA-F] {2} )/ . test (raw)) {
69 throw new Error ( 'malformed_url' );
70 }
71 if ( ! / ^ (?:https ? : \/\/ | \/\/ | \/ )/ i . test (raw) && ! context) {
72 throw new Error ( 'missing_source_context' );
73 }
74 let url;
75 try {
76 url = new URL (raw, context);
77 } catch {
78 throw new Error ( 'malformed_url' );
79 }
80 if ( ! [ 'http:' , 'https:' ]. includes (url.protocol) || url.username || url.password) {
81 throw new Error ( 'unsupported_or_credentialed_url' );
82 }
83 return url;
84 }
85
86 function classifyTarget ( rule , domains ) {
87 const source = parseHttpUrl (rule.from, rule.sourceContext);
88 const target = parseHttpUrl (rule.to, source.href);
89 const declared = domains. get ( domainIdentity (target));
90 const siteIdentity = rule.websiteIdentity;
91 const aliases = Array. isArray (rule.websiteAliases) ? rule.websiteAliases : [];
92 const website = new Set ([siteIdentity, ... aliases]. filter (Boolean));
93 for ( const identity of website) {
94 if (domains. get (identity)?.cutover !== true ) {
95 throw new Error ( 'website_identity_not_declared_cutover' );
96 }
97 }
98 const targetIdentity = domainIdentity (target);
99 const sourceOrigins = new Set ((rule.capturedSourceOrigins || []). map (( value ) => domainIdentity ( parseHttpUrl (value))));
100 let kind;
101 if (rule.to. startsWith ( '/' ) && ! rule.to. startsWith ( '//' )) {
102 kind = 'internal' ;
103 } else if (website. has (targetIdentity) && declared?.cutover === true ) {
104 kind = 'internal' ;
105 } else if (website. has (targetIdentity) || sourceOrigins. has (targetIdentity) && ! declared) {
106 kind = 'target_ownership_unresolved' ;
107 } else {
108 kind = 'external' ;
109 }
110 return { source, target, kind };
111 }
112
113 function redact ( raw ) {
114 if ( typeof raw !== 'string' ) return null ;
115 try {
116 const protocolRelative = raw. startsWith ( '//' );
117 const relative = raw. startsWith ( '/' ) && ! protocolRelative;
118 const value = new URL (protocolRelative ? `https:${ raw }` : raw,
119 relative ? 'https://redaction.invalid/' : undefined );
120 if ( ! [ 'http:' , 'https:' ]. includes (value.protocol)) return '[unsupported URL — see local audit]' ;
121 value.username = '' ;
122 value.password = '' ;
123 if (value.search) value.search = '?[redacted]' ;
124 if (relative) return `${ value . pathname }${ value . search }${ value . hash }` ;
125 if (protocolRelative) return `//${ value . host }${ value . pathname }${ value . search }${ value . hash }` ;
126 return value.href;
127 } catch {
128 return '[invalid URL — see local audit]' ;
129 }
130 }
131
132 function actionFor ( row ) {
133 if ((row.disposition === 'excluded_inactive_rule' && ! row.requiresReconciliation) ||
134 (row.disposition === 'removed_input' && ! row.requiresReconciliation)) return null ;
135 const setup = row.disposition === 'external_redirect_automation_unavailable' ;
136 const unsupported = row.disposition === 'external_redirect_unsupported_on_wix' ;
137 const pending = row.disposition === 'external_pending_execution' || row.disposition === 'same_site_plan' ||
138 row.disposition === 'preserved_cutover_target' ;
139 return {
140 code: pending ? 'cutover_links_pending_verification' : 'source_origin_links_need_action' ,
141 severity: unsupported ? 'critical' : 'high' ,
142 action: unsupported ? 'No Wix resolution is known for this exact redirect rule; retain the gap.' :
143 setup ? 'Cutover Operator: set up this rule in Redirect Manager using the original destination; then verify the source status and Location.' :
144 pending ? 'Requires Import Operator: reconcile with the redirect helper and verify the source redirect status and Location before cutover.' :
145 'Requires Import Operator: resolve this rule from immutable capture and capability evidence before execution.' ,
146 deadlineReason: 'Old addresses may fail or send visitors to the wrong destination after DNS cutover.' ,
147 prerequisites: setup ? 'Original source path, status, scope and exact target from the Import Operator.' :
148 'Immutable source capture, declared website origins and current redirect inventory.' ,
149 timing: 'Before DNS preparation; verify again against the public source address after the authorized DNS move.' ,
150 evidence: 'state/url-preservation/url-rewrite-occurrences.ndjson and state/url-preservation/url-verification.ndjson' ,
151 entity: row.sourceStableKey,
152 affectedCount: 1 ,
153 };
154 }
155
156 function digest ( value ) {
157 return crypto. createHash ( 'sha256' ). update ( JSON . stringify (value)). digest ( 'hex' );
158 }
159
160 function planRule ( rule , domains , existingBySource ) {
161 if ( ! rule || rule.kind !== 'imported_redirect' || ! rule.sourceSystem ||
162 ! rule.captureRevision) {
163 throw new Error ( 'redirect record lacks ownership or immutable capture' );
164 }
165 const key = rule.sourceRuleId ? `${ rule . sourceSystem }:redirect:${ rule . sourceRuleId }` :
166 `${ rule . sourceSystem }:redirect:unidentified:${ digest ({ sourceImmutableId: rule.sourceImmutableId || null ,
167 from: rule.from , to: rule.to , captureRevision: rule.captureRevision }) }` ;
168 const base = {
169 schemaVersion: 1 , sourceStableKey: key, field: 'to' , captureRevision: rule.captureRevision,
170 originalSource: typeof rule.from === 'string' ? rule.from : '' ,
171 originalTarget: typeof rule.to === 'string' ? rule.to : '' , httpStatus: rule.httpStatus,
172 matchType: rule.matchType, scope: rule.scope, enabled: rule.enabled !== false ,
173 };
174 const outcome = ( disposition , reason , extras = {}) => ({ ... base, disposition, reason, ... extras });
175 if ( ! rule.sourceRuleId) return outcome ( 'manual_review' , 'stable_source_rule_identity_missing' );
176 if (rule.enabled === false ) return outcome ( 'excluded_inactive_rule' , 'source_rule_disabled' );
177 if ( typeof rule.from !== 'string' || ! rule.from || typeof rule.to !== 'string' || ! rule.to) {
178 return outcome ( 'manual_review' , 'source_or_target_missing_or_nonscalar' );
179 }
180 if (rule.httpStatus !== 301 || rule.matchType !== 'exact' || rule.scope !== 'path' ) {
181 return outcome ( 'manual_review' , 'unsupported_rule_semantics' );
182 }
183 let parsed;
184 try { parsed = classifyTarget (rule, domains); } catch (error) {
185 return outcome ( 'unresolved' , error.message);
186 }
187 const { source , target , kind } = parsed;
188 if (kind === 'target_ownership_unresolved' ) return outcome ( 'unresolved' , kind);
189 if (source.origin !== new URL (rule.sourceContext || source.href).origin || source.search || source.hash) {
190 return outcome ( 'manual_review' , 'unsupported_source_address' );
191 }
192 const previous = existingBySource. get (source.pathname);
193 if (previous && (previous.conflictingPlans || previous.sourceStableKey !== key ||
194 kind === 'external' || ! rule.to. startsWith ( '/' ) || rule.to. startsWith ( '//' ) ||
195 previous.targetRelativeUrl !== target.pathname + target.search + target.hash)) {
196 return outcome ( 'manual_review' , 'stale_or_conflicting_plan' ,
197 { stalePlan: previous, destinationKind: kind, resolvedTarget: target.href });
198 }
199 if (kind === 'external' ) {
200 const capability = rule.capability || {};
201 if (capability.productSupported === false && capability.evidence) {
202 return outcome ( 'external_redirect_unsupported_on_wix' , 'proven_platform_limitation' ,
203 { destinationKind: kind, resolvedTarget: target.href, capability });
204 }
205 if (capability.productSupported === true && capability.automationSupported === false && capability.evidence) {
206 return outcome ( 'external_redirect_automation_unavailable' , 'manual_redirect_manager_setup' ,
207 { destinationKind: kind, resolvedTarget: target.href, capability });
208 }
209 if (capability.productSupported === true && capability.automationSupported === true && capability.evidence) {
210 return outcome ( 'external_pending_execution' , 'awaiting_compatible_redirect_consumer' ,
211 { destinationKind: kind, resolvedTarget: target.href, capability });
212 }
213 return outcome ( 'external_redirect_capability_unverified' , 'capability_evidence_missing' ,
214 { destinationKind: kind, resolvedTarget: target.href, capability });
215 }
216 if ( ! rule.to. startsWith ( '/' ) || rule.to. startsWith ( '//' )) {
217 return outcome ( 'preserved_cutover_target' , 'absolute_internal_target_requires_execution_equivalence' );
218 }
219 if (source.pathname === target.pathname + target.search + target.hash) {
220 return outcome ( 'manual_review' , 'self_loop' );
221 }
222 const plan = {
223 schemaVersion: 1 , sourceStableKey: key, sourceRelativeUrl: source.pathname,
224 targetRelativeUrl: target.pathname + target.search + target.hash, httpStatus: 301 , status: 'planned' ,
225 };
226 validateRedirectRow (plan);
227 return outcome ( 'same_site_plan' , 'equivalent_relative_target' , { plan });
228 }
229
230 function adaptSourceRedirects ( sourceRules , { captureRevision , sourceDomains , capability = {} }) {
231 if ( ! captureRevision || ! sourceDomains?.origins?. length ) throw new Error ( 'source capture revision and declared origins are required' );
232 const origins = sourceDomains.origins;
233 const primary = origins. find (( origin ) => origin.role === 'production' );
234 if ( ! primary) throw new Error ( 'production source origin is required' );
235 const originUrl = ( origin ) => `${ origin . scheme }://${ origin . hostname }${ origin . effectivePort === DEFAULT_PORT [ origin . scheme ] ? '' : `:${ origin . effectivePort }`}/` ;
236 const identity = ( origin ) => `${ origin . scheme } \t ${ origin . hostname } \t ${ origin . effectivePort }` ;
237 const declared = new Set (origins. map (identity));
238 return sourceRules. map (( rule ) => {
239 let sourceContext = originUrl (primary);
240 try {
241 const source = new URL (rule.source);
242 if (declared. has ( domainIdentity (source))) sourceContext = source.origin + '/' ;
243 } catch { /* relative source uses the declared production context */ }
244 return ({
245 kind: 'imported_redirect' , sourceSystem: rule.provider,
246 sourceRuleId: rule.storage?.kind === 'export' ? null : (rule.sourceRuleId || rule.stableId),
247 captureRevision,
248 from: rule.source, to: rule.destination,
249 enabled: rule.enabled,
250 httpStatus: rule.statusCode, matchType: rule.matchType,
251 scope: rule.enabled && ! (rule.conditions || []). length && ! rule.language ? 'path' : 'unsupported' ,
252 sourceContext, websiteIdentity: identity (primary),
253 websiteAliases: origins. filter (( origin ) => origin.role !== 'production' ). map (identity),
254 capturedSourceOrigins: rule.capturedSourceOrigins || [],
255 capability: capability[rule.ruleKey] || rule.capability ||
256 ( / ^ https ? : \/\/ [ ^ ?#] +$ / i . test (rule.destination || '' )
257 ? { productSupported: true , automationSupported: false ,
258 evidence: 'Redirect Manager documents external 301 UI setup; the current API writer accepts only same-site relative targets' }
259 : {}),
260 });
261 });
262 }
263
264 async function readAudit ( file ) {
265 let raw;
266 try { raw = await fs. readFile (file, 'utf8' ); } catch (error) {
267 if (error.code === 'ENOENT' ) return [];
268 throw error;
269 }
270 if (raw && ! raw. endsWith ( ' \n ' )) throw new Error ( 'audit torn tail; locked recovery required' );
271 return raw. split ( ' \n ' ). filter (Boolean). map (( line , index ) => {
272 try {
273 const row = JSON . parse (line);
274 validateAuditRow (row);
275 return row;
276 } catch { throw new Error ( `audit malformed complete row ${ index + 1 }` ); }
277 });
278 }
279
280 function validateAuditRow ( row ) {
281 if ( ! row || typeof row !== 'object' || row.schemaVersion !== 1 ) throw new Error ( 'invalid audit schema' );
282 if (row.type === 'rule' ) {
283 if ( ! row.sourceStableKey || ! row.disposition || typeof row.originalTarget !== 'string' ||
284 ! row.evidenceDigest) throw new Error ( 'invalid audit rule' );
285 } else if (row.type === 'capture_complete' ) {
286 if ( ! row.runId || ! row.captureRevision || ! Number. isInteger (row.count)) throw new Error ( 'invalid capture marker' );
287 } else if (row.type === 'plan_retired' ) {
288 if ( ! row.sourceStableKey || ! row.sourceRelativeUrl || ! row.targetRelativeUrl || ! row.evidence) {
289 throw new Error ( 'invalid plan retirement' );
290 }
291 } else throw new Error ( 'unknown audit row type' );
292 }
293
294 async function currentAuditRows ( projectDir ) {
295 const history = await readAudit (path. join ( urlPreservationDir (projectDir), AUDIT_FILE ));
296 const end = history. findLastIndex (( row ) => row.type === 'capture_complete' );
297 if (end < 0 ) return [];
298 const current = new Map ();
299 const redirects = await loadRedirects (projectDir);
300 for ( const row of completedCaptureRows (history, end)) {
301 current. set (row.sourceStableKey, row.disposition === 'removed_input' ? {
302 ... row, requiresReconciliation: Object. values (redirects.byKey). some (( plan ) =>
303 plan.sourceStableKey === row.sourceStableKey && plan.status === 'planned' ),
304 } : row);
305 }
306 return [ ... current. values ()];
307 }
308
309 function completedCaptureRows ( history , markerIndex ) {
310 if (markerIndex < 0 ) return [];
311 const marker = history[markerIndex];
312 const start = history. slice ( 0 , markerIndex). findLastIndex (( row ) => row.type === 'capture_complete' );
313 const matching = history. slice (start + 1 , markerIndex). filter (( row ) =>
314 row.type === 'rule' && row.runId === marker.runId && row.captureRevision === marker.captureRevision);
315 const rows = marker.count ? matching. slice ( - marker.count) : [];
316 if (rows. length !== marker.count) throw new Error ( 'completed capture marker count does not match its rule rows' );
317 return rows;
318 }
319
320 async function acquireAuditLock ( file ) {
321 const lockFile = `${ file }.lock` ;
322 // This audit lock deliberately requires operator inspection before removal: a dead
323 // writer may have left a torn tail that needs recovery after the stale lock is cleared.
324 let handle;
325 try { handle = await fs. open (lockFile, 'wx' ); }
326 catch (error) {
327 if (error.code === 'EEXIST' ) throw new Error ( `audit lock exists at ${ lockFile }; confirm no writer is running, remove the stale lock, then recover a torn audit tail before retrying planning` );
328 throw error;
329 }
330 return async () => { await handle. close (); await fs. unlink (lockFile); };
331 }
332
333 async function updateVerificationOutcomes ( projectDir , plan , verificationRows ) {
334 const file = path. join (projectDir, 'execution' , REPORT_FILE );
335 let report;
336 try { report = JSON . parse ( await fs. readFile (file, 'utf8' )); }
337 catch (error) { if (error.code === 'ENOENT' ) return null ; throw error; }
338 report.siteId = plan.siteId || report.siteId || null ;
339 const byRule = new Map ();
340 const auditByKey = new Map (( await currentAuditRows (projectDir))
341 . map (( row ) => [row.sourceStableKey, row]));
342 for ( const ob of plan.obligations || []) for ( const source of ob.provenance || []) {
343 if (source.kind === 'source_rule' ) byRule. set ( `${ source . provider }:redirect:${ source . sourceRuleId || source . stableId }` , ob);
344 }
345 const { executionDigestFor } = require ( './url-verification' );
346 const verificationByObligation = new Map ();
347 for ( const entry of verificationRows || []) {
348 if (entry.stage !== 'verified_public' || entry.planDigest !== plan.planDigest ||
349 entry.deploymentId !== plan.deploymentId) continue ;
350 if ( ! verificationByObligation. has (entry.obligationKey)) verificationByObligation. set (entry.obligationKey, []);
351 verificationByObligation. get (entry.obligationKey). push (entry);
352 }
353 for ( const row of report.rules || []) {
354 row.siteId = plan.siteId || row.siteId || null ;
355 const ob = byRule. get (row.sourceStableKey);
356 if ( ! ob || ! ob.externalTarget) continue ;
357 const capture = auditByKey. get (row.sourceStableKey);
358 if ( ! capture || capture.originalTarget !== ob.externalOriginalTarget ||
359 capture.disposition !== ob.externalDisposition) {
360 row.setupEvidence = null ;
361 row.verificationOutcome = 'pending' ;
362 row.verificationEvidence = null ;
363 continue ;
364 }
365 row.setupEvidence = ob.externalSetup ? { remoteId: ob.externalSetup.remoteId,
366 evidence: 'matching current Redirect Manager inventory at planning time' } : null ;
367 if (row.setupEvidence) {
368 row.owner = 'Import Operator' ;
369 row.nextAction = 'Requires Import Operator: run bounded source-status/Location verification after the authorized DNS move.' ;
370 }
371 const digestValue = executionDigestFor (ob, {});
372 const current = (verificationByObligation. get (ob.obligationKey) || []). filter (( entry ) =>
373 entry.executionDigest === digestValue);
374 const byOrigin = new Map (current. map (( entry ) => [entry.origin, entry]));
375 const checked = (ob.origins || []). map (( origin ) => byOrigin. get (origin));
376 row.verificationOutcome = checked. length && checked. every (( entry ) => entry?.result === 'verified_redirect' )
377 ? 'verified_public' : checked. some (( entry ) => entry?.result === 'failed' ) ? 'failed' : 'pending' ;
378 row.verificationEvidence = row.verificationOutcome === 'pending' ? null :
379 'state/url-preservation/url-verification.ndjson' ;
380 if (row.verificationOutcome === 'verified_public' ) row.nextAction = 'No action; source redirect verified publicly.' ;
381 }
382 const reportByKey = new Map (report.rules. map (( row ) => [row.sourceStableKey, row]));
383 report.operatorActions = (report.operatorActions || []). filter (( action ) =>
384 reportByKey. get (action.entity)?.verificationOutcome !== 'verified_public' );
385 for ( const action of report.operatorActions) {
386 const row = reportByKey. get (action.entity);
387 if (row?.setupEvidence) action.action = row.nextAction;
388 }
389 const temp = `${ file }.${ process . pid }.tmp` ;
390 await fs. writeFile (temp, `${ JSON . stringify ( report , null , 2 ) } \n ` );
391 await fs. rename (temp, file);
392 const markdown = [ '# Imported redirect target planning' , '' ,
393 `Run: ${ report . runId }; capture revision: ${ report . captureRevision }; complete: ${ report . captureComplete }.` , '' ,
394 '| Rule | Original source | Original target | Outcome | Verification |' ,
395 '| --- | --- | --- | --- | --- |' ,
396 ... (report.rules || []). map (( row ) => {
397 const safe = ( value ) => String (value || '' ). replace ( / \| / g , ' \\ |' ). replace ( / [\r\n] / g , ' ' );
398 return `| ${ safe ( row . sourceStableKey ) } | ${ safe ( row . source ) } | ${ safe ( row . target ) } | ${ safe ( row . disposition ) } | ${ safe ( row . verificationOutcome || 'pending' ) } |` ;
399 }), '' ]. join ( ' \n ' );
400 const mdFile = path. join (projectDir, 'execution' , 'url-rewrite-report.md' );
401 await fs. writeFile ( `${ mdFile }.tmp` , markdown);
402 await fs. rename ( `${ mdFile }.tmp` , mdFile);
403 return report;
404 }
405
406 async function recoverTornAudit ( projectDir ) {
407 const file = path. join ( urlPreservationDir (projectDir), AUDIT_FILE );
408 const release = await acquireAuditLock (file);
409 try {
410 const raw = await fs. readFile (file, 'utf8' );
411 const end = raw. lastIndexOf ( ' \n ' );
412 const complete = end < 0 ? '' : raw. slice ( 0 , end + 1 );
413 complete. split ( ' \n ' ). filter (Boolean). forEach (( line ) => validateAuditRow ( JSON . parse (line)));
414 const handle = await fs. open (file, 'r+' );
415 try { await handle. truncate (Buffer. byteLength (complete)); await handle. sync (); } finally { await handle. close (); }
416 } finally { await release (); }
417 }
418
419 async function retireImportedPlan ({ projectDir , sourceStableKey , sourceRelativeUrl ,
420 targetRelativeUrl , evidence , targetInventory }) {
421 if ( ! sourceStableKey || ! sourceRelativeUrl || ! targetRelativeUrl || ! evidence || ! Array. isArray (targetInventory)) {
422 throw new Error ( 'retirement needs exact plan identity, evidence and a current destination inventory' );
423 }
424 const auditFile = path. join ( urlPreservationDir (projectDir), AUDIT_FILE );
425 const release = await acquireAuditLock (auditFile);
426 try {
427 const current = await currentAuditRows (projectDir);
428 const capture = current. find (( row ) => row.sourceStableKey === sourceStableKey);
429 if ( ! capture || ! ([ 'removed_input' , 'excluded_inactive_rule' ]. includes (capture.disposition) ||
430 capture.destinationKind === 'external' || capture.reason === 'stale_or_conflicting_plan' )) {
431 throw new Error ( 'current complete capture does not prove an external, removed, disabled or changed source rule' );
432 }
433 const redirects = await loadRedirects (projectDir);
434 const key = `${ sourceRelativeUrl } \t ${ targetRelativeUrl } \t 301` ;
435 const row = redirects.byKey[key];
436 if ( ! row || row.status === 'retired' || row.sourceStableKey !== sourceStableKey) {
437 throw new Error ( 'exact active imported plan was not found' );
438 }
439 const { parseAddress , conflictPath } = require ( './url-redirect-plan' );
440 const source = parseAddress (sourceRelativeUrl);
441 if ( ! source.ok || source.external || targetInventory. some (( remote ) => {
442 const parsed = parseAddress (remote.from);
443 return parsed.ok && ! parsed.external && parsed.conflictPath === conflictPath (source.path);
444 })) throw new Error ( 'destination inventory still has an overlapping source rule' );
445 const retiredAt = new Date (). toISOString ();
446 const handle = await fs. open (auditFile, 'a' );
447 try {
448 await handle. write ( `${ JSON . stringify ({ type: 'plan_retired' , schemaVersion: 1 ,
449 sourceStableKey , sourceRelativeUrl , targetRelativeUrl , evidence , retiredAt }) } \n ` );
450 await handle. sync ();
451 } finally { await handle. close (); }
452 await appendRedirectRow (projectDir, { ... row, status: 'retired' , retirementEvidence: evidence, retiredAt });
453 return { sourceStableKey, sourceRelativeUrl, targetRelativeUrl, retiredAt };
454 } finally { await release (); }
455 }
456
457 async function planImportedRedirectTargets ( input ) {
458 const { projectDir , runId , captureRevision , rules , fullCapture = false } = input;
459 if ( ! projectDir || ! runId || ! captureRevision || ! Array. isArray (rules)) {
460 throw new Error ( 'projectDir, runId, captureRevision and rules are required' );
461 }
462 const dir = urlPreservationDir (projectDir);
463 await fs. mkdir (dir, { recursive: true });
464 const domains = validateDomains ( JSON . parse ( await fs. readFile (path. join (dir, 'source-domains.json' ), 'utf8' )));
465 const { loadStateJson } = require ( './url-preservation-state' );
466 const launchPlan = await loadStateJson (projectDir, 'launchPlan' );
467 const finalRoutes = await loadStateJson (projectDir, 'finalRoutes' );
468 const siteId = launchPlan?.siteId || finalRoutes?.siteId || null ;
469 const auditFile = path. join (dir, AUDIT_FILE );
470 const release = await acquireAuditLock (auditFile);
471 try {
472 const history = await readAudit (auditFile);
473 const redirects = await loadRedirects (projectDir);
474 const existingBySource = new Map ();
475 for ( const row of Object. values (redirects.byKey)) {
476 if (row.status !== 'planned' ) continue ;
477 const prior = existingBySource. get (row.sourceRelativeUrl);
478 existingBySource. set (row.sourceRelativeUrl, prior ? { ... prior, conflictingPlans: true } : row);
479 }
480 const ids = new Set ();
481 const outcomes = rules. map (( rule ) => {
482 if (rule.captureRevision !== captureRevision) {
483 throw new Error ( 'redirect record capture revision does not match invocation' );
484 }
485 const result = planRule (rule, domains, existingBySource);
486 if (result.disposition === 'excluded_inactive_rule' ) {
487 result.requiresReconciliation = Object. values (redirects.byKey). some (( plan ) =>
488 plan.sourceStableKey === result.sourceStableKey && plan.status === 'planned' );
489 }
490 if (ids. has (result.sourceStableKey)) throw new Error ( `duplicate source rule ${ result . sourceStableKey }` );
491 ids. add (result.sourceStableKey);
492 return result;
493 });
494 const completedIndex = history. findLastIndex (( row ) => row.type === 'capture_complete' );
495 const completed = history[completedIndex];
496 if (fullCapture && completed) {
497 const previous = new Map ();
498 for ( const row of completedCaptureRows (history, completedIndex)) previous. set (row.sourceStableKey, row);
499 const retiredSinceCapture = new Set (history. slice (completedIndex + 1 )
500 . filter (( row ) => row.type === 'plan_retired' ). map (( row ) => row.sourceStableKey));
501 for ( const [ key , row ] of previous) {
502 const activePlan = Object. values (redirects.byKey). some (( plan ) =>
503 plan.sourceStableKey === key && plan.status === 'planned' );
504 if ( ! ids. has (key) && (row.disposition !== 'removed_input' ||
505 activePlan || ! retiredSinceCapture. has (key))) {
506 const { type , runId : priorRunId , evidenceDigest , updatedAt , ... base } = row;
507 outcomes. push ({ ... base, captureRevision, disposition: 'removed_input' ,
508 reason: 'missing_from_complete_capture' , requiresReconciliation: activePlan });
509 }
510 }
511 }
512 if (fullCapture && completed?.captureRevision === captureRevision) {
513 const byKey = new Map ( completedCaptureRows (history, completedIndex)
514 . map (( row ) => [row.sourceStableKey, row]));
515 if (byKey.size === outcomes. length && outcomes. every (( row ) =>
516 byKey. get (row.sourceStableKey)?.evidenceDigest === digest (row))) {
517 try {
518 const existingReport = JSON . parse ( await fs. readFile (path. join (projectDir, 'execution' , REPORT_FILE ), 'utf8' ));
519 if (existingReport.siteId === siteId) return existingReport;
520 } catch (error) { if (error.code !== 'ENOENT' ) throw error; }
521 }
522 }
523 const now = new Date (). toISOString ();
524 const rows = outcomes. map (( row ) => ({ ... row, type: 'rule' , runId, evidenceDigest: digest (row), updatedAt: now }));
525 const handle = await fs. open (auditFile, 'a' );
526 try {
527 for ( const row of rows) await handle. write ( `${ JSON . stringify ( row ) } \n ` );
528 await handle. sync ();
529 for ( const row of rows) {
530 if (fullCapture && row.disposition === 'same_site_plan' && redirects.byKey[
531 `${ row . plan . sourceRelativeUrl } \t ${ row . plan . targetRelativeUrl } \t 301` ]?.status !== 'planned' ) {
532 await appendRedirectRow (projectDir, row.plan);
533 }
534 }
535 if (fullCapture) {
536 await handle. write ( `${ JSON . stringify ({ type: 'capture_complete' , schemaVersion: 1 , runId , captureRevision ,
537 count: rows. length , updatedAt: now }) } \n ` );
538 await handle. sync ();
539 }
540 } finally { await handle. close (); }
541 const reportRows = fullCapture ? rows : (() => {
542 const latest = new Map ();
543 if (completed) {
544 for ( const row of completedCaptureRows (history, completedIndex)) latest. set (row.sourceStableKey, row);
545 }
546 for ( const row of rows) latest. set (row.sourceStableKey, { ... row, provisional: true });
547 return [ ... latest. values ()];
548 })();
549 const report = {
550 schemaVersion: 1 , runId, siteId, captureRevision, captureComplete: fullCapture,
551 sourceArtifact: 'state/url-preservation/url-rewrite-occurrences.ndjson' ,
552 rules: reportRows. map (( row ) => {
553 const action = actionFor (row);
554 return {
555 project: path. basename (projectDir), runId, siteId,
556 sourceStableKey: row.sourceStableKey, source: redact (row.originalSource),
557 target: redact (row.originalTarget), disposition: row.disposition, reason: row.reason,
558 provisional: Boolean (row.provisional),
559 capabilityState: row.disposition. startsWith ( 'external_' ) ? row.disposition : null ,
560 customerImpact: action?.deadlineReason || null ,
561 owner: [ 'external_redirect_automation_unavailable' , 'external_redirect_unsupported_on_wix' ]
562 . includes (row.disposition) ? 'Cutover Operator' : action ? 'Import Operator' : null ,
563 nextAction: action?.action || 'No action; no active plan requires reconciliation.' ,
564 prerequisites: action?.prerequisites || null , timing: action?.timing || null ,
565 requiresImportOperator: Boolean (action),
566 importOperatorPrompt: action ? `Requires Import Operator: resume ${ path . basename ( projectDir ) } run ${ runId }, site ${ siteId || 'from the current launch plan'}. Read execution/url-rewrite-report.json and the local url-rewrite-occurrences.ndjson row for ${ row . sourceStableKey }; reconstruct the exact source, target, status and scope from immutable capture. Resolve ${ row . reason } without substituting a local destination. If supported UI setup is needed, hand the exact values and capability evidence to the Cutover Operator before DNS preparation. Refresh the URL report and retain source-status/Location verification evidence after the authorized DNS move.` : null ,
567 closingEvidence: action?.evidence || null , evidenceDigest: row.evidenceDigest,
568 };
569 }),
570 operatorActions: [
571 ... reportRows. map (actionFor). filter (Boolean),
572 ... ( ! fullCapture ? [{ code: 'source_origin_links_need_action' , severity: 'high' ,
573 action: 'Requires Import Operator: complete the authoritative redirect capture before using this run for cutover.' ,
574 deadlineReason: 'This capture was partial; missing rules cannot be interpreted as removals.' ,
575 entity: 'imported redirects' , affectedCount: 0 }] : []),
576 ],
577 };
578 const reportFile = path. join (projectDir, 'execution' , REPORT_FILE );
579 await fs. mkdir (path. dirname (reportFile), { recursive: true });
580 const temp = `${ reportFile }.${ process . pid }.tmp` ;
581 await fs. writeFile (temp, `${ JSON . stringify ( report , null , 2 ) } \n ` );
582 await fs. rename (temp, reportFile);
583 const markdown = [
584 '# Imported redirect target planning' , '' ,
585 `Run: ${ runId }; capture revision: ${ captureRevision }; complete: ${ fullCapture }.` , '' ,
586 '| Rule | Original source | Original target | Outcome | Next action |' ,
587 '| --- | --- | --- | --- | --- |' ,
588 ... report.rules. map (( row ) => {
589 const safe = ( value ) => String (value || '' ). replace ( / \| / g , ' \\ |' ). replace ( / [\r\n] / g , ' ' );
590 const action = report.operatorActions. find (( item ) => item.entity === row.sourceStableKey);
591 return `| ${ safe ( row . sourceStableKey ) } | ${ safe ( row . source ) } | ${ safe ( row . target ) } | ${ safe ( row . disposition ) } | ${ safe ( action ?. action ) } |` ;
592 }), '' ,
593 ]. join ( ' \n ' );
594 await fs. writeFile ( `${ reportFile }.md.tmp` , markdown);
595 await fs. rename ( `${ reportFile }.md.tmp` , path. join (projectDir, 'execution' , 'url-rewrite-report.md' ));
596 const redirectPlan = await loadStateJson (projectDir, 'redirectPlan' );
597 if (redirectPlan) {
598 const { loadUrlVerification } = require ( './url-preservation-state' );
599 return await updateVerificationOutcomes (projectDir, redirectPlan,
600 ( await loadUrlVerification (projectDir)).rows);
601 }
602 return report;
603 } finally { await release (); }
604 }
605
606 module . exports = { validateDomains, classifyTarget, planRule, adaptSourceRedirects,
607 planImportedRedirectTargets, recoverTornAudit, retireImportedPlan,
608 readAudit, currentAuditRows, updateVerificationOutcomes };