From d8fb7e1d66adec7ab52915a6c0f2835cacc6134c Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Thu, 8 Oct 2026 16:19:49 -0400 Subject: [PATCH] Show provisioning requesters and reserve Maxis for system actions --- src/lib/auth.ts | 3 +++ src/lib/provisioning-list.ts | 2 +- src/lib/server/auth/index.ts | 6 +++-- src/lib/server/provisioning/history.ts | 2 +- src/lib/ui/provisioning-presentation.ts | 7 +++++- src/routes/provisioning/+page.svelte | 10 ++++---- src/routes/provisioning/[id]/+page.svelte | 4 ++-- tests/unit/auth-users.test.ts | 24 ++++++++++++++++++++ tests/unit/provisioning-presentation.test.ts | 15 +++++++++++- tests/unit/provisioning-requester.test.ts | 19 +++++++++++++--- 10 files changed, 77 insertions(+), 15 deletions(-) diff --git a/src/lib/auth.ts b/src/lib/auth.ts index ffca13d..dd297f2 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,5 +1,8 @@ export type UserRole = 'admin' | 'operator' | 'viewer'; +/** Reserved identity for work requested by the controller. */ +export const systemUsername = 'Maxis'; + export interface AuthUser { id: string; username: string; diff --git a/src/lib/provisioning-list.ts b/src/lib/provisioning-list.ts index 0f45333..6fa14e1 100644 --- a/src/lib/provisioning-list.ts +++ b/src/lib/provisioning-list.ts @@ -29,7 +29,7 @@ export function provisioningMatchesSource(row: Pick & { + 'finished_at' | 'triggered_by' | 'requested_by' | 'error' | 'warnings' | 'server_exists'> & { canCancelRemoval: number; canCancel?: number; sources?: ProvisioningSource[]; phase?: string | null; phaseStartedAt?: string | null; waitCode?: string | null; }; diff --git a/src/lib/server/auth/index.ts b/src/lib/server/auth/index.ts index 3a06e28..aef358d 100644 --- a/src/lib/server/auth/index.ts +++ b/src/lib/server/auth/index.ts @@ -2,7 +2,7 @@ import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypt import { db } from '../db/index.js'; import { ensureAuthSchema } from './schema.js'; import { hashPassword, verifyPassword } from './passwords.js'; -import type { AuthUser, UserRole } from '../../auth.js'; +import { systemUsername, type AuthUser, type UserRole } from '../../auth.js'; export const SESSION_COOKIE = '__Host-ec_session'; export const SESSION_LIFETIME_MS = 12 * 60 * 60_000; @@ -25,7 +25,9 @@ export function listUsers(): AuthUser[] { return (database().prepare('SELECT * F export function setupRequired(): boolean { return !(database().prepare('SELECT 1 FROM auth_users LIMIT 1').get()); } function username(value: unknown): string { if (typeof value !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{2,63}$/.test(value)) throw new AuthError('Usernames need 3–64 letters, numbers, dots, underscores or hyphens.'); - return value.toLowerCase(); + const normalized = value.toLowerCase(); + if (normalized === systemUsername.toLowerCase()) throw new AuthError(`${systemUsername} is reserved for system actions. Choose another username.`); + return normalized; } function displayName(value: unknown, fallback: string): string { const name = value === undefined ? fallback : value; diff --git a/src/lib/server/provisioning/history.ts b/src/lib/server/provisioning/history.ts index 9a39a08..461987f 100644 --- a/src/lib/server/provisioning/history.ts +++ b/src/lib/server/provisioning/history.ts @@ -303,7 +303,7 @@ const reviewDeployment = `d.state IN ('failed', 'interrupted', 'warning') AND NO SELECT 1 FROM retention_controls c WHERE c.category='provisioning' AND c.record_id=d.id AND c.resolved_at >= COALESCE(d.finished_at,d.started_at))`; const deploymentListColumns = `d.id, d.server, d.server_deleted_at, d.action, d.state, d.stage, - d.started_at, d.finished_at, d.triggered_by, d.error, d.warnings, + d.started_at, d.finished_at, d.triggered_by, d.requested_by, d.error, d.warnings, d.phase_key AS phase, d.phase_started_at AS phaseStartedAt, CASE WHEN json_valid(d.observation_json) THEN json_extract(d.observation_json,'$.blocker.code') END AS waitCode, (d.server_deleted_at IS NULL AND EXISTS(SELECT 1 FROM servers s WHERE s.name = d.server AND s.destroyed_at IS NULL)) AS server_exists, diff --git a/src/lib/ui/provisioning-presentation.ts b/src/lib/ui/provisioning-presentation.ts index a3ef7fb..5e77fcd 100644 --- a/src/lib/ui/provisioning-presentation.ts +++ b/src/lib/ui/provisioning-presentation.ts @@ -1,5 +1,10 @@ -import { provisioningPhases, type ProvisioningDetail, type ProvisioningEvent, type ProvisioningEventPage, type ProvisioningPhase } from '$lib/provisioning-detail.js'; +import { provisioningPhases, type ProvisioningDeployment, type ProvisioningDetail, type ProvisioningEvent, type ProvisioningEventPage, type ProvisioningPhase } from '$lib/provisioning-detail.js'; import { actionCompleted, actionLabels, provisioningActions, type ProvisioningAction } from '$lib/provisioning.js'; +import { systemUsername } from '$lib/auth.js'; + +export function provisioningRequester(operation: Pick): string { + return operation.requested_by ?? (operation.triggered_by === 'ui' ? 'Not recorded' : systemUsername); +} export const activeProvisioningStates = ['queued', 'waiting', 'running', 'awaiting-health']; export type ProgressState = 'done' | 'current' | 'waiting' | 'failed' | 'pending' | 'cancelled' | 'unknown'; diff --git a/src/routes/provisioning/+page.svelte b/src/routes/provisioning/+page.svelte index 52961c8..9eb4e7a 100644 --- a/src/routes/provisioning/+page.svelte +++ b/src/routes/provisioning/+page.svelte @@ -22,7 +22,7 @@ import { contextHref } from '$lib/navigation-context.js'; import { confirmAction } from '$ui/confirm.js'; import { serverTitle } from '$ui/server-labels.js'; - import { elapsedTime } from '$ui/provisioning-presentation.js'; + import { elapsedTime, provisioningRequester } from '$ui/provisioning-presentation.js'; import { provisioningQueueStatus, provisioningWarnings } from '$ui/provisioning-queue.js'; let lifecycleOpen = $state(false); @@ -210,7 +210,7 @@ {#snippet operationTable(rows: Deployment[])}
- + {#each rows as row (row.id)} {@const status = provisioningQueueStatus(row)} {@const primarySource = row.sources?.[0]} @@ -221,14 +221,15 @@ + -
ServerOperationStatusSourceStartedDetails
ServerOperationStatusSourceRequesterStartedDetails
{actionLabels[row.action]} {status.label}{#if ['queued','waiting'].includes(status.group) && row.phaseStartedAt && now - Date.parse(row.phaseStartedAt) >= 600000}In this stage {elapsedTime(row.phaseStartedAt, now)}{/if} {#if primarySource?.href}{provisioningSourceLabel(row)}{:else}{provisioningSourceLabel(row)}{/if}{#if (row.sources?.length ?? 0) > 1}+{row.sources!.length - 1} more{/if}{provisioningRequester(row)}