/** * uat/cli/lib/auth-client.ts — Thin client over the SmartStack auth/admin API. * * Encodes the VERIFIED backend contracts (SmartStack.Api source, re-checked 2026-08-30): * POST /api/auth/login {email,password,deviceType?} → 200 {token,refreshToken,user{roles,permissions,tenants},mustChangePassword} | 401 * POST /api/auth/change-password {currentPassword,newPassword} → 200 {message,requireRelogin} (Bearer) * POST /api/administration/users {email,password,firstName,lastName,roleIds[]} → 201 | 409 conflict (perm administration.users.create) * GET /api/administration/permissions/roles → RoleListDto[] (perm administration.roles.read; name/shortName LOCALIZED per Accept-Language, fallback fr; code NULL on most platform roles) * GET /api/administration/applications → application list (perm administration.applications.read) * GET /api/administration/tenants → tenant list (perm administration.tenants.read) * POST /api/administration/tenants/b2c {name,slug,description?} → 201 TenantDto | conflict (perm administration.tenants.create) * POST /api/administration/tenants/{id}/applications/bulk {applicationIds[]} → 2xx (perm administration.tenants.applications.assign) * GET /api/config/features → 200 (AllowAnonymous, HARD route — the identity probe) * * NOTE — /api/administration/roles NEVER existed in the socle (the admin routes are * resolved from the Navigation table at startup; roles live under the * `administration.permissions` nav node). /api/health does not exist either. * * Tenant context rides the `X-Tenant-Slug` header (the backend resolves it per * request; the JWT carries no tenant claim). Every call returns a plain result — * never throws — and takes an injectable fetch via ApiConfig for tests. */ import { timedFetch, timedJsonPost, type FetchLike, type TimedResponse } from './http.js'; export interface ApiConfig { /** API base URL, no trailing slash (e.g. http://localhost:5142). */ apiUrl: string; /** Tenant context header value for admin calls (omit → backend default tenant). */ tenantSlug?: string; timeoutMs?: number; fetchImpl?: FetchLike; } const opts = (cfg: ApiConfig): { timeoutMs?: number; fetchImpl?: FetchLike } => ({ timeoutMs: cfg.timeoutMs, fetchImpl: cfg.fetchImpl, }); /** Authorization (+ tenant context) headers for an authenticated call. */ export function authHeaders(token: string | null, tenantSlug?: string): Record { const h: Record = {}; if (token) h.Authorization = `Bearer ${token}`; if (tenantSlug) h['X-Tenant-Slug'] = tenantSlug; return h; } export interface LoggedInUser { id: string; email: string; roles: string[]; permissions: string[]; /** Tenant slugs the user belongs to. */ tenantSlugs: string[]; } export interface LoginResult { ok: boolean; status: number; token: string | null; user: LoggedInUser | null; mustChangePassword: boolean; error?: string; } const asStringArray = (v: unknown): string[] => Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : []; /** POST /api/auth/login. ok ⇔ 200 with a token. */ export async function login(cfg: ApiConfig, email: string, password: string): Promise { const res = await timedJsonPost( `${cfg.apiUrl}/api/auth/login`, { email, password, deviceType: 'UAT' }, {}, opts(cfg), ); if (!res.ok) return { ok: false, status: 0, token: null, user: null, mustChangePassword: false, error: res.error }; const body = (res.json ?? {}) as Record; const token = typeof body.token === 'string' ? body.token : null; if (res.status !== 200 || !token) { const msg = typeof body.message === 'string' ? body.message : `login failed with status ${res.status}`; return { ok: false, status: res.status, token: null, user: null, mustChangePassword: false, error: msg }; } const rawUser = (body.user ?? {}) as Record; const tenants = Array.isArray(rawUser.tenants) ? (rawUser.tenants as Record[]) : []; const user: LoggedInUser = { id: typeof rawUser.id === 'string' ? rawUser.id : '', email: typeof rawUser.email === 'string' ? rawUser.email : email, roles: asStringArray(rawUser.roles), permissions: asStringArray(rawUser.permissions), tenantSlugs: tenants.map((t) => (typeof t.slug === 'string' ? t.slug : '')).filter(Boolean), }; const mustChangePassword = body.mustChangePassword === true || (rawUser as { mustChangePassword?: unknown }).mustChangePassword === true; return { ok: true, status: res.status, token, user, mustChangePassword }; } export interface SimpleResult { ok: boolean; status: number; error?: string; } /** POST /api/auth/change-password (Bearer). ok ⇔ 200. */ export async function changePassword( cfg: ApiConfig, token: string, currentPassword: string, newPassword: string, ): Promise { const res = await timedJsonPost( `${cfg.apiUrl}/api/auth/change-password`, { currentPassword, newPassword }, authHeaders(token, cfg.tenantSlug), opts(cfg), ); if (!res.ok) return { ok: false, status: 0, error: res.error }; if (res.status !== 200) { const body = (res.json ?? {}) as Record; const msg = typeof body.message === 'string' ? body.message : `change-password failed with status ${res.status}`; return { ok: false, status: res.status, error: msg }; } return { ok: true, status: 200 }; } export type EnsureOutcome = 'created' | 'exists' | 'failed'; export interface CreateUserResult { outcome: EnsureOutcome; status: number; userId?: string; error?: string; } /** POST /api/administration/users. 201 → created, 409 → exists. */ export async function createUser( cfg: ApiConfig, token: string, user: { email: string; password: string; firstName: string; lastName: string; roleIds: string[] }, ): Promise { const res = await timedJsonPost( `${cfg.apiUrl}/api/administration/users`, user, authHeaders(token, cfg.tenantSlug), opts(cfg), ); if (!res.ok) return { outcome: 'failed', status: 0, error: res.error }; if (res.status === 201 || res.status === 200) { const body = (res.json ?? {}) as Record; return { outcome: 'created', status: res.status, userId: typeof body.id === 'string' ? body.id : undefined }; } if (res.status === 409) return { outcome: 'exists', status: 409 }; const body = (res.json ?? {}) as Record; const msg = typeof body.message === 'string' ? body.message : res.bodyText.slice(0, 300); return { outcome: 'failed', status: res.status, error: msg || `create user failed with status ${res.status}` }; } export interface DiscoveredRole { id: string; /** Display name — LOCALIZED by the backend (Accept-Language, fallback fr). Never a join key. */ name: string; /** Technical key when the role carries one (extension roles do; most platform roles have code NULL). */ code?: string; } export interface ListRolesResult { ok: boolean; status: number; roles: DiscoveredRole[]; error?: string; } /** Extract the list from a tolerated envelope: bare array, or `{items}` / `{data}` / `{}`. */ function unwrapList(body: unknown, extraKey: string): unknown[] | undefined { if (Array.isArray(body)) return body; if (body && typeof body === 'object') { const rec = body as Record; const list = rec.items ?? rec.data ?? rec[extraKey]; if (Array.isArray(list)) return list; } return undefined; } /** * GET /api/administration/permissions/roles — tolerant to envelope shape: a bare * array, or an object carrying the list under `items` / `data` / `roles`. * * Called WITHOUT `X-Tenant-Slug` (cfg.tenantSlug unset) this returns the GLOBAL * catalogue; with a tenant slug the backend filters to the tenant's active * applications (a fresh tenant has none → empty list). `name` is a display field * (localized); the stable join key is `id`. */ export async function listRoles(cfg: ApiConfig, token: string): Promise { const res = await timedFetch( `${cfg.apiUrl}/api/administration/permissions/roles`, { headers: authHeaders(token, cfg.tenantSlug) }, opts(cfg), ); if (!res.ok) return { ok: false, status: 0, roles: [], error: res.error }; if (res.status !== 200) return { ok: false, status: res.status, roles: [], error: `roles list returned ${res.status}` }; const list = unwrapList(res.json, 'roles'); if (!list) return { ok: false, status: 200, roles: [], error: 'unrecognized roles payload shape' }; const roles: DiscoveredRole[] = []; for (const item of list) { if (!item || typeof item !== 'object') continue; const r = item as Record; const id = typeof r.id === 'string' ? r.id : undefined; const name = typeof r.name === 'string' ? r.name : typeof r.label === 'string' ? r.label : undefined; const code = typeof r.code === 'string' && r.code.length > 0 ? r.code : undefined; if (id && name) roles.push({ id, name, ...(code ? { code } : {}) }); } return { ok: true, status: 200, roles }; } export interface CreateTenantResult { outcome: EnsureOutcome; status: number; tenantId?: string; error?: string; } /** POST /api/administration/tenants/b2c. 201 → created; 409/duplicate-slug 400 → exists. */ export async function createTenantB2C( cfg: ApiConfig, token: string, tenant: { name: string; slug: string; description?: string }, ): Promise { const res = await timedJsonPost( `${cfg.apiUrl}/api/administration/tenants/b2c`, tenant, authHeaders(token), opts(cfg), ); if (!res.ok) return { outcome: 'failed', status: 0, error: res.error }; if (res.status === 201 || res.status === 200) { const body = (res.json ?? {}) as Record; return { outcome: 'created', status: res.status, tenantId: typeof body.id === 'string' ? body.id : undefined }; } // A replayed slug surfaces as 409 (conflict) or a 400 mentioning the slug — both mean "already there". const text = res.bodyText.toLowerCase(); if (res.status === 409 || (res.status === 400 && (text.includes('slug') || text.includes('exist')))) { return { outcome: 'exists', status: res.status }; } const body = (res.json ?? {}) as Record; const msg = typeof body.message === 'string' ? body.message : res.bodyText.slice(0, 300); return { outcome: 'failed', status: res.status, error: msg || `create tenant failed with status ${res.status}` }; } /** * IDENTITY probe: up ⇔ GET /api/config/features answers 200. The endpoint is * [AllowAnonymous] with a HARD route (not Navigation-resolved) and tenant-exempt, * so 200 proves the process on the port IS a SmartStack app — any other status * (404 from a port squatter, 5xx) reports down. /api/health does not exist in * the socle; certifying "API up" on the faith of a 404 was the §52 false positive. */ export async function probeApi(cfg: ApiConfig): Promise<{ up: boolean; status: number; error?: string }> { const res: TimedResponse = await timedFetch( `${cfg.apiUrl}/api/config/features`, {}, { ...opts(cfg), timeoutMs: cfg.timeoutMs ?? 5000 }, ); if (!res.ok) return { up: false, status: 0, error: res.error }; if (res.status !== 200) { return { up: false, status: res.status, error: `GET /api/config/features returned ${res.status} — not a reachable SmartStack app`, }; } return { up: true, status: 200 }; } export interface DiscoveredApplication { id: string; code?: string; name?: string; } export interface ListApplicationsResult { ok: boolean; status: number; applications: DiscoveredApplication[]; error?: string; } /** GET /api/administration/applications — the app ids a fresh UAT tenant must be granted. */ export async function listApplications(cfg: ApiConfig, token: string): Promise { const res = await timedFetch( `${cfg.apiUrl}/api/administration/applications`, { headers: authHeaders(token, cfg.tenantSlug) }, opts(cfg), ); if (!res.ok) return { ok: false, status: 0, applications: [], error: res.error }; if (res.status !== 200) { return { ok: false, status: res.status, applications: [], error: `applications list returned ${res.status}` }; } const list = unwrapList(res.json, 'applications'); if (!list) return { ok: false, status: 200, applications: [], error: 'unrecognized applications payload shape' }; const applications: DiscoveredApplication[] = []; for (const item of list) { if (!item || typeof item !== 'object') continue; const a = item as Record; const id = typeof a.id === 'string' ? a.id : undefined; if (!id) continue; applications.push({ id, ...(typeof a.code === 'string' && a.code ? { code: a.code } : {}), ...(typeof a.name === 'string' && a.name ? { name: a.name } : {}), }); } return { ok: true, status: 200, applications }; } export interface BulkAssignAppsResult { ok: boolean; status: number; /** 409 — the assignment already exists; treated as success for idempotent re-runs. */ alreadyAssigned?: boolean; error?: string; } /** * POST /api/administration/tenants/{tenantId}/applications/bulk — activate * applications on a tenant. THE platform bootstrap lever: roles are a GLOBAL * catalogue filtered per tenant by its active applications, so a fresh B2C * tenant shows 0 roles (and grants 0 access) until this is called. */ export async function bulkAssignApps( cfg: ApiConfig, token: string, tenantId: string, body: { applicationIds: string[]; accessLevel?: string; accessMode?: string }, ): Promise { const payload: Record = { applicationIds: body.applicationIds }; if (body.accessLevel !== undefined) payload.accessLevel = body.accessLevel; if (body.accessMode !== undefined) payload.accessMode = body.accessMode; const res = await timedJsonPost( `${cfg.apiUrl}/api/administration/tenants/${tenantId}/applications/bulk`, payload, authHeaders(token), opts(cfg), ); if (!res.ok) return { ok: false, status: 0, error: res.error }; if (res.status >= 200 && res.status < 300) return { ok: true, status: res.status }; if (res.status === 409) return { ok: true, status: 409, alreadyAssigned: true }; const bodyJson = (res.json ?? {}) as Record; const msg = typeof bodyJson.message === 'string' ? bodyJson.message : res.bodyText.slice(0, 300); return { ok: false, status: res.status, error: msg || `bulk app assignment failed with status ${res.status}` }; } export interface FindTenantResult { ok: boolean; status: number; /** Present when a tenant with the given slug exists. */ tenantId?: string; error?: string; } /** * GET /api/administration/tenants?search={slug} — resolve a tenant id by slug * (case-insensitive). Used when createTenantB2C reports 'exists' (the conflict * response carries no id). The endpoint is PAGINATED (PaginatedResult, default * pageSize 20), so the slug is pushed as the server-side `search` filter * (matches name/slug/description) and the exact slug match happens client-side — * an unfiltered first page would miss the tenant on a >20-tenant platform. */ export async function findTenantBySlug(cfg: ApiConfig, token: string, slug: string): Promise { const res = await timedFetch( `${cfg.apiUrl}/api/administration/tenants?search=${encodeURIComponent(slug)}`, { headers: authHeaders(token) }, opts(cfg), ); if (!res.ok) return { ok: false, status: 0, error: res.error }; if (res.status !== 200) return { ok: false, status: res.status, error: `tenants list returned ${res.status}` }; const list = unwrapList(res.json, 'tenants'); if (!list) return { ok: false, status: 200, error: 'unrecognized tenants payload shape' }; const wanted = slug.toLowerCase(); for (const item of list) { if (!item || typeof item !== 'object') continue; const t = item as Record; if (typeof t.slug === 'string' && t.slug.toLowerCase() === wanted && typeof t.id === 'string') { return { ok: true, status: 200, tenantId: t.id }; } } return { ok: true, status: 200 }; }