1import { scryptSync } from 'node:crypto' 3import * as _ from 'lodash-es' 4import { fileURLToPath } from 'url' 5import { styleText } from 'node:util' 18const codeExtRe = extRe(ptFileExtA) 20const derivedHostnameSeedName = 'derived-hostname-seed' 22// Core derivation function - derives hostname from name + wcDomain using master secret 23export const getDerivedHostname = async ({name, wcDomain}: {name: string, wcDomain: string}) => { 26 const hash = scryptSync(name, seed, 16).toString('hex') 27 return `${name}-${hash}.${wcDomain}` 30// Sync variant for scaffold codegen — unhashed fallback when no seed exists (e.g. OSS clone) 31export const derivedHostnameSync = ({name, wcDomain}: {name: string, wcDomain: string}): string => { 33 if (!seed) return `${name}.${wcDomain}` 34 return `${name}-${scryptSync(name, seed, 16).toString('hex')}.${wcDomain}` 37// Parse hostname to extract derivation params 38// Format: <name>-<hash>.<wcDomain> (hashed) or <name>.<wcDomain> (unhashed) 39const parseHostname = (hostname: string) => { 40 const dotIdx = hostname.indexOf('.') 41 if (dotIdx === -1) return null 42 const firstPart = hostname.slice(0, dotIdx) 43 const wcDomain = hostname.slice(dotIdx + 1) 44 // Check if already hashed: name-<32-char-hex> 45 const hashMatch = firstPart.match(/^(.+)-([a-f0-9]{32})$/) 46 if (hashMatch) return {name: hashMatch[1], wcDomain} 47 // Unhashed: just <name> 48 return {name: firstPart, wcDomain} 51// Scan file for // derivedhn lines, re-derive, update if needed 52export const doUpdateDerivedHostnames = async ({filePath}: {filePath: string}) => { 53 const content = fs.readFileSync(filePath, 'utf8') 54 const lines = content.split('\n') 55 const updates: {from: string, to: string}[] = [] 57 for (let i = 0; i < lines.length; i++) { 59 if (!line.endsWith('// derivedhn')) continue 61 const quoteMatch = line.match(/'([^']*)'/) 62 if (!quoteMatch) continue 64 const currentHostname = quoteMatch[1] 65 const parsed = parseHostname(currentHostname) 67 console.log(`WARN: cannot parse hostname: ${currentHostname}`) 71 const derivedHostname = await getDerivedHostname(parsed) 72 if (currentHostname === derivedHostname) continue 74 lines[i] = line.replace(`'${currentHostname}'`, `'${derivedHostname}'`) 75 updates.push({from: currentHostname, to: derivedHostname}) 78 if (updates.length === 0) return {filePath, updated: false} 80 fs.writeFileSync(filePath, lines.join('\n')) 81 return {filePath, updated: true, updates} 84// Get all files in ep's import tree (ES imports + Next.js pages/app + their transitive imports) 85// Uses filtered paths (excludes devpconlyF) since this is for deploy validation 86const getEpImportTreePaths = async (regEpPath: string) => { 89 if (!getVirtualPathsFnc) return esImportPaths 90 const virtualImportPaths = await getVirtualPathsFnc() 91 // Also trace transitive imports of virtual imports (pages/app files) 92 const jsVirtualPaths = virtualImportPaths.filter(p => codeExtRe.test(p)) 93 const {pathsA: virtualTransitivePaths} = jsVirtualPaths.length > 0 96 return _.uniq([...esImportPaths, ...virtualImportPaths, ...virtualTransitivePaths]) 99// Check for stale derived hostnames without updating - returns array of stale entries 100export const checkDerivedHostnames = async ({regEpPath}: {regEpPath: string}) => { 101 const allPaths = await getEpImportTreePaths(regEpPath) 102 const staleEntries: {filePath: string, current: string, expected: string}[] = [] 104 for (const relPath of allPaths) { 105 const absPath = `${ptDir}/${relPath}` 106 if (!fs.existsSync(absPath)) continue 107 const content = fs.readFileSync(absPath, 'utf8') 108 if (!content.includes('// derivedhn')) continue 110 const lines = content.split('\n') 111 for (const line of lines) { 112 if (!line.endsWith('// derivedhn')) continue 114 const quoteMatch = line.match(/'([^']*)'/) 115 if (!quoteMatch) continue 117 const currentHostname = quoteMatch[1] 118 const parsed = parseHostname(currentHostname) 119 if (!parsed) continue 121 const derivedHostname = await getDerivedHostname(parsed) 122 if (currentHostname !== derivedHostname) { 123 staleEntries.push({filePath: relPath, current: currentHostname, expected: derivedHostname}) 131// Warn or throw if derived hostnames are stale during apply 132export const warnOrThrowStaleHostnames = async ({regEpPath, action}: {regEpPath: string, action: string}) => { 133 const staleEntries = await checkDerivedHostnames({regEpPath}) 134 if (staleEntries.length === 0) return 136 const msg = `Stale derived hostnames:\n${staleEntries.map(e => ` ${e.filePath}: ${e.current} -> ${e.expected}`).join('\n')}\nRun 'rotatewchn' action to fix.` 138 if (action === 'apply') { 141 console.log(styleText('yellow', `WARN: ${msg}`)) 145// CLI action - scans ep import tree for // derivedhn and updates 146export const rotatewchn = async () => { 151 const allPaths = await getEpImportTreePaths(regEpPath) 153 let totalUpdates = 0, filesWithMarkers = 0 154 for (const relPath of allPaths) { 155 const absPath = `${ptDir}/${relPath}` 156 if (!fs.existsSync(absPath)) continue 157 const content = fs.readFileSync(absPath, 'utf8') 158 if (!content.split('\n').some(line => line.endsWith('// derivedhn'))) continue 161 const result = await doUpdateDerivedHostnames({filePath: absPath}) 162 if (result.updated) { 163 betLog({updated: relPath, changes: result.updates}) 164 totalUpdates += result.updates!.length 168 if (filesWithMarkers === 0) console.log('no derived hostnames in this ep') 169 else console.log(totalUpdates === 0 ? 'all derived hostnames up to date' : `updated ${totalUpdates} hostname(s)`) 171rotatewchn.cliDescript = 'validate/update derived hostnames in import tree'