/** * uat-provision/validate.ts — Spec parse + resolution of everything provisioning * needs: the plan (for roles), the API URL, and the initial-admin credentials. * A missing admin password is fatal with a pointed hint (it lives in the * gitignored appsettings.Local.json that `ss dev` writes). */ import path from 'node:path'; import { existsSync, statSync } from 'node:fs'; import { findSmartStackStructure } from '../../../lib/detector.js'; import { resolveApiUrl, resolveInitialAdmin } from '../lib/app-config.js'; import { locatePlan, loadPlan } from '../lib/plan-io.js'; import type { RoleCatalogEntry } from '../lib/plantest-schema.js'; import { UatProvisionInputSchema, type UatProvisionInput } from './types.js'; export interface ProvisionContext { spec: UatProvisionInput; projectRoot: string; application: string; /** Roles to provision (anonymous excluded), in canonical plan order. */ roles: string[]; /** Role identities from the plan's role_catalog (keyed by role name) — the join keys. */ roleCatalog: Record; apiUrl: string; apiUrlSource: string; adminEmail: string; adminPassword: string; adminSource: string; planRelPath: string; } export interface ProvisionValidation { valid: boolean; context: ProvisionContext | null; errors: string[]; warnings: string[]; } const fail = (errors: string[], warnings: string[] = []): ProvisionValidation => ({ valid: false, context: null, errors, warnings, }); export async function validate(raw: unknown): Promise { const parsed = UatProvisionInputSchema.safeParse(raw); if (!parsed.success) { return fail(parsed.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`)); } const spec = parsed.data; const warnings: string[] = []; const projectRoot = path.resolve(spec.projectPath); if (!existsSync(projectRoot) || !statSync(projectRoot).isDirectory()) { return fail([`projectPath is not a directory: ${projectRoot}`]); } // Roles: explicit override, else read from the plan. Either way the plan's // role_catalog is REQUIRED — provisioning joins plan roles onto the live app by // catalog id (names are localized by the API and can never re-match the SQL // vocabulary — the §49 defect). A plan without the block predates /uat plan 1.1.0. const NO_CATALOG = 'The plan carries no role_catalog (generated by a pre-1.1.0 /uat plan). Provisioning now joins plan roles to the app by role id. Regenerate the plan (/uat plan, or /uat run with refreshPlan=auto), then re-provision.'; let roles: string[]; let application: string; let roleCatalog: Record; let planRelPath: string; const located = locatePlan({ projectRoot, planFile: spec.planFile, path: spec.path }); if (spec.roles && spec.roles.length > 0) { roles = spec.roles.filter((r) => r !== 'anonymous'); application = spec.path?.split('/')[0] ?? 'app'; // The override restricts WHICH plan roles run — the identities still come from // the plan. Without a locatable catalog-bearing plan the names are unjoinable. if (!located.ok) { return fail([`A roles override still needs the plan's role_catalog for the ids — ${located.error}`]); } const loaded = loadPlan(located.location.absPath); if (!loaded.ok) return fail(loaded.errors); if (!loaded.plan.role_catalog) return fail([NO_CATALOG]); warnings.push(...loaded.violations); roleCatalog = Object.fromEntries( Object.entries(loaded.plan.role_catalog).filter(([name]) => roles.includes(name)), ); planRelPath = `${located.location.relPath} (roles override)`; } else { if (!located.ok) return fail([located.error]); const loaded = loadPlan(located.location.absPath); if (!loaded.ok) return fail(loaded.errors); if (!loaded.plan.role_catalog) return fail([NO_CATALOG]); warnings.push(...loaded.violations); roles = loaded.plan.roles.filter((r) => r !== 'anonymous'); application = loaded.plan.meta.application; roleCatalog = loaded.plan.role_catalog; planRelPath = located.location.relPath; } if (roles.length === 0) return fail(['No roles to provision (plan/roles yielded an empty set).']); const structure = await findSmartStackStructure(projectRoot); const apiDir = structure.api ?? structure.apiExtensions ?? structure.apiCore ?? null; if (!apiDir) { return fail(['Could not locate the API project (no *.Api*.csproj) under projectPath.']); } const api = resolveApiUrl(apiDir, spec.apiUrl); const admin = resolveInitialAdmin(apiDir, { email: spec.adminEmail, password: spec.adminPassword }); if (!admin.value.password) { return fail([ 'Initial admin password not found (Security.InitialAdmin.Password). It lives in the gitignored appsettings.Local.json that `ss dev` writes — run `ss dev up` once, or pass "adminPassword" in the spec.', ]); } return { valid: true, context: { spec, projectRoot, application, roles, roleCatalog, apiUrl: api.value, apiUrlSource: api.source, adminEmail: admin.value.email, adminPassword: admin.value.password, adminSource: admin.source, planRelPath, }, errors: [], warnings, }; }