import type { ModuleManifest, VariableDeclare } from '../manifest/schema'; import type { ResolutionContext } from './types'; /** * Get nested property from object using dot notation * * Policy function - pure navigation * * @param obj - Object to navigate * @param path - Dot-separated path (e.g., "server.ip.primary") * @returns Value at path or undefined if not found */ function getNestedProperty(obj: Record, path: string): unknown { const parts = path.split('.'); let current: unknown = obj; for (const part of parts) { if (current && typeof current === 'object' && part in (current as Record)) { current = (current as Record)[part]; } else { return undefined; } } return current; } /** * Resolve declarative variable derivation from manifest * * Policy function - pure template substitution * * Supports the following template patterns: * - $system:key - System config value (e.g., $system:primary_domain) * - {variable} - Module's own variable (e.g., {hostname}) * - $capability:name.path - Capability data (e.g., $capability:dns_registrar.primary_domain) * * @param variable - Variable declaration with derive_from field * @param context - Resolution context (selfConfig, systemConfig, etc.) * @returns Derived value or undefined if dependencies missing * @throws Error if required dependencies are missing */ /** * Pattern that matches any unresolved variable reference. * Used to detect whether another resolution pass is needed. */ const UNRESOLVED_PATTERN = /\$\{?(?:system|self|capability|secret|system_secret):/; /** * Run one pass of variable substitution on a string. */ function substituteVariables( input: string, variableName: string, context: ResolutionContext, ): string { let result = input; // Replace $system:key patterns (both $system:key and ${system:key} forms). // // The key may contain hyphens. celilo's own zone names are kebab-case, so // `$system:network.control-plane-vpn.subnet` and // `$system:network.secure-mgmt.subnet` are both real keys in shipped // manifests — and neither could ever resolve while this class excluded `-`: // the match stopped at the first hyphen, looked up `network.control`, and // threw. For an optional variable that throw is swallowed, so the derive // simply produced nothing, forever, in silence. That is half of the // 2026-08-14 DNS outage (`technitium.vpn_subnet`); the other half is that // nothing re-derived the value at read time — see // `hooks/load-hook-config.ts`. result = result.replace(/\$\{?system:([a-zA-Z0-9_.-]+)\}?/g, (_match, key) => { const value = context.systemConfig[key]; if (value === undefined) { throw new Error(`Missing system config: ${key} (required by variable '${variableName}')`); } return value; }); // Replace $self:key patterns (both $self:key and ${self:key} forms). // Same lookup target as the {var} syntax below — both read selfConfig — // but $self: is the form that appears in user-authored manifests and // capability data blocks, so it must resolve here too. Without this, // a `derive_from: "https://auth.$self:domain"` stays literally // unresolved and the defensive guard in applyDeclarativeDerivations // refuses to store it, breaking downstream capability consumers. result = result.replace(/\$\{?self:([a-zA-Z0-9_]+)\}?/g, (_match, key) => { const value = context.selfConfig[key]; if (value === undefined) { throw new Error(`Missing self variable: ${key} (required by variable '${variableName}')`); } return value; }); // Replace {variable_name} patterns result = result.replace(/\{([a-zA-Z0-9_]+)\}/g, (_match, varName) => { const value = context.selfConfig[varName]; if (value === undefined) { throw new Error(`Missing variable: ${varName} (required by variable '${variableName}')`); } return value; }); // Replace $capability:name.path patterns result = result.replace( /\$capability:([a-zA-Z0-9_]+)\.([a-zA-Z0-9_.]+)/g, (_match, capName, path) => { const capData = context.capabilities[capName]; if (!capData) { throw new Error(`Missing capability: ${capName} (required by variable '${variableName}')`); } const value = getNestedProperty(capData, path); if (value === undefined) { throw new Error( `Missing capability field: ${capName}.${path} (required by variable '${variableName}')`, ); } return String(value); }, ); return result; } export function resolveDeclarativeDerivation( variable: VariableDeclare, context: ResolutionContext, ): string | undefined { if (!variable.derive_from) { return undefined; } const template = variable.derive_from; // $machine: derivations are handled by the config interview, not template resolution if (template.startsWith('$machine:')) { return undefined; } // Resolve variables iteratively — capability values may contain $system: or // other references that need a second pass to fully resolve. let result = template; const maxPasses = 5; for (let i = 0; i < maxPasses; i++) { const resolved = substituteVariables(result, variable.name, context); if (resolved === result) break; // Stable — no more substitutions possible result = resolved; if (!UNRESOLVED_PATTERN.test(result)) break; // Fully resolved } return result; } /** * Apply all declarative derivations from manifest * * Planning function - processes variable declarations in order * * Rules: * 1. User-provided values always take precedence (not overwritten) * 2. Variables are resolved in declaration order * 3. Only works for type: string variables * 4. If derivation fails for optional variable, silently skip * 5. If derivation fails for required variable, throw error * * @param manifest - Module manifest with variable declarations * @param context - Resolution context (will be mutated with derived values) */ export function applyDeclarativeDerivations( manifest: ModuleManifest, context: ResolutionContext, ): void { const variables = manifest.variables?.owns ?? []; for (const variable of variables) { // Re-derive capability-sourced and infrastructure-sourced variables every time, // since the upstream data may have changed. User-provided values take precedence // only for user-sourced variables. const shouldRederive = variable.source === 'capability' || variable.source === 'infrastructure'; if (!shouldRederive && context.selfConfig[variable.name] !== undefined) { continue; } // Skip if no derivation defined if (!variable.derive_from) { continue; } // Declarative derivation resolves scalar template expressions. Numeric and // boolean values flow through this resolution context as their string form // and recover their declared type at the module-config / inventory boundary. // Structured arrays and objects still belong to the typed config path: a // template substitution would collapse them to a lossy string. if (variable.type === 'array' || variable.type === 'object') { continue; } try { const derived = resolveDeclarativeDerivation(variable, context); if (derived !== undefined) { // Don't overwrite with a value that still contains unresolved // template references ($self:, $system:, $capability:). This // happens when capability data contains template strings that // can't be fully resolved in the consuming module's context — // e.g. $capability:dns_registrar.primary_domain resolves to // namecheap's "$self:primary_domain", but $self: in that // context is namecheap, not the consumer. const hasUnresolved = derived.includes('$self:') || derived.includes('$system:') || derived.includes('$capability:') || derived.includes('$secret:'); if (hasUnresolved) { // Don't store a value that still contains unresolved template refs. // This happens when a capability's data contains $self: refs that // resolve in the provider's context, not the consumer's. continue; } context.selfConfig[variable.name] = derived; } } catch (error) { // If derivation fails due to missing dependencies, handle based on required flag if (variable.required) { // Required variable - propagate error throw error; } // Optional variable - silently skip (will use default or remain unset) } } }