Setting the file. One moment.
URL Redirect Plan · Wix Replatform · wix/skills · Skills Docs
ContentsBack to the top of the page function obligationKeyOf
— line 144
This file
Number 45.160
Position 160 of 182
Type JavaScript
Size 36 KB
Lines 728 lib/ url-redirect-plan.js
JavaScript · 728 lines · 36 KB
13
14 const PLAN_SCHEMA_VERSION = 1 ;
15 const MAX_SOURCE_CHAIN_HOPS = 10 ;
16 const PLACEHOLDER_BASE = 'http://placeholder.invalid' ;
17
18 function sha256 ( value ) {
19 return crypto. createHash ( 'sha256' ). update ( typeof value === 'string' ? value : JSON . stringify (value)). digest ( 'hex' );
20 }
21
22 function stable ( value ) {
23 if (Array. isArray (value)) return value. map (stable);
24 if (value && typeof value === 'object' ) {
25 return Object. keys (value). sort (). reduce (( out , key ) => {
26 if (value[key] !== undefined ) out[key] = stable (value[key]);
27 return out;
28 }, {});
29 }
30 return value;
31 }
32
33 function digest ( value ) {
34 return sha256 ( JSON . stringify ( stable (value)));
35 }
36
37 // --- URL parsing ------------------------------------------------------------------------
38
39 // Structural parse of an old address. Reserved escapes such as %2F are kept as-is (never
40 // decoded), hex case is normalized, non-ASCII is percent-encoded, and the query/fragment are
41 // kept separately so they are never discarded.
42 function parseAddress ( raw , { origins = [] } = {}) {
43 if ( typeof raw !== 'string' || ! raw. trim ()) return { ok: false , reason: 'malformed_url' };
44 const value = raw. trim ();
45 if ( /%(?! [0-9A-Fa-f] {2} )/ . test (value)) return { ok: false , reason: 'malformed_encoding' };
46 let url;
47 let absolute = false ;
48 try {
49 if ( / ^ [a-z][a-z0-9+.-] * :/ i . test (value)) {
50 absolute = true ;
51 url = new URL (value);
52 } else if (value. startsWith ( '//' )) {
53 return { ok: false , reason: 'scheme_relative_url' };
54 } else if (value. startsWith ( '/' )) {
55 url = new URL (value, PLACEHOLDER_BASE );
56 } else {
57 return { ok: false , reason: 'not_site_relative' };
58 }
59 } catch {
60 return { ok: false , reason: 'malformed_url' };
61 }
62 if (absolute && url.protocol !== 'http:' && url.protocol !== 'https:' ) return { ok: false , reason: 'unsupported_scheme' };
63 if (url.username || url.password) return { ok: false , reason: 'credentials_in_url' };
64 let origin = null ;
65 if (absolute) {
66 const scheme = url.protocol. slice ( 0 , - 1 );
67 const port = url.port ? Number (url.port) : (scheme === 'https' ? 443 : 80 );
68 origin = `${ scheme }://${ url . hostname }:${ port }` ;
69 const declared = origins. some (( o ) => `${ o . scheme }://${ o . hostname }:${ o . effectivePort }` === origin);
70 if ( ! declared) return { ok: true , external: true , origin, href: url.href };
71 }
72 const pathName = url.pathname. replace ( /% [0-9a-f] {2} / gi , ( m ) => m. toUpperCase ());
73 return {
74 ok: true ,
75 external: false ,
76 origin,
77 path: pathName,
78 conflictPath: conflictPath (pathName),
79 query: url.search || '' ,
80 fragment: url.hash || '' ,
81 };
82 }
83
84 // Provider trailing-slash equivalence, applied only to redirect conflict keys.
85 function conflictPath ( pathName ) {
86 if (pathName. length > 1 && pathName. endsWith ( '/' )) return pathName. replace ( / \/ +$ / , '' ) || '/' ;
87 return pathName;
88 }
89
90 function isPathPrefix ( prefix , candidate ) {
91 if (prefix === '/' ) return true ;
92 return candidate === prefix || candidate. startsWith ( `${ prefix }/` );
93 }
94
95 // --- remote identity -------------------------------------------------------------------
96
97 // The API stores one site-level rule per path; origins are not part of its identity.
98 function apiScopeOf ( rule ) {
99 return {
100 groupRedirect: Boolean (rule && rule.options && rule.options.groupRedirect),
101 language: (rule && rule.language) || '*' ,
102 };
103 }
104
105 function remoteRuleKey ( siteId , fromConflictPath , scope = { groupRedirect: false , language: '*' }) {
106 return `${ siteId }|${ fromConflictPath }|${ scope . groupRedirect ? 'group' : 'exact'}|${ scope . language }` ;
107 }
108
109 function normalizeInventoryRule ( rule ) {
110 const from = parseAddress (rule.from || '' );
111 const to = parseAddress (rule.to || '' );
112 return {
113 id: rule.id || null ,
114 from: rule.from,
115 to: rule.to,
116 fromConflict: from.ok && ! from.external ? from.conflictPath : null ,
117 toConflict: to.ok && ! to.external ? to.conflictPath : null ,
118 toExternal: Boolean (to.ok && to.external),
119 toQuery: to.ok && ! to.external ? to.query : '' ,
120 scope: apiScopeOf (rule),
121 };
122 }
123
124 function inventoryDigest ( inventory ) {
125 return digest ((inventory || []). map (( rule ) => ({
126 id: rule.id || null , from: rule.from, to: rule.to, scope: apiScopeOf (rule),
127 })). sort (( a , b ) => `${ a . from }|${ a . to }|${ a . id }` . localeCompare ( `${ b . from }|${ b . to }|${ b . id }` )));
128 }
129
130 // --- planning --------------------------------------------------------------------------
131
132 const ENTITY_URL_SKIP = new Set ([ 'not_public' , 'source_url_missing' ]);
133 // Import-era unresolved rows that a final route manifest is expected to settle. Any other open
134 // or manual_review row keeps its decision: the obligation stays manual_review, never a write.
135 const IMPORT_RESOLVABLE_STATUS = new Set ([ 'target_url_missing' , 'pending_target_route' , 'redirect_required' , 'preserved' ]);
136 // Only these documented routing uncertainties can be settled by an exact final route.
137 // Privacy, explicit decisions, missing reasons and all other causes remain restrictions.
138 const BASE_PATH_ROUTING_REASONS = new Set ([ 'target_route_unknown' , 'target_url_missing' , 'pending_target_route' , 'final_route_missing' ]);
139
140 function addReason ( target , code ) {
141 if ( ! target.reasonCodes. includes (code)) target.reasonCodes. push (code);
142 }
143
144 function obligationKeyOf ( parsed ) {
145 return `${ parsed . path }${ parsed . query }${ parsed . fragment }` ;
146 }
147
148 function buildEntityIndex ( ledgerRows ) {
149 const byConflictPath = new Map ();
150 for ( const row of ledgerRows) {
151 if ( ENTITY_URL_SKIP . has (row.urlStatus) || ! row.sourceRelativeUrl) continue ;
152 const parsed = parseAddress (row.sourceRelativeUrl);
153 if ( ! parsed.ok || parsed.external) continue ;
154 const list = byConflictPath. get (parsed.conflictPath) || [];
155 if ( ! list. some (( entry ) => entry.sourceStableKey === row.sourceStableKey)) list. push (row);
156 byConflictPath. set (parsed.conflictPath, list);
157 }
158 return byConflictPath;
159 }
160
161 function buildRouteIndexes ( finalRoutes ) {
162 const bySourceKey = new Map ();
163 const byConflictPath = new Map ();
164 for ( const route of (finalRoutes && finalRoutes.routes) || []) {
165 const parsed = parseAddress (route.relativeUrl);
166 if ( ! parsed.ok || parsed.external) continue ;
167 const entry = { ... route, conflictPath: parsed.conflictPath, path: parsed.path, query: parsed.query };
168 bySourceKey. set (route.sourceKey, [ ... (bySourceKey. get (route.sourceKey) || []), entry]);
169 byConflictPath. set (parsed.conflictPath, [ ... (byConflictPath. get (parsed.conflictPath) || []), entry]);
170 }
171 return { bySourceKey, byConflictPath };
172 }
173
174 function distinctRoutes ( routes ) {
175 const seen = new Map ();
176 for ( const route of routes) seen. set ( `${ route . targetEntityType }|${ route . targetEntityId }|${ route . conflictPath }` , route);
177 return [ ... seen. values ()];
178 }
179
180 // Follow enabled exact source rules to the current source entity whose page they reach.
181 function resolveSourceChain ( startRule , ctx ) {
182 const visited = [];
183 let rule = startRule;
184 for ( let hop = 0 ; hop <= MAX_SOURCE_CHAIN_HOPS ; hop += 1 ) {
185 visited. push (rule.ruleKey);
186 const dest = parseAddress (rule.destination || '' , { origins: ctx.origins });
187 if ( ! dest.ok) return { status: 'manual_review' , reason: `destination_${ dest . reason }` , edges: visited };
188 if (dest.external) return { status: 'manual_review' , reason: 'external_destination' , edges: visited };
189 if (dest.query) return { status: 'manual_review' , reason: 'destination_query_semantics' , edges: visited };
190 const entities = ctx.entityIndex. get (dest.conflictPath) || [];
191 if (entities. length > 1 ) return { status: 'manual_review' , reason: 'ambiguous_destination_entity' , edges: visited };
192 if (entities. length === 1 ) return { status: 'resolved' , entity: entities[ 0 ], edges: visited };
193 const next = ctx.exactRulesBySource. get (dest.conflictPath) || [];
194 if (next. length > 1 ) return { status: 'manual_review' , reason: 'conflicting_source_rules' , edges: visited };
195 if (next. length === 0 ) return { status: 'unresolved' , reason: 'destination_not_found' , edges: visited };
196 if (visited. includes (next[ 0 ].ruleKey)) return { status: 'manual_review' , reason: 'source_rule_cycle' , edges: visited };
197 rule = next[ 0 ];
198 }
199 return { status: 'manual_review' , reason: 'source_chain_too_long' , edges: visited };
200 }
201
202 function classifySourceRule ( rule ) {
203 if ( ! rule.enabled) return 'disabled_rule_excluded' ;
204 if (rule.matchType !== 'exact' ) return 'unsupported_match_type' ;
205 // Only exact 301 rules can be written (the API takes an unqualified from/to rule). A 308
206 // keeps the request method; rewriting it as a 301 would silently change its semantics.
207 if (rule.statusCode === 308 ) return 'status_308_not_writable' ;
208 if (rule.statusCode !== 301 ) return 'non_permanent_status' ;
209 if (rule.language) return 'language_scoped_rule' ;
210 if (Array. isArray (rule.conditions) && rule.conditions. length ) {
211 const onlyHost = rule.conditions. every (( c ) => c && c.type === 'host' );
212 if ( ! onlyHost) return 'conditional_rule' ;
213 }
214 return null ;
215 }
216
217 function hostConditionOrigins ( rule , origins ) {
218 const hosts = (rule.conditions || []). filter (( c ) => c && c.type === 'host' ). map (( c ) => String (c.value || '' ). toLowerCase ());
219 if ( ! hosts. length ) return null ;
220 return origins. filter (( o ) => hosts. includes (o.hostname));
221 }
222
223 function planRedirects ( input ) {
224 const {
225 siteId ,
226 ledgerRows = [],
227 sourceRules = [],
228 importedRedirectOutcomes = [],
229 coverage = null ,
230 finalRoutes = null ,
231 sourceDomains = null ,
232 targetInventory = null ,
233 importRedirects = [],
234 invalidImportRedirects = [],
235 importUnresolved = [],
236 basePaths = [],
237 } = input;
238 const origins = (sourceDomains && sourceDomains.origins) || [];
239 const originKeys = origins. map (( o ) => `${ o . scheme }://${ o . hostname }:${ o . effectivePort }` );
240 const deploymentId = finalRoutes ? finalRoutes.deploymentId : null ;
241 const ruleFindings = [];
242 const importedByRule = new Map (importedRedirectOutcomes. map (( row ) => [row.sourceStableKey, row]));
243 const capturedRuleKeys = new Set (sourceRules. filter (( rule ) => rule.enabled && rule.storage?.kind !== 'export' )
244 . map (( rule ) => `${ rule . provider }:redirect:${ rule . sourceRuleId || rule . stableId }` ));
245 const inventoryBySource = new Map ();
246 for ( const rule of targetInventory || []) {
247 const normalized = normalizeInventoryRule (rule);
248 if ( ! normalized.fromConflict) continue ;
249 if ( ! inventoryBySource. has (normalized.fromConflict)) inventoryBySource. set (normalized.fromConflict, []);
250 inventoryBySource. get (normalized.fromConflict). push ({ rule, normalized });
251 }
252
253 // Source-rule inventory: deduplicate identical definitions across providers, keep evidence.
254 const activeRules = [];
255 const semantic = new Map ();
256 for ( const rule of sourceRules) {
257 const exclusion = classifySourceRule (rule);
258 if (exclusion === 'disabled_rule_excluded' ) {
259 ruleFindings. push ({ ruleKey: rule.ruleKey, provider: rule.provider, disposition: 'excluded' , reasonCodes: [exclusion] });
260 continue ;
261 }
262 const src = parseAddress (rule.source, { origins });
263 const key = src.ok && ! src.external ? `${ src . conflictPath }${ src . query }` : `raw:${ rule . source }` ;
264 const signature = JSON . stringify ([key, rule.destination, rule.statusCode, rule.matchType, rule.conditions || null , rule.language || null ]);
265 if (semantic. has (signature)) {
266 semantic. get (signature).duplicateEvidence. push ({ ruleKey: rule.ruleKey, provider: rule.provider, storage: rule.storage });
267 continue ;
268 }
269 const entry = { ... rule, parsedSource: src, exclusion, duplicateEvidence: [] };
270 semantic. set (signature, entry);
271 activeRules. push (entry);
272 }
273 const exactRulesBySource = new Map ();
274 for ( const rule of activeRules) {
275 if (rule.exclusion || ! rule.parsedSource.ok || rule.parsedSource.external || rule.parsedSource.query) continue ;
276 const list = exactRulesBySource. get (rule.parsedSource.conflictPath) || [];
277 list. push (rule);
278 exactRulesBySource. set (rule.parsedSource.conflictPath, list);
279 }
280
281 const entityIndex = buildEntityIndex (ledgerRows);
282 const entityTypesByKey = new Map ();
283 for ( const row of ledgerRows) {
284 if ( ! row.sourceStableKey || ! row.sourceEntityType) continue ;
285 if ( ! entityTypesByKey. has (row.sourceStableKey)) entityTypesByKey. set (row.sourceStableKey, new Set ());
286 entityTypesByKey. get (row.sourceStableKey). add (row.sourceEntityType);
287 }
288 const routes = buildRouteIndexes (finalRoutes);
289 const ctx = { origins, entityIndex, exactRulesBySource };
290 const obligations = new Map ();
291
292 function obligationFor ( parsed , rawUrl ) {
293 const key = parsed.ok && ! parsed.external ? obligationKeyOf (parsed) : `refused:${ rawUrl }` ;
294 if ( ! obligations. has (key)) {
295 obligations. set (key, {
296 obligationKey: key,
297 originalUrl: rawUrl,
298 path: parsed.ok ? parsed.path || null : null ,
299 conflictPath: parsed.ok ? parsed.conflictPath || null : null ,
300 query: parsed.ok ? parsed.query || '' : '' ,
301 fragment: parsed.ok ? parsed.fragment || '' : '' ,
302 provenance: [],
303 origins: new Set (),
304 targets: [],
305 disposition: null ,
306 reasonCodes: [],
307 expected: null ,
308 remoteRuleKey: null ,
309 });
310 }
311 return obligations. get (key);
312 }
313
314 function attachTarget ( ob , entity , via , originSubset ) {
315 const types = new Set (entityTypesByKey. get (entity.sourceStableKey) || []);
316 if (entity.sourceEntityType) types. add (entity.sourceEntityType);
317 const resolvedEntity = { ... entity, sourceEntityType: types.size === 1 ? [ ... types][ 0 ] : null };
318 ob.targets. push ({ entity: resolvedEntity, via, origins: originSubset || originKeys });
319 for ( const key of originSubset || originKeys) ob.origins. add (key);
320 }
321
322 for ( const row of ledgerRows) {
323 if ( ENTITY_URL_SKIP . has (row.urlStatus) || ! row.sourceRelativeUrl) continue ;
324 const parsed = parseAddress (row.sourceRelativeUrl, { origins });
325 const ob = obligationFor (parsed, row.sourceRelativeUrl);
326 ob.provenance. push ({ kind: 'entity_url' , sourceStableKey: row.sourceStableKey, sourceEntityType: row.sourceEntityType, urlStatus: row.urlStatus });
327 if ( ! parsed.ok) { addReason (ob, parsed.reason); continue ; }
328 // A recorded manual decision is never turned into an automatic write.
329 if (row.urlStatus === 'manual_review' ) addReason (ob, 'import_manual_review' );
330 attachTarget (ob, row, 'entity_url' );
331 }
332
333 // Replay import-era redirect requirements: each one stays an obligation for its entity even
334 // when the ledger no longer lists that address. The final route decides the destination;
335 // the import-era target is kept as evidence.
336 for ( const row of importRedirects) {
337 // Source-authored exact rules also have their full semantics in sourceRules. Their
338 // import-era relative plan is compatibility evidence, not a second entity owner.
339 if (capturedRuleKeys. has (row.sourceStableKey)) continue ;
340 if ( /:redirect:/ . test (row.sourceStableKey || '' )) {
341 const parsed = parseAddress (row.sourceRelativeUrl, { origins });
342 const ob = obligationFor (parsed, row.sourceRelativeUrl);
343 ob.provenance. push ({ kind: 'inactive_import_redirect' , sourceStableKey: row.sourceStableKey,
344 recordedDestination: row.targetRelativeUrl });
345 addReason (ob, 'inactive_import_redirect_plan_requires_reconciliation' );
346 continue ;
347 }
348 const parsed = parseAddress (row.sourceRelativeUrl, { origins });
349 const ob = obligationFor (parsed, row.sourceRelativeUrl);
350 ob.provenance. push ({ kind: 'import_redirect' , sourceStableKey: row.sourceStableKey, importTarget: row.targetRelativeUrl, status: row.status });
351 if ( ! parsed.ok) { addReason (ob, parsed.reason); continue ; }
352 attachTarget (ob, { sourceStableKey: row.sourceStableKey, sourceEntityType: row.sourceEntityType || null }, 'import_redirect' );
353 }
354
355 for ( const row of invalidImportRedirects) {
356 const source = row.sourceRelativeUrl || `redirects.ndjson line ${ row . line }: address missing` ;
357 const parsed = parseAddress (source, { origins });
358 const ob = obligationFor (parsed, source);
359 ob.provenance. push ({ kind: 'import_redirect_rejected' , line: row.line, recordedDestination: row.recordedDestination || null , recordedStatus: row.recordedStatus || null });
360 addReason (ob, row.reason || 'import_redirect_invalid_row' );
361 if ( ! parsed.ok) addReason (ob, parsed.reason);
362 }
363
364 // Replay import-era unresolved rows. Resolved rows are evidence only. Open rows that only
365 // waited for a final route stay obligations; anything else keeps a manual decision.
366 for ( const row of importUnresolved) {
367 const parsed = parseAddress (row.sourceRelativeUrl, { origins });
368 const evidence = { kind: 'import_unresolved' , sourceStableKey: row.sourceStableKey, urlStatus: row.urlStatus, status: row.status, reason: row.reason || null };
369 if (row.status === 'resolved' ) {
370 const key = parsed.ok && ! parsed.external ? obligationKeyOf (parsed) : `refused:${ row . sourceRelativeUrl }` ;
371 if (obligations. has (key)) obligations. get (key).provenance. push (evidence);
372 continue ;
373 }
374 const ob = obligationFor (parsed, row.sourceRelativeUrl);
375 ob.provenance. push (evidence);
376 if ( ! parsed.ok) { addReason (ob, parsed.reason); continue ; }
377 if (row.status === 'manual_review' || row.urlStatus === 'manual_review' ) addReason (ob, 'import_manual_review' );
378 else if ( ! IMPORT_RESOLVABLE_STATUS . has (row.urlStatus)) addReason (ob, `import_unresolved_${ row . urlStatus }` );
379 attachTarget (ob, { sourceStableKey: row.sourceStableKey, sourceEntityType: row.sourceEntityType }, 'import_unresolved' );
380 }
381
382 for ( const row of importedRedirectOutcomes) {
383 if (row.disposition !== 'removed_input' ) continue ;
384 const parsed = parseAddress (row.originalSource || '' , { origins });
385 const remoteRuleExists = parsed.ok && ! parsed.external &&
386 (inventoryBySource. get (parsed.conflictPath) || []). length > 0 ;
387 if ( ! row.requiresReconciliation && ! remoteRuleExists) continue ;
388 const ob = obligationFor (parsed, row.originalSource || `removed:${ row . sourceStableKey }` );
389 ob.provenance. push ({ kind: 'imported_redirect_removed' , sourceStableKey: row.sourceStableKey,
390 evidenceDigest: row.evidenceDigest });
391 addReason (ob, 'removed_source_rule_requires_reconciliation' );
392 }
393
394 for ( const rule of activeRules) {
395 const parsed = rule.parsedSource;
396 const ob = obligationFor (parsed, rule.source);
397 ob.provenance. push ({ kind: 'source_rule' , ruleKey: rule.ruleKey, provider: rule.provider,
398 storage: rule.storage, stableId: rule.stableId, sourceRuleId: rule.sourceRuleId || rule.stableId,
399 duplicates: rule.duplicateEvidence });
400 ruleFindings. push ({ ruleKey: rule.ruleKey, provider: rule.provider, disposition: 'obligation' , obligationKey: ob.obligationKey, duplicates: rule.duplicateEvidence. map (( d ) => d.ruleKey), reasonCodes: rule.exclusion ? [rule.exclusion] : [] });
401 if ( ! parsed.ok) { addReason (ob, parsed.reason); continue ; }
402 if (parsed.external) { addReason (ob, 'source_on_undeclared_origin' ); continue ; }
403 if (rule.exclusion) { addReason (ob, rule.exclusion); continue ; }
404 if (parsed.query) { addReason (ob, 'query_conditional_rule' ); continue ; }
405 const imported = importedByRule. get ( `${ rule . provider }:redirect:${ rule . sourceRuleId || rule . stableId }` );
406 const target = parseAddress (imported?.resolvedTarget || rule.destination || '' , { origins });
407 if (target.ok && target.external) {
408 if (ob.externalTarget && ob.externalTarget !== rule.destination) {
409 addReason (ob, 'conflicting_source_rules' );
410 continue ;
411 }
412 if ( ! imported || imported.originalTarget !== rule.destination || imported.destinationKind !== 'external' ) {
413 addReason (ob, 'external_destination' );
414 addReason (ob, 'external_target_audit_missing_or_stale' );
415 continue ;
416 }
417 if (ob.targets. length || ob.provenance. some (( item ) => item.kind !== 'source_rule' )) {
418 addReason (ob, 'source_address_ownership_conflict' );
419 continue ;
420 }
421 ob.externalTarget = imported.resolvedTarget || imported.originalTarget;
422 ob.externalOriginalTarget = imported.originalTarget;
423 ob.externalDisposition = imported.disposition;
424 ob.externalEvidenceDigest = imported.evidenceDigest;
425 continue ;
426 }
427 const subset = hostConditionOrigins (rule, origins);
428 if (subset && subset. length === 0 ) { addReason (ob, 'host_condition_undeclared' ); continue ; }
429 const chain = resolveSourceChain (rule, ctx);
430 ob.provenance[ob.provenance. length - 1 ].chain = chain.edges;
431 if (chain.status !== 'resolved' ) { addReason (ob, chain.reason); ob.chainStatus = chain.status; continue ; }
432 attachTarget (ob, chain.entity, 'source_rule' , subset ? subset. map (( o ) => `${ o . scheme }://${ o . hostname }:${ o . effectivePort }` ) : null );
433 }
434
435 // Import-era base-path decisions: a base path held for manual review, or recorded as not
436 // public, is evaluated by reason for each matching occurrence. Unknown type cannot bypass it.
437 const heldBasePaths = basePaths
438 . filter (( entry ) => entry.status === 'manual_review' || entry.public === false )
439 . map (( entry ) => ({ entry, parsed: parseAddress (entry.sourceBasePath) }));
440
441 // Resolve each obligation's intended final page and whether a redirect is needed.
442 for ( const ob of obligations. values ()) {
443 if (ob.externalTarget) {
444 if (ob.reasonCodes. length || ! origins. length || ob.path === '/' ) {
445 ob.disposition = 'manual_review' ;
446 if ( ! origins. length ) addReason (ob, 'origins_not_declared' );
447 if (ob.path === '/' ) addReason (ob, 'source_root' );
448 } else if ( ! [ 'external_pending_execution' , 'external_redirect_automation_unavailable' ]. includes (ob.externalDisposition)) {
449 ob.disposition = 'manual_review' ;
450 addReason (ob, ob.externalDisposition || 'external_target_not_ready' );
451 } else {
452 ob.disposition = 'external_redirect' ;
453 // The same-site API writer never accepts an external destination. A matching
454 // inventory row is setup evidence only; public source verification is separate.
455 const atSource = inventoryBySource. get (ob.conflictPath) || [];
456 const matching = atSource. filter (({ rule , normalized }) =>
457 ! normalized.scope.groupRedirect && normalized.scope.language === '*' &&
458 rule.to === ob.externalTarget);
459 ob.externalSetup = matching. length === 1 ?
460 { remoteId: matching[ 0 ].rule.id || null , target: matching[ 0 ].rule.to } : null ;
461 if (matching. length > 1 ) { ob.disposition = 'manual_review' ; addReason (ob, 'external_setup_conflict' ); }
462 if (atSource. some (( entry ) => ! matching. includes (entry))) {
463 ob.disposition = 'manual_review' ; addReason (ob, 'existing_rule_conflict' ); ob.externalSetup = null ;
464 }
465 }
466 continue ;
467 }
468 ob.origins = [ ... ob.origins]. sort ();
469 const routingHolds = [];
470 for ( const { entry , parsed } of heldBasePaths) {
471 const unknownType = ! ob.targets. length || ob.targets. some (( target ) => ! target.entity.sourceEntityType);
472 if ( ! unknownType && ! ob.targets. some (( target ) => target.entity.sourceEntityType === entry.sourceEntityType)) continue ;
473 if ( ! ob.conflictPath) continue ;
474 if ( ! parsed.ok || parsed.external) { addReason (ob, 'base_path_unparseable' ); continue ; }
475 if ( ! isPathPrefix (parsed.conflictPath, ob.conflictPath)) continue ;
476 const reasons = Array. isArray (entry.reasonCodes) && entry.reasonCodes. every (( code ) => typeof code === 'string' && code. trim ()) ? entry.reasonCodes : [];
477 const hold = { kind: 'base_path_hold' , sourceBasePath: entry.sourceBasePath,
478 sourceEntityType: entry.sourceEntityType, reasonCodes: reasons, resolution: 'unresolved' };
479 ob.provenance. push (hold);
480 if (entry.public === false ) { addReason (ob, 'base_path_not_public' ); continue ; }
481 if (unknownType) { addReason (ob, 'base_path_entity_type_unknown' ); continue ; }
482 if ( ! reasons. length ) { addReason (ob, 'base_path_hold_reason_missing' ); continue ; }
483 if ( ! reasons. every (( reason ) => BASE_PATH_ROUTING_REASONS . has (reason))) {
484 addReason (ob, 'base_path_manual_review' ); continue ;
485 }
486 // Resolve only this occurrence, and only after the normal exact-route/ownership checks.
487 routingHolds. push (hold);
488 }
489 if ( ! origins. length ) addReason (ob, 'origins_not_declared' );
490 if (ob.path === '/' || ob.conflictPath === '/' ) addReason (ob, 'source_root' );
491 if (ob.reasonCodes. length ) {
492 const unresolvedOnly = ob.reasonCodes. every (( code ) => code === 'destination_not_found' || code === 'origins_not_declared' );
493 ob.disposition = unresolvedOnly ? 'unresolved' : 'manual_review' ;
494 continue ;
495 }
496 if ( ! finalRoutes) { ob.disposition = 'pending_website_build' ; addReason (ob, 'final_routes_missing' ); continue ; }
497 const entityKeys = [ ...new Set (ob.targets. map (( t ) => t.entity.sourceStableKey))];
498 if (entityKeys. length !== 1 ) { ob.disposition = 'manual_review' ; addReason (ob, 'conflicting_intended_entities' ); continue ; }
499 const candidates = distinctRoutes (routes.bySourceKey. get (entityKeys[ 0 ]) || []);
500 if (candidates. length === 0 ) { ob.disposition = 'unresolved' ; addReason (ob, 'final_route_missing' ); continue ; }
501 if (candidates. length > 1 ) { ob.disposition = 'manual_review' ; addReason (ob, 'ambiguous_final_route' ); continue ; }
502 const route = candidates[ 0 ];
503 // The old address's query is part of what it means. A final route that carries its own,
504 // different query leaves the expected result undefined, so a person decides.
505 if (ob.query && route.query && ob.query !== route.query) { ob.disposition = 'manual_review' ; addReason (ob, 'query_semantics_conflict' ); continue ; }
506 ob.expected = {
507 sourceKey: route.sourceKey,
508 targetEntityType: route.targetEntityType,
509 targetEntityId: route.targetEntityId,
510 relativeUrl: route.relativeUrl,
511 conflictPath: route.conflictPath,
512 query: route.query || ob.query || '' ,
513 pageMarker: route.pageMarker,
514 };
515 const owners = distinctRoutes (routes.byConflictPath. get (ob.conflictPath) || []);
516 if (owners. some (( owner ) => owner.targetEntityId !== route.targetEntityId || owner.targetEntityType !== route.targetEntityType)) {
517 ob.disposition = 'manual_review' ;
518 addReason (ob, 'ownership_conflict' );
519 continue ;
520 }
521 for ( const hold of routingHolds) {
522 hold.resolution = 'resolved_by_exact_final_route' ;
523 hold.evidence = { sourceStableKey: route.sourceKey, targetEntityType: route.targetEntityType,
524 targetEntityId: route.targetEntityId, relativeUrl: route.relativeUrl, deploymentId };
525 }
526 if (owners. length || route.conflictPath === ob.conflictPath) {
527 ob.disposition = 'direct' ;
528 continue ;
529 }
530 ob.disposition = 'redirect' ;
531 }
532
533 // Fold per-origin intent into remote rules: one site-level rule per path and API scope.
534 const remote = new Map ();
535 for ( const ob of obligations. values ()) {
536 if ( ! ob.conflictPath || ob.conflictPath === '/' || ! origins. length ) continue ;
537 if ( ! [ 'redirect' , 'direct' ]. includes (ob.disposition)) continue ;
538 const key = remoteRuleKey (siteId, ob.conflictPath);
539 if ( ! remote. has (key)) remote. set (key, { ruleKey: key, fromConflict: ob.conflictPath, from: ob.path, intents: [], obligationKeys: [] });
540 const entry = remote. get (key);
541 entry.obligationKeys. push (ob.obligationKey);
542 for ( const originKeyValue of originKeys) {
543 const serves = ob.targets. some (( t ) => t.origins. includes (originKeyValue));
544 entry.intents. push ({
545 origin: originKeyValue,
546 obligationKey: ob.obligationKey,
547 need: ! serves ? 'none' : ob.disposition === 'direct' ? 'no_redirect' : ob.expected.relativeUrl,
548 });
549 }
550 }
551
552 const remoteRules = [];
553 for ( const entry of remote. values ()) {
554 const perOrigin = new Map ();
555 for ( const intent of entry.intents) {
556 if ( ! perOrigin. has (intent.origin)) perOrigin. set (intent.origin, new Set ());
557 perOrigin. get (intent.origin). add (intent.need);
558 }
559 // An origin with no obligation at this path has no requirement; an origin whose only
560 // requirement is "none" while another needs a redirect blocks the whole key.
561 const needs = new Set ();
562 const conflicts = [];
563 for ( const [ origin , set ] of perOrigin) {
564 const concrete = [ ... set]. filter (( need ) => need !== 'none' );
565 // A 'none' beside a concrete need means a second obligation at this path serves the
566 // origin; only an origin with no concrete need contributes 'none'.
567 if (concrete. length === 0 ) needs. add ( 'none' );
568 else if (concrete. length > 1 ) conflicts. push ({ origin, reason: 'conflicting_targets' });
569 else needs. add (concrete[ 0 ]);
570 }
571 const rule = {
572 ruleKey: entry.ruleKey,
573 from: entry.from,
574 fromConflict: entry.fromConflict,
575 to: null ,
576 origins: [ ... perOrigin. keys ()]. sort (),
577 obligationKeys: [ ...new Set (entry.obligationKeys)]. sort (),
578 disposition: null ,
579 reasonCodes: [],
580 perOriginConflicts: conflicts,
581 };
582 if (conflicts. length ) {
583 rule.disposition = 'manual_review' ;
584 rule.reasonCodes. push ( 'origin_intent_conflict' );
585 } else if (needs.size > 1 ) {
586 rule.disposition = 'manual_review' ;
587 rule.reasonCodes. push ( 'origin_intent_conflict' );
588 for ( const [ origin , set ] of perOrigin) rule.perOriginConflicts. push ({ origin, reason: `needs:${ [ ... set ]. join ( ',' ) }` });
589 } else {
590 const only = [ ... needs][ 0 ];
591 if (only === 'no_redirect' || only === 'none' ) {
592 rule.disposition = 'no_write' ;
593 rule.reasonCodes. push ( 'route_serves_original_address' );
594 } else {
595 rule.to = only;
596 rule.disposition = 'write_candidate' ;
597 }
598 }
599 remoteRules. push (rule);
600 }
601
602 // Whole-candidate-set checks: a destination that is itself a planned source would be a chain.
603 const candidateSources = new Set (remoteRules. filter (( r ) => r.disposition === 'write_candidate' ). map (( r ) => r.fromConflict));
604 for ( const rule of remoteRules) {
605 if (rule.disposition !== 'write_candidate' ) continue ;
606 const to = parseAddress (rule.to);
607 rule.toConflict = to.ok ? to.conflictPath : null ;
608 if ( ! to.ok || to.external) { rule.disposition = 'manual_review' ; rule.reasonCodes. push ( 'invalid_destination' ); continue ; }
609 if (to.conflictPath === rule.fromConflict) { rule.disposition = 'manual_review' ; rule.reasonCodes. push ( 'self_loop' ); continue ; }
610 if (candidateSources. has (to.conflictPath)) { rule.disposition = 'manual_review' ; rule.reasonCodes. push ( 'candidate_chain' ); }
611 }
612
613 const preflight = targetInventory ? preflightAgainstInventory (remoteRules, targetInventory, siteId) : null ;
614 const effectiveRules = preflight ? preflight.rules : remoteRules;
615
616 // Blocked remote rules block every obligation they serve.
617 const byRemote = new Map (effectiveRules. map (( rule ) => [rule.ruleKey, rule]));
618 for ( const ob of obligations. values ()) {
619 if ( ! ob.conflictPath) continue ;
620 const key = remoteRuleKey (siteId, ob.conflictPath);
621 const rule = byRemote. get (key);
622 if ( ! rule) continue ;
623 ob.remoteRuleKey = key;
624 if (rule.disposition === 'manual_review' && [ 'redirect' , 'direct' ]. includes (ob.disposition)) {
625 if (ob.disposition === 'redirect' || rule.reasonCodes. includes ( 'origin_intent_conflict' )) {
626 ob.disposition = 'manual_review' ;
627 for ( const code of rule.reasonCodes) addReason (ob, code);
628 }
629 }
630 }
631
632 const obligationList = [ ... obligations. values ()]. sort (( a , b ) => a.obligationKey. localeCompare (b.obligationKey));
633 // The plan identity covers its inputs, not the live inventory: inventory drift is caught by
634 // the apply-time preflight, while a changed ledger, rule set, route manifest, deployment or
635 // origin list is a different plan.
636 const planDigest = digest ({
637 siteId,
638 deploymentId,
639 origins: originKeys,
640 ledger: ledgerRows. map (( row ) => [row.sourceStableKey, row.sourceRelativeUrl, row.sourceEntityType || null , row.urlStatus]),
641 sourceRules: sourceRules. map (( rule ) => [rule.ruleKey, rule.enabled, rule.matchType, rule.source, rule.destination, rule.statusCode, rule.conditions || null , rule.language || null ]),
642 importedRedirectOutcomes: importedRedirectOutcomes. map (( row ) => [row.sourceStableKey, row.originalTarget, row.disposition, row.evidenceDigest]),
643 routes: ((finalRoutes && finalRoutes.routes) || []). map (( r ) => [r.sourceKey, r.targetEntityType, r.targetEntityId, r.relativeUrl, r.pageMarker]),
644 importRedirects: importRedirects. map (( row ) => [row.sourceStableKey, row.sourceRelativeUrl, row.sourceEntityType || null , row.targetRelativeUrl, row.status]),
645 invalidImportRedirects: invalidImportRedirects. map (( row ) => [row.line, row.sourceRelativeUrl, row.reason, row.recordedDestination || null , row.recordedStatus || null ]),
646 importUnresolved: importUnresolved. map (( row ) => [row.sourceStableKey, row.sourceRelativeUrl, row.sourceEntityType || null , row.urlStatus, row.status]),
647 basePaths: basePaths. map (( entry ) => [entry.sourceEntityType, entry.sourceBasePath, entry.sourceUrlPattern, entry.public, entry.status, entry.reasonCodes || null ]),
648 });
649 const coverageRows = (coverage && coverage.providers) || [];
650 return {
651 schemaVersion: PLAN_SCHEMA_VERSION ,
652 planDigest,
653 siteId,
654 deploymentId,
655 productionHostname: sourceDomains ? sourceDomains.productionHostname : null ,
656 origins: originKeys,
657 websiteBuilt: Boolean (finalRoutes),
658 coverage: coverageRows,
659 coverageComplete: coverageRows. length > 0 && coverageRows. every (( row ) => row.status === 'extracted' || row.status === 'confirmed_empty' ),
660 obligations: obligationList. map (( ob ) => ({
661 obligationKey: ob.obligationKey,
662 originalUrl: ob.originalUrl,
663 path: ob.path,
664 query: ob.query,
665 fragment: ob.fragment,
666 origins: ob.origins. length ? ob.origins : originKeys,
667 disposition: ob.disposition,
668 reasonCodes: ob.reasonCodes,
669 expected: ob.expected,
670 externalTarget: ob.externalTarget || null ,
671 externalOriginalTarget: ob.externalOriginalTarget || null ,
672 externalDisposition: ob.externalDisposition || null ,
673 externalEvidenceDigest: ob.externalEvidenceDigest || null ,
674 externalSetup: ob.externalSetup || null ,
675 remoteRuleKey: ob.remoteRuleKey,
676 provenance: ob.provenance,
677 })),
678 // Pre-inventory candidates: apply always re-runs the preflight against a fresh inventory.
679 remoteRules,
680 preflight,
681 sourceRuleFindings: ruleFindings,
682 };
683 }
684
685 // Compare candidate rules against the site's existing redirects. Exact semantic match is
686 // `already_present`; anything that could replace, delete or overlap an existing rule is
687 // `manual_review` with zero writes.
688 function preflightAgainstInventory ( remoteRules , inventory , siteId ) {
689 const existing = (inventory || []). map (normalizeInventoryRule);
690 const rules = remoteRules. map (( rule ) => ({ ... rule, reasonCodes: [ ... rule.reasonCodes] }));
691 for ( const rule of rules) {
692 if (rule.disposition !== 'write_candidate' ) continue ;
693 const toConflict = rule.toConflict || parseAddress (rule.to).conflictPath;
694 const block = ( code ) => { rule.disposition = 'manual_review' ; if ( ! rule.reasonCodes. includes (code)) rule.reasonCodes. push (code); };
695 for ( const ex of existing) {
696 if ( ! ex.fromConflict) continue ;
697 const exKey = remoteRuleKey (siteId, ex.fromConflict, ex.scope);
698 if (exKey === rule.ruleKey) {
699 if ( ! ex.toExternal && ex.toConflict === toConflict && ! ex.toQuery) {
700 rule.disposition = 'already_present' ;
701 rule.remoteId = ex.id;
702 } else block ( 'existing_rule_conflict' );
703 continue ;
704 }
705 if (ex.fromConflict === rule.fromConflict && ex.scope.language !== '*' ) block ( 'language_overlap' );
706 if (ex.fromConflict === rule.fromConflict && ex.scope.groupRedirect) block ( 'group_overlap' );
707 if (ex.scope.groupRedirect && ex.fromConflict !== rule.fromConflict && ( isPathPrefix (ex.fromConflict, rule.fromConflict) || isPathPrefix (rule.fromConflict, ex.fromConflict))) block ( 'group_overlap' );
708 if (ex.fromConflict === toConflict) block ( 'destination_is_existing_source' );
709 if (ex.scope.groupRedirect && isPathPrefix (ex.fromConflict, toConflict)) block ( 'destination_under_group_rule' );
710 if (ex.toConflict && ex.toConflict === rule.fromConflict) block ( 'creates_chain_with_existing_rule' );
711 }
712 }
713 return { rules, inventoryDigest: inventoryDigest (inventory) };
714 }
715
716 module . exports = {
717 PLAN_SCHEMA_VERSION,
718 MAX_SOURCE_CHAIN_HOPS,
719 parseAddress,
720 conflictPath,
721 remoteRuleKey,
722 apiScopeOf,
723 inventoryDigest,
724 normalizeInventoryRule,
725 planRedirects,
726 preflightAgainstInventory,
727 digest,
728 };