/** * uat-provision/plan-actions.ts — PURE provisioning planner. * * Decides, from the target roles and the existing `uat-users.json`, what to do per * role BEFORE any I/O: reuse a stored credential (verify it still logs in) or * create a fresh user. Pure + deterministic so the decision matrix is unit-tested * away from the network; execute.ts replays the decisions against the live API. */ import type { UatUser, UatUsersFile } from '../lib/users-file.js'; export interface RoleAction { role: string; email: string; /** verify = entry exists, prove it logs in; create = no entry, create the user. */ action: 'verify' | 'create'; /** Password to use: the stored one (verify) or the new one (create). */ password: string; } /** `uat.{role-slug}@{domain}` — role slugged to a safe local-part. PURE. */ export function emailForRole(role: string, domain: string): string { const slug = role .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, ''); return `uat.${slug || 'role'}@${domain}`; } export interface PlanRoleActionsInput { /** Target roles, `anonymous` already excluded. */ roles: readonly string[]; existing: UatUsersFile | null; emailDomain: string; /** Password for roles that need a create — keyed by role (generated by the caller). */ newPasswords: Readonly>; } /** One action per role, in the given role order. PURE. */ export function planRoleActions(input: PlanRoleActionsInput): RoleAction[] { const byRole = new Map((input.existing?.users ?? []).map((u) => [u.role, u])); const byEmail = new Map((input.existing?.users ?? []).map((u) => [u.email, u])); return input.roles.map((role) => { // Primary match by stored role name; secondary by DERIVED email — an entry // stored under a corrupted/renamed spelling of the same role (the pre-§48 // mojibake) slugs to the same address, so its password is reused instead of // colliding with a create → 409 → dead end. const existing = byRole.get(role) ?? byEmail.get(emailForRole(role, input.emailDomain)); if (existing) { return { role, email: existing.email, action: 'verify', password: existing.password }; } const password = input.newPasswords[role]; if (!password) throw new Error(`planRoleActions: missing generated password for role "${role}"`); return { role, email: emailForRole(role, input.emailDomain), action: 'create', password }; }); } /** * Join plan roles onto the app's live roles through the plan's `role_catalog` — * by GUID, the only key stable across the SQL vocabulary (auth_Roles.Name) and * the LOCALIZED API display names (GET /api/administration/permissions/roles). * * `missingFromCatalog` = plan roles without a catalog entry (stale/hand-edited * plan). `driftedIds` = catalog ids the live app no longer knows (plan predates a * role change) — both mean "regenerate the plan", never a name-matching fallback: * matching display names is exactly the §49 defect this join replaces. PURE. */ export function resolveRoleIds( roles: readonly string[], catalog: Readonly>, liveRoles: readonly { id: string }[], ): { resolved: Record; missingFromCatalog: string[]; driftedIds: string[] } { const liveIds = new Set(liveRoles.map((r) => r.id)); const resolved: Record = {}; const missingFromCatalog: string[] = []; const driftedIds: string[] = []; for (const role of roles) { const entry = catalog[role]; if (!entry) { missingFromCatalog.push(role); } else if (!liveIds.has(entry.id)) { driftedIds.push(role); } else { resolved[role] = entry.id; } } return { resolved, missingFromCatalog, driftedIds }; }