import { KNOWN_CAPABILITY_NAMES, isProviderView } from '@celilo/capabilities'; import { parse as parseYaml } from 'yaml'; import type { ZodError } from 'zod'; import { SUBMODULES_DIR } from '../module/packaging/package-rules'; import { validateModuleZoneRequirements } from '../services/zone-policy'; import { resolveContract, supportedContractVersions } from './contracts'; import { ModuleManifestSchema, getSingularSystemSpec } from './schema'; import type { ModuleManifest } from './schema'; /** * Validation result types */ export interface ValidationSuccess { success: true; data: ModuleManifest; } export interface ValidationError { success: false; errors: Array<{ path: string; message: string; }>; } export type ValidationResult = ValidationSuccess | ValidationError; /** * Parse YAML string into unknown object * This is the first stage - just parse the YAML */ function parseManifestYaml(yamlContent: string): unknown { try { return parseYaml(yamlContent); } catch (error) { throw new Error( `Failed to parse YAML: ${error instanceof Error ? error.message : 'Unknown error'}`, ); } } /** * Format Zod errors into readable validation errors */ function formatZodErrors(error: ZodError): ValidationError { return { success: false, errors: error.errors.map((err) => ({ path: err.path.join('.'), message: err.message, })), }; } /** * Validate module manifest * * Policy function (Rule 10.1) - validates input only * Does NOT perform any side effects or business logic * * @param yamlContent - Raw YAML string from manifest.yml * @returns Validation result with parsed manifest or errors */ export function validateManifest(yamlContent: string): ValidationResult { if (!yamlContent || yamlContent.trim().length === 0) { return { success: false, errors: [{ path: '', message: 'Manifest content cannot be empty' }], }; } let parsed: unknown; try { parsed = parseManifestYaml(yamlContent); } catch (error) { return { success: false, errors: [ { path: '', message: error instanceof Error ? error.message : 'Failed to parse YAML', }, ], }; } const result = ModuleManifestSchema.safeParse(parsed); if (!result.success) { return formatZodErrors(result.error); } return { success: true, data: result.data, }; } /** * Validate that required capabilities exist * * Policy function - checks capability requirements against available capabilities * Does NOT perform database queries - caller provides capability list * * @param manifest - Validated manifest * @param availableCapabilities - List of capability names available in the system * @returns Validation errors if any required capabilities are missing */ export function validateCapabilityRequirements( manifest: ModuleManifest, availableCapabilities: string[], ): ValidationError | null { const errors: Array<{ path: string; message: string }> = []; for (const required of manifest.requires.capabilities) { if (!availableCapabilities.includes(required.name)) { errors.push({ path: `requires.capabilities.${required.name}`, message: `Required capability '${required.name}' is not provided by any installed module`, }); } } if (errors.length > 0) { return { success: false, errors }; } return null; } /** * Validate that every capability name listed in `requires.capabilities` and * `optional.capabilities` is a known capability in the framework registry. * * Policy function (Rule 10.1) — pure validation, no side effects. * * Why (HOOK_API_V2 Phase 3 / D3): catches typos and stale references in the * manifest before they become silent runtime no-ops. Without this check, a * misspelled capability name (`requires: [{ name: dns_register }]`) would * pass schema validation, fail to load any provider, and give the user a * confusing "capability not provided" error far downstream. The known * registry lives in `@celilo/capabilities` so the TS interface and the * runtime list stay in sync. * * @param manifest - Validated manifest * @returns Validation error if any name is unknown, null otherwise */ export function validateCapabilityNames(manifest: ModuleManifest): ValidationError | null { const errors: Array<{ path: string; message: string }> = []; // Provider views are registry entries, so a bare membership test would accept // them. They are framework-injected into the PROVIDER's own hooks and no // module can ask for one, so requiring one is always a mistake, and a silent // one: the manifest would validate and the capability would simply never // arrive. Excluded from the suggestion list too, for the same reason. const requirableNames: readonly string[] = KNOWN_CAPABILITY_NAMES.filter( (name) => !isProviderView(name), ); const checkName = (name: string, path: string): void => { if (requirableNames.includes(name)) return; errors.push({ path, message: isProviderView(name) ? `'${name}' is a provider view, not a capability a module can declare. celilo injects it into the hooks of the module that PROVIDES the paired capability. Remove this declaration.` : `Unknown capability '${name}'. Known capabilities: ${requirableNames.join(', ')}.`, }); }; for (const required of manifest.requires.capabilities) { checkName(required.name, `requires.capabilities.${required.name}`); } for (const opt of manifest.optional?.capabilities ?? []) { checkName(opt.name, `optional.capabilities.${opt.name}`); } if (errors.length > 0) { return { success: false, errors }; } return null; } /** * Privileged capabilities — framework-granted privileges that only specific * modules may declare in `requires.capabilities`. Allow-list is intentionally * tiny: adding a module here is a deliberate trust decision, not an * automatic side effect of how capabilities work elsewhere. * * Why this gate lives in `validate.ts` (not in the capability resolver): * the privilege is gated at MANIFEST IMPORT time so the operator sees the * rejection immediately when they try to `celilo module import` a module * that's claiming a privilege it shouldn't have. Catching it later (at * hook-invocation time) would let a bad module sit in IMPORTED state. * * Keys are capability names; values are arrays of module IDs allowed to * require that capability. Empty value = no module may require it (acts as * an internal-only privilege flag — currently unused, but the shape leaves * room for that pattern). */ const PRIVILEGED_CAPABILITY_ALLOW_LIST: Record = { cross_module_read: ['celilo-mgmt'], }; /** * Framework-granted capabilities that any module may declare. * * Same satisfaction path as the allow-list above — celilo supplies them, so no * module provides them and the resolver must not go looking for one — but a * different authorization story, and the two were welded together while * `cross_module_read` was the only entry. * * `cross_module_read` hands a module every OTHER module's terraform state, so * who may hold it is a per-module trust decision and the list is the gate. * `control_plane_api` mints a principal whose grants are derived from * `readOnlyGrants(COMMANDS)` and cannot be widened by the caller, so the worst * a wrongly-declared consumer obtains is celilo's own read verbs. The gate is * the `requires` line, which a reviewer reads before the module is ever * imported (web-ui-console D7b). * * An allow-list here was considered and rejected: it would put consumer module * ids in core, so every new console-shaped consumer would need a core change * and a `.deb` release before it could be imported at all. */ const FRAMEWORK_GRANTED_CAPABILITIES: ReadonlySet = new Set(['control_plane_api']); /** * Whether `name` is a framework-granted privilege rather than a normal * provider-backed capability. Privileges are satisfied by the framework, so * the capability-provider resolver must NOT expect a module to "provide" them. */ export function isPrivilegedCapability(name: string): boolean { return name in PRIVILEGED_CAPABILITY_ALLOW_LIST || FRAMEWORK_GRANTED_CAPABILITIES.has(name); } /** * Reject `requires.capabilities` declarations for privileged capabilities * by modules not in the allow-list. Surfaces a clear actionable error * identifying the privilege and the allowed modules. * * `optional.capabilities` is checked too — a module shouldn't be able to * "soft-require" a privilege either (otherwise the privilege flag could be * smuggled in via the optional path and granted at hook time). */ export function validatePrivilegedCapabilities(manifest: ModuleManifest): ValidationError | null { const errors: Array<{ path: string; message: string }> = []; for (const required of manifest.requires.capabilities) { const allowed = PRIVILEGED_CAPABILITY_ALLOW_LIST[required.name]; if (allowed && !allowed.includes(manifest.id)) { errors.push({ path: `requires.capabilities.${required.name}`, message: `Capability '${required.name}' is a framework-granted privilege; only these modules may require it: ${allowed.join(', ')}. Module '${manifest.id}' is not on the allow-list.`, }); } } for (const opt of manifest.optional?.capabilities ?? []) { const allowed = PRIVILEGED_CAPABILITY_ALLOW_LIST[opt.name]; if (allowed && !allowed.includes(manifest.id)) { errors.push({ path: `optional.capabilities.${opt.name}`, message: `Capability '${opt.name}' is a framework-granted privilege; only these modules may declare it (including under optional): ${allowed.join(', ')}. Module '${manifest.id}' is not on the allow-list.`, }); } } if (errors.length > 0) { return { success: false, errors }; } return null; } /** * Validate that variable sources are valid * * Policy function - checks that capability references in variables exist * * @param manifest - Validated manifest * @returns Validation errors if any variable sources are invalid */ export function validateVariableSources(manifest: ModuleManifest): ValidationError | null { const errors: Array<{ path: string; message: string }> = []; // Variables can import from capabilities listed in requires OR optional — // both are declared dependencies of this module. const declaredCapabilityNames = new Set([ ...manifest.requires.capabilities.map((c) => c.name), ...(manifest.optional?.capabilities ?? []).map((c) => c.name), ]); for (const varImport of manifest.variables.imports) { if (varImport.source === 'capability') { const capabilityName = varImport.from.split('.')[0]; if (!capabilityName) { errors.push({ path: `variables.imports.${varImport.name}`, message: `Variable '${varImport.name}' has invalid capability reference: '${varImport.from}'`, }); continue; } if (!declaredCapabilityNames.has(capabilityName)) { errors.push({ path: `variables.imports.${varImport.name}`, message: `Variable '${varImport.name}' imports capability '${capabilityName}' but module does not declare it in requires or optional`, }); } } } if (errors.length > 0) { return { success: false, errors }; } return null; } /** * Validate zone requirements for module capabilities * * Policy function - checks that module provides capabilities in correct zones * * Zone-based policy enforcement * - Modules providing well-known capabilities must be deployed to specific zones * - Example: public_web must be in 'dmz' zone (defense perimeter, internet-facing) * - Example: public_web must be in 'dmz' zone (defense perimeter) * * @param manifest - Validated manifest * @returns Validation errors if zone requirements are violated */ export function validateZoneRequirements(manifest: ModuleManifest): ValidationError | null { const errors: Array<{ path: string; message: string }> = []; // Check if module has infrastructure spec (requires.system) const hasInfrastructureSpec = getSingularSystemSpec(manifest); // If module requires infrastructure, zone field is mandatory if (hasInfrastructureSpec) { const zone = hasInfrastructureSpec.zone; if (!zone) { errors.push({ path: 'requires.system.zone', message: 'Zone field is required for modules with infrastructure requirements', }); // Can't validate zone requirements without a zone return { success: false, errors }; } // Validate zone requirements for provided capabilities if (manifest.provides.capabilities.length > 0) { const capabilityNames = manifest.provides.capabilities.map((cap) => cap.name); const zoneValidation = validateModuleZoneRequirements(capabilityNames, zone); if (!zoneValidation.valid && zoneValidation.error) { errors.push({ path: 'requires.system.zone', message: zoneValidation.error, }); } } } if (errors.length > 0) { return { success: false, errors }; } return null; } /** * Validate that the prefix of every variable's `derive_from` template * matches its declared `source`. * * Why: a manifest like * `source: capability, derive_from: "$system:primary_domain"` * is incoherent — the source says the value comes from a capability but the * derivation reads from system config. Zod can't catch this because both * fields are individually valid; this validator enforces the coherence rule * at runtime so the bug fails import instead of confusing a future reader. * * Rules: * - `source: capability` → `derive_from` must only reference `$capability:` * tokens (and may also include `{var}` and `$self:` placeholders). * - `source: system` → `derive_from` must only reference `$system:` tokens * (and `{var}` and `$self:` placeholders). * - `$self:` references are allowed under *any* source. They point at * another variable owned by the same manifest, so they don't introduce * a cross-context mismatch — that's the bug class this validator was * built to catch. * - Other sources (`user`, `infrastructure`, `terraform`) — `derive_from` is * optional and unconstrained; we don't check the prefix. */ export function validateDeriveFromSources(manifest: ModuleManifest): ValidationError | null { const errors: Array<{ path: string; message: string }> = []; // Match $foo:bar tokens (foo is the prefix, bar is the path) const tokenPattern = /\$([a-z_]+):/g; for (const variable of manifest.variables.owns) { if (!variable.derive_from) continue; let expected: string | null = null; if (variable.source === 'capability') expected = 'capability'; else if (variable.source === 'system') expected = 'system'; if (!expected) continue; const tokens = [...variable.derive_from.matchAll(tokenPattern)].map((m) => m[1]); // `self` is always allowed: it references another variable in this // same manifest, which is the same context as the declared source. const offending = tokens.filter((t) => t !== expected && t !== 'self'); if (offending.length > 0) { const unique = Array.from(new Set(offending)).join(', '); errors.push({ path: `variables.owns.${variable.name}.derive_from`, message: `Variable '${variable.name}' has source: ${variable.source} but derive_from references $${unique}: tokens. Either change source to match or rewrite the derivation.`, }); } } if (errors.length > 0) { return { success: false, errors }; } return null; } /** * Validate that every declared hook is part of the contract version the * manifest targets. * * Why: the Zod schema's `.strict()` on the hooks block already prevents * unknown hook names structurally, but the contract registry is the * authoritative list of which hooks Celilo will actually invoke. This * validator surfaces a clear error if someone declares a hook that the * declared contract version doesn't promise to call. */ export function validateHookContract(manifest: ModuleManifest): ValidationError | null { const errors: Array<{ path: string; message: string }> = []; const contract = resolveContract(manifest.celilo_contract); if (!contract) { errors.push({ path: 'celilo_contract', message: `Unsupported celilo_contract version '${manifest.celilo_contract}'. Supported: ${supportedContractVersions().join(', ')}`, }); return { success: false, errors }; } if (!manifest.hooks) { return null; } for (const hookName of Object.keys(manifest.hooks)) { if (!(hookName in contract.hooks)) { errors.push({ path: `hooks.${hookName}`, message: `Hook '${hookName}' is not part of celilo_contract ${manifest.celilo_contract}. Known hooks: ${Object.keys(contract.hooks).join(', ')}`, }); } } if (errors.length > 0) { return { success: false, errors }; } return null; } /** * Validate that capability data templates in `provides.capabilities[].data` * do not contain cross-capability references. * * Why (D9 firm rule): well-known and module-provided capability data * templates must not embed `$capability:other_capability.x` references. If a * capability value depends on another capability, the providing module * derives it in its own variables and exposes the resolved value through * `provides.capabilities[].data`. Cross-capability references in data * templates create implicit ordering dependencies between capability * providers, which the variable resolver isn't designed to handle and which * make the dependency graph hard to reason about. */ export function validateProvidesNoCrossCapabilityRefs( manifest: ModuleManifest, ): ValidationError | null { const errors: Array<{ path: string; message: string }> = []; function walk(node: unknown, path: string): void { if (typeof node === 'string') { if (node.includes('$capability:')) { errors.push({ path, message: `Capability data template at ${path} contains a $capability: reference. Per D9, capability data must not cross-reference other capabilities — derive the value in this module's variables instead and expose the resolved value here.`, }); } return; } if (node === null || typeof node !== 'object') return; if (Array.isArray(node)) { node.forEach((item, idx) => walk(item, `${path}[${idx}]`)); return; } for (const [key, value] of Object.entries(node as Record)) { walk(value, `${path}.${key}`); } } for (const cap of manifest.provides.capabilities) { walk(cap.data, `provides.capabilities.${cap.name}.data`); } if (errors.length > 0) { return { success: false, errors }; } return null; } /** * The directory a parent's submodules live in, relative to the parent's own * source root. Re-exported rather than redefined: the packaging rules need the * same name and `package-rules.ts` is deliberately dependency-free (the * registry server imports it), so it owns the definition and everything * manifest-facing reads it from here. */ export { SUBMODULES_DIR }; /** * Validate a PARENT's `submodules` declaration. * * Policy function - checks the declaration itself, not the submodules it names. * Reading those is I/O and belongs to the caller. * * @param manifest - Validated manifest of the parent * @returns Validation errors if any, null if clean */ export function validateSubmoduleDeclaration(manifest: ModuleManifest): ValidationError | null { const declared = manifest.submodules ?? []; if (declared.length === 0) return null; const errors: Array<{ path: string; message: string }> = []; const seen = new Set(); for (const [index, name] of declared.entries()) { if (seen.has(name)) { errors.push({ path: `submodules.${index}`, message: `Submodule '${name}' is declared more than once`, }); } seen.add(name); // The derived id of an instance is built from the parent and the submodule // name, so a submodule sharing its parent's id yields an instance id that // reads as the parent's own. Cheap to refuse, confusing to debug. if (name === manifest.id) { errors.push({ path: `submodules.${index}`, message: `Submodule '${name}' cannot share its parent module's id`, }); } } return errors.length > 0 ? { success: false, errors } : null; } /** * Validate that a SUBMODULE's own manifest is legal as a submodule. * * Policy function - the caller has already read and schema-validated the * submodule's manifest; this decides whether it may be one. * * @param parent - The declaring parent's manifest * @param submoduleName - The name the parent declared, i.e. its directory * @param submodule - The submodule's own validated manifest * @returns Validation errors if any, null if clean */ export function validateSubmoduleManifest( parent: ModuleManifest, submoduleName: string, submodule: ModuleManifest, ): ValidationError | null { const errors: Array<{ path: string; message: string }> = []; const where = `${SUBMODULES_DIR}/${submoduleName}/manifest.yml`; if (submodule.id !== submoduleName) { errors.push({ path: `${where}#id`, message: `Submodule id '${submodule.id}' does not match its directory '${submoduleName}'. A submodule is addressed by the name its parent declares, so the two must agree.`, }); } // D9: celilo has no general rule for resolving a capability name to one of // several providers. The two bespoke rules that exist RESOLVE rather than // fail — `firewall` builds a chain, `dns_registrar` takes the first row — so // N instances registering one capability would silently bind a consumer to // whichever was created first. Refused here rather than discovered there. if ((submodule.provides?.capabilities ?? []).length > 0) { const names = submodule.provides.capabilities.map((c) => c.name).join(', '); errors.push({ path: `${where}#provides.capabilities`, message: `A submodule may not provide a capability (declares: ${names}). A capability resolves to ONE provider, and a submodule exists as many instances, so a consumer would bind to whichever instance happened to be created first. Move the capability to '${parent.id}'.`, }); } // One level only. D4's instance layout puts every instance flat under // `modules/`, and nesting would make a derived id ambiguous about which // ancestor owns it. if ((submodule.submodules ?? []).length > 0) { errors.push({ path: `${where}#submodules`, message: `A submodule may not declare submodules of its own. Ownership is one level deep: '${parent.id}' owns '${submoduleName}', and nothing owns anything below that.`, }); } return errors.length > 0 ? { success: false, errors } : null; }