fix: harden terminal recovery and deployment
Some checks are pending
ci / verify (push) Waiting to run

This commit is contained in:
Ochenstarik 2026-09-18 11:10:28 +07:00
parent 7c8e7fcaf1
commit d45bee960a
20 changed files with 585 additions and 142 deletions

View file

@ -3,6 +3,7 @@ PORT=8080
LOG_LEVEL=info
STAFF_RUNTIME_MODE=standalone
POSTGRES_MIGRATION_PASSWORD=local-migration-only
POSTGRES_MIGRATION_USER=krona_migrator
POSTGRES_APP_PASSWORD=local-app-only
DATABASE_MIGRATION_URL=postgres://krona_migrator:local-migration-only@postgres:5432/krona_staff
DATABASE_URL=postgres://krona_staff_app:local-app-only@postgres:5432/krona_staff

View file

@ -18,5 +18,5 @@ RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist
USER app
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --retries=3 CMD wget -q -O - http://127.0.0.1:8080/live || exit 1
HEALTHCHECK --interval=30s --timeout=3s --retries=3 CMD wget -q -O - http://127.0.0.1:8080/api/v1/staff/live || exit 1
CMD ["node", "dist/server.js"]

View file

@ -16,13 +16,14 @@ namespace KronaStaffTerminal;
/// </summary>
public partial class App : Application
{
private const string InstanceMutexName = @"Local\KronaStaffTerminal.Singleton";
private const string ActivationEventName = @"Local\KronaStaffTerminal.Activate";
private const string InstanceMutexName = @"Global\KronaStaffTerminal.Singleton";
private const string ActivationEventName = @"Global\KronaStaffTerminal.Activate";
private TrayService? _trayService;
private Microsoft.UI.Dispatching.DispatcherQueueTimer? _autostartTimer;
private Mutex? _instanceMutex;
private EventWaitHandle? _activationEvent;
private CancellationTokenSource? _activationCancellation;
private Task? _activationTask;
/// <summary>
/// The main application window. Use <c>App.Window</c> from any class that needs
/// the window reference (for dialogs, pickers, interop, etc.).
@ -88,7 +89,7 @@ public partial class App : Application
_trayService = new TrayService(ShowWindow, RequestExit);
_activationEvent = new EventWaitHandle(false, EventResetMode.AutoReset, ActivationEventName);
_activationCancellation = new CancellationTokenSource();
_ = Task.Run(() => ListenForActivation(_activationCancellation.Token));
_activationTask = Task.Run(() => ListenForActivation(_activationCancellation.Token));
Window.Activate();
if (Environment.GetCommandLineArgs().Any(argument =>
argument.Equals("--autostart", StringComparison.OrdinalIgnoreCase)))
@ -114,6 +115,7 @@ public partial class App : Application
{
if (_activationEvent?.WaitOne(TimeSpan.FromSeconds(1)) == true)
{
if (cancellationToken.IsCancellationRequested) return;
DispatcherQueue.TryEnqueue(ShowWindow);
}
}
@ -141,10 +143,28 @@ public partial class App : Application
public void CompleteExit()
{
_activationCancellation?.Cancel();
_activationCancellation?.Dispose();
_activationCancellation = null;
_activationEvent?.Dispose();
_activationEvent = null;
_activationEvent?.Set();
var activationStopped = true;
try
{
activationStopped = _activationTask?.Wait(TimeSpan.FromSeconds(2)) != false;
}
catch (AggregateException exception)
{
CrashLogService.Write(exception.Flatten());
}
if (activationStopped)
{
_activationTask = null;
_activationCancellation?.Dispose();
_activationCancellation = null;
_activationEvent?.Dispose();
_activationEvent = null;
}
else
{
CrashLogService.Write(new TimeoutException("Слушатель активации не завершился; его ресурсы оставлены до выхода процесса."));
}
_instanceMutex?.ReleaseMutex();
_instanceMutex?.Dispose();
_instanceMutex = null;

View file

@ -23,9 +23,9 @@ public sealed partial class MainPage : Page
private bool _adminNavigationInProgress;
private bool _stateLoadFailed;
private bool _scannerReconnectPending;
private int _failedPinAttempts;
private DateTimeOffset _pinBlockedUntilUtc;
private string? _registrationEmployeeId;
private bool _refreshingLists;
private string _lastAdminActor = "unknown";
private DateTimeOffset _adminSessionExpiresAtUtc;
private static readonly TimeSpan AdminSessionDuration = TimeSpan.FromMinutes(5);
@ -73,6 +73,10 @@ public sealed partial class MainPage : Page
RefreshLists();
ConnectScanner();
MainTabs.SelectedItem = TerminalTab;
if (_store.RecoveryNotice is not null)
{
SetStatus(_store.RecoveryNotice, InfoBarSeverity.Warning);
}
}
private void Page_Unloaded(object sender, RoutedEventArgs e)
@ -209,7 +213,8 @@ public sealed partial class MainPage : Page
_state.Settings.AdminPinSalt = pin.Salt;
_state.Settings.AdminPinHash = pin.Hash;
_state.Settings.AdminPinIterations = pin.Iterations;
await _store.SaveAsync(_state);
await _store.SaveAsync(_state, createRecoveryPoint: true);
_lastAdminActor = "initial-pin";
return true;
}
@ -284,7 +289,7 @@ public sealed partial class MainPage : Page
IsAdministrator = AdministratorCheckBox.IsChecked == true
};
_state.Employees.Add(employee);
await _store.SaveAsync(_state);
await _store.SaveAsync(_state, createRecoveryPoint: true);
EmployeeNameBox.Text = "";
PersonnelNumberBox.Text = "";
PositionBox.Text = "";
@ -374,12 +379,7 @@ public sealed partial class MainPage : Page
2 => "Женский",
_ => ""
};
foreach (var attendance in _state.Attendance.Where(item => item.EmployeeId == employee.Id))
{
attendance.EmployeeName = name;
}
await _store.SaveAsync(_state);
await _store.SaveAsync(_state, createRecoveryPoint: true);
RefreshLists();
EmployeesList.SelectedItem = employee;
SetStatus($"Данные сотрудника «{name}» обновлены. Отпечатки и журнал сохранены.", InfoBarSeverity.Success);
@ -420,6 +420,7 @@ public sealed partial class MainPage : Page
private void EmployeesList_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (_refreshingLists) return;
if (_registrationEmployeeId is null) return;
if (EmployeesList.SelectedItem is EmployeeRecord employee && employee.Id == _registrationEmployeeId) return;
CancelRegistration();
@ -443,9 +444,9 @@ public sealed partial class MainPage : Page
Label = $"Отпечаток {employee.Fingerprints.Count + 1}"
});
_registrationEmployeeId = null;
await _store.SaveAsync(_state);
await _store.SaveAsync(_state, createRecoveryPoint: true);
RefreshLists();
TerminalMessage.Text = $"Новый отпечаток сотрудника «{employee.DisplayName}» зарегистрирован";
TerminalMessage.Text = $"Новый отпечаток сотрудника «{TerminalDisplayName(employee)}» зарегистрирован";
SetStatus($"Добавлен отпечаток №{employee.Fingerprints.Count} сотрудника «{employee.DisplayName}».", InfoBarSeverity.Success);
}
@ -482,7 +483,7 @@ public sealed partial class MainPage : Page
if (!await AuthorizeAdminAsync("удаление отпечатков сотрудника")) return;
employee.Fingerprints.Clear();
await _store.SaveAsync(_state);
await _store.SaveAsync(_state, createRecoveryPoint: true);
RefreshLists();
ConnectScanner();
SetStatus($"Все отпечатки сотрудника «{employee.DisplayName}» удалены.", InfoBarSeverity.Success);
@ -503,7 +504,7 @@ public sealed partial class MainPage : Page
return;
}
employee.IsAdministrator = !employee.IsAdministrator;
await _store.SaveAsync(_state);
await _store.SaveAsync(_state, createRecoveryPoint: true);
RefreshLists();
SetStatus(employee.IsAdministrator ? $"{employee.DisplayName} назначен администратором." : $"Права администратора у {employee.DisplayName} сняты.", InfoBarSeverity.Success);
}
@ -539,7 +540,7 @@ public sealed partial class MainPage : Page
}
_state.Employees.Remove(employee);
await _store.SaveAsync(_state);
await _store.SaveAsync(_state, createRecoveryPoint: true);
RefreshLists();
ConnectScanner();
SetStatus("Сотрудник и его отпечаток удалены.", InfoBarSeverity.Success);
@ -554,7 +555,8 @@ public sealed partial class MainPage : Page
{
if (employee.IsAdministrator)
{
TerminalMessage.Text = $"Администратор {employee.DisplayName} подтверждён";
_lastAdminActor = $"employee:{employee.Id}";
TerminalMessage.Text = $"Администратор {TerminalDisplayName(employee)} подтверждён";
_adminAuthCompletion.TrySetResult(true);
_adminAuthDialog?.Hide();
}
@ -578,7 +580,7 @@ public sealed partial class MainPage : Page
if (last is not null && now - last.OccurredAtUtc < guard)
{
var remaining = Math.Max(1, (int)Math.Ceiling((guard - (now - last.OccurredAtUtc)).TotalSeconds));
TerminalMessage.Text = $"{employee.DisplayName}\nПовторный скан отклонён\nПовторите через {remaining} сек.";
TerminalMessage.Text = $"{TerminalDisplayName(employee)}\nПовторный скан отклонён\nПовторите через {remaining} сек.";
SetStatus($"Повторный скан защищён: событие не создано. Последнее событие — «{last.EventType}».", InfoBarSeverity.Informational);
return;
}
@ -602,7 +604,7 @@ public sealed partial class MainPage : Page
}
await _store.SaveAsync(_state);
RefreshLists();
TerminalMessage.Text = $"{employee.DisplayName}\n{eventType} отмечен в {now.ToLocalTime():HH:mm}";
TerminalMessage.Text = $"{TerminalDisplayName(employee)}\n{eventType} отмечен в {now.ToLocalTime():HH:mm}";
SetStatus($"{eventType} отмечен. Точность распознавания: {score}.", InfoBarSeverity.Success);
var delivery = await _notifications.SendAttendanceAsync(_state.Settings, employee.DisplayName, eventType, now.ToLocalTime());
@ -632,7 +634,7 @@ public sealed partial class MainPage : Page
if (!await AuthorizeAdminAsync("очистка журнала прихода и ухода")) return;
_state.Attendance.Clear();
await _store.SaveAsync(_state);
await _store.SaveAsync(_state, createRecoveryPoint: true);
RefreshLists();
TerminalMessage.Text = "Журнал отметок очищен";
SetStatus("Журнал прихода и ухода очищен.", InfoBarSeverity.Success);
@ -641,6 +643,19 @@ public sealed partial class MainPage : Page
private async void ExportAttendance_Click(object sender, RoutedEventArgs e)
{
if (!await EnsureAdminSessionAsync("экспорт журнала")) return;
var warning = new ContentDialog
{
XamlRoot = XamlRoot,
Title = "Экспорт персональных данных",
Content = "CSV-файл не шифруется и содержит ФИО, табельные номера, должности и время отметок. Сохраняйте его только в защищённом месте.",
PrimaryButtonText = "Продолжить экспорт",
CloseButtonText = "Отмена",
DefaultButton = ContentDialogButton.Close
};
if (await warning.ShowAsync() != ContentDialogResult.Primary) return;
_lastAdminActor = "unknown";
if (!await AuthorizeAdminAsync("экспорт персональных данных", requireIdentifiedActor: true)) return;
var picker = new FileSavePicker
{
SuggestedFileName = $"krona-staff-attendance-{DateTime.Now:yyyy-MM-dd}"
@ -650,25 +665,44 @@ public sealed partial class MainPage : Page
var file = await picker.PickSaveFileAsync();
if (file is null) return;
var csv = new StringBuilder("Сотрудник;Табельный номер;Должность;Событие;Время;Доставка\r\n");
foreach (var item in _state.Attendance.OrderBy(record => record.OccurredAtUtc))
AppendSecurityAudit("attendance.export", "started", $"records={_state.Attendance.Count}; file={file.Name}");
await _store.SaveAsync(_state);
try
{
var employee = _state.Employees.FirstOrDefault(candidate => candidate.Id == item.EmployeeId);
csv.Append(Csv(item.EmployeeName)).Append(';')
.Append(Csv(employee?.PersonnelNumber ?? "")).Append(';')
.Append(Csv(employee?.Position ?? "")).Append(';')
.Append(Csv(item.EventType)).Append(';')
.Append(Csv(item.LocalTime)).Append(';')
.Append(Csv(item.DeliveryStatus)).Append("\r\n");
var csv = new StringBuilder("Сотрудник;Табельный номер;Должность;Событие;Время;Доставка\r\n");
foreach (var item in _state.Attendance.OrderBy(record => record.OccurredAtUtc))
{
var employee = _state.Employees.FirstOrDefault(candidate => candidate.Id == item.EmployeeId);
csv.Append(Csv(item.EmployeeName)).Append(';')
.Append(Csv(employee?.PersonnelNumber ?? "")).Append(';')
.Append(Csv(employee?.Position ?? "")).Append(';')
.Append(Csv(item.EventType)).Append(';')
.Append(Csv(item.LocalTime)).Append(';')
.Append(Csv(item.DeliveryStatus)).Append("\r\n");
}
await FileIO.WriteTextAsync(file, csv.ToString(), Windows.Storage.Streams.UnicodeEncoding.Utf8);
AppendSecurityAudit("attendance.export", "succeeded", $"records={_state.Attendance.Count}; file={file.Name}");
await _store.SaveAsync(_state);
SetStatus($"Журнал экспортирован: {file.Name}", InfoBarSeverity.Success);
}
catch (Exception exception)
{
AppendSecurityAudit("attendance.export", "failed", $"records={_state.Attendance.Count}; error={exception.GetType().Name}");
await _store.SaveAsync(_state);
CrashLogService.Write(exception);
SetStatus($"Экспорт не выполнен: {exception.Message}", InfoBarSeverity.Error);
}
await FileIO.WriteTextAsync(file, csv.ToString(), Windows.Storage.Streams.UnicodeEncoding.Utf8);
SetStatus($"Журнал экспортирован: {file.Name}", InfoBarSeverity.Success);
}
private static string Csv(string value) => $"\"{value.Replace("\"", "\"\"")}\"";
public async Task<bool> AuthorizeExitAsync()
{
if (_stateLoadFailed)
{
SetStatus("Хранилище не открыто. Программа завершает работу без изменения данных.", InfoBarSeverity.Warning);
return true;
}
if (!SecurityConfigured)
{
SetStatus("Выход заблокирован: сначала настройте PIN или отпечаток администратора.", InfoBarSeverity.Warning);
@ -686,26 +720,37 @@ public sealed partial class MainPage : Page
private bool SecurityConfigured =>
PinConfigured || AdministratorFingerprintConfigured;
private async Task<bool> AuthorizeAdminAsync(string operation)
private async Task<bool> AuthorizeAdminAsync(string operation, bool requireIdentifiedActor = false)
{
var remainingLockout = _pinBlockedUntilUtc - DateTimeOffset.UtcNow;
if (remainingLockout > TimeSpan.Zero)
var remainingLockout = _state.Settings.AdminPinBlockedUntilUtc - DateTimeOffset.UtcNow;
var pinBlocked = remainingLockout > TimeSpan.Zero;
var hasAdministratorFingerprint = AdministratorFingerprintConfigured;
if (requireIdentifiedActor && !hasAdministratorFingerprint)
{
SetStatus("Для экспорта персональных данных требуется отпечаток сотрудника-администратора.", InfoBarSeverity.Warning);
return false;
}
if (pinBlocked && !hasAdministratorFingerprint)
{
SetStatus($"Слишком много неверных попыток PIN. Повторите через {Math.Ceiling(remainingLockout.TotalSeconds)} сек.", InfoBarSeverity.Warning);
return false;
}
var hasAdministratorFingerprint = AdministratorFingerprintConfigured;
var pinBox = new PasswordBox
{
Header = "PIN администратора",
PasswordRevealMode = PasswordRevealMode.Peek,
MinWidth = 280
MinWidth = 280,
IsEnabled = !pinBlocked && !requireIdentifiedActor
};
var content = new StackPanel { Spacing = 12, MinWidth = 320 };
content.Children.Add(new TextBlock
{
Text = hasAdministratorFingerprint
? "Введите PIN или приложите зарегистрированный палец администратора."
Text = requireIdentifiedActor
? "Для записи автора экспорта приложите зарегистрированный палец сотрудника-администратора."
: hasAdministratorFingerprint
? pinBlocked
? $"PIN временно заблокирован ещё на {Math.Ceiling(remainingLockout.TotalSeconds)} сек. Приложите зарегистрированный палец администратора."
: "Введите PIN или приложите зарегистрированный палец администратора."
: "Введите PIN. После входа зарегистрируйте отпечаток администратора.",
TextWrapping = TextWrapping.Wrap
});
@ -718,6 +763,7 @@ public sealed partial class MainPage : Page
Title = $"Вход администратора: {operation}",
Content = content,
PrimaryButtonText = "Войти по PIN",
IsPrimaryButtonEnabled = !pinBlocked && !requireIdentifiedActor,
CloseButtonText = "Отмена",
DefaultButton = ContentDialogButton.Primary
};
@ -726,7 +772,8 @@ public sealed partial class MainPage : Page
_adminAuthCompletion.Task.Result;
_adminAuthCompletion.TrySetResult(false);
var pinAuthorized = false;
if (dialogResult == ContentDialogResult.Primary)
string verifiedPin = pinBox.Password;
if (dialogResult == ContentDialogResult.Primary && !pinBlocked && !requireIdentifiedActor)
{
pinAuthorized = await Task.Run(() =>
AdminPinService.Verify(
@ -742,17 +789,36 @@ public sealed partial class MainPage : Page
_state.Settings.AdminPinIterations)));
}
var authorized = fingerprintAuthorized || pinAuthorized;
var securityStateChanged = false;
if (authorized)
{
_failedPinAttempts = 0;
_pinBlockedUntilUtc = default;
_state.Settings.FailedAdminPinAttempts = 0;
_state.Settings.AdminPinBlockedUntilUtc = default;
securityStateChanged = true;
if (pinAuthorized)
{
_lastAdminActor = "local-pin";
if (_state.Settings.AdminPinIterations < AdminPinService.CurrentIterations)
{
if (!AdminPinService.Verify(verifiedPin, _state.Settings.AdminPinSalt, _state.Settings.AdminPinHash, _state.Settings.AdminPinIterations))
{
verifiedPin = verifiedPin.Trim();
}
var upgraded = await Task.Run(() => AdminPinService.Hash(verifiedPin));
_state.Settings.AdminPinSalt = upgraded.Salt;
_state.Settings.AdminPinHash = upgraded.Hash;
_state.Settings.AdminPinIterations = upgraded.Iterations;
}
}
}
else if (dialogResult == ContentDialogResult.Primary)
else if (dialogResult == ContentDialogResult.Primary && !pinBlocked && !requireIdentifiedActor)
{
_failedPinAttempts++;
var delaySeconds = Math.Min(30, 1 << Math.Min(_failedPinAttempts - 1, 5));
_pinBlockedUntilUtc = DateTimeOffset.UtcNow.AddSeconds(delaySeconds);
_state.Settings.FailedAdminPinAttempts++;
var delaySeconds = Math.Min(900, 1 << Math.Min(_state.Settings.FailedAdminPinAttempts - 1, 10));
_state.Settings.AdminPinBlockedUntilUtc = DateTimeOffset.UtcNow.AddSeconds(delaySeconds);
securityStateChanged = true;
}
if (securityStateChanged) await _store.SaveAsync(_state);
_adminAuthCompletion = null;
_adminAuthDialog = null;
SetStatus(authorized
@ -777,7 +843,7 @@ public sealed partial class MainPage : Page
_state.Settings.AdminPinHash = pin.Hash;
_state.Settings.AdminPinIterations = pin.Iterations;
NewAdminPinBox.Password = "";
await _store.SaveAsync(_state);
await _store.SaveAsync(_state, createRecoveryPoint: true);
SetStatus("Новый PIN администратора сохранён.", InfoBarSeverity.Success);
}
@ -785,7 +851,7 @@ public sealed partial class MainPage : Page
{
if (!await EnsureAdminSessionAsync("изменение настроек")) return;
CopySettingsFromControls();
await _store.SaveAsync(_state);
await _store.SaveAsync(_state, createRecoveryPoint: true);
ApplyStartupSetting();
ConnectScanner();
SetStatus("Настройки сохранены.", InfoBarSeverity.Success);
@ -843,26 +909,47 @@ public sealed partial class MainPage : Page
private void RefreshLists()
{
EmployeesList.ItemsSource = null;
EmployeesList.ItemsSource = _state.Employees.OrderBy(employee => employee.DisplayName).ToList();
AttendanceList.ItemsSource = null;
AttendanceList.ItemsSource = _state.Attendance
.OrderByDescending(item => item.OccurredAtUtc)
.Take(100)
.Select(item => _state.Settings.ShowEmployeeNamesOnTerminal ? item : new AttendanceRecord
{
Id = item.Id,
EmployeeId = item.EmployeeId,
EmployeeName = MaskName(item.EmployeeName),
OccurredAtUtc = item.OccurredAtUtc,
ReaderIndex = item.ReaderIndex,
EventType = item.EventType,
NotificationDelivered = item.NotificationDelivered,
NotificationError = item.NotificationError
})
.ToList();
var selectedEmployeeId = (EmployeesList.SelectedItem as EmployeeRecord)?.Id;
_refreshingLists = true;
try
{
var employees = _state.Employees.OrderBy(employee => employee.DisplayName).ToList();
EmployeesList.ItemsSource = employees;
EmployeesList.SelectedItem = employees.FirstOrDefault(employee => employee.Id == selectedEmployeeId);
AttendanceList.ItemsSource = _state.Attendance
.OrderByDescending(item => item.OccurredAtUtc)
.Take(100)
.Select(item => _state.Settings.ShowEmployeeNamesOnTerminal ? item : new AttendanceRecord
{
Id = item.Id,
EmployeeId = item.EmployeeId,
EmployeeName = MaskName(item.EmployeeName),
OccurredAtUtc = item.OccurredAtUtc,
ReaderIndex = item.ReaderIndex,
EventType = item.EventType,
NotificationDelivered = item.NotificationDelivered,
NotificationError = item.NotificationError
})
.ToList();
}
finally
{
_refreshingLists = false;
}
}
private string TerminalDisplayName(EmployeeRecord employee) =>
_state.Settings.ShowEmployeeNamesOnTerminal ? employee.DisplayName : MaskName(employee.DisplayName);
private void AppendSecurityAudit(string action, string outcome, string detail) =>
_state.SecurityAudit.Add(new TerminalAuditRecord
{
Action = action,
Actor = _lastAdminActor,
Outcome = outcome,
Detail = detail
});
private static string MaskName(string name) => string.Join(' ', name
.Split(' ', StringSplitOptions.RemoveEmptyEntries)
.Select(part => part.Length == 1 ? $"{part[0]}…" : $"{part[0]}***"));

View file

@ -67,6 +67,8 @@ public sealed class TerminalSettings
public string AdminPinSalt { get; set; } = "";
public string AdminPinHash { get; set; } = "";
public int AdminPinIterations { get; set; } = 120_000;
public int FailedAdminPinAttempts { get; set; }
public DateTimeOffset AdminPinBlockedUntilUtc { get; set; }
public int DuplicateScanGuardSeconds { get; set; } = 120;
public int MaximumShiftHours { get; set; } = 20;
public bool ShowEmployeeNamesOnTerminal { get; set; }
@ -82,5 +84,16 @@ public sealed class TerminalState
{
public List<EmployeeRecord> Employees { get; set; } = [];
public List<AttendanceRecord> Attendance { get; set; } = [];
public List<TerminalAuditRecord> SecurityAudit { get; set; } = [];
public TerminalSettings Settings { get; set; } = new();
}
public sealed class TerminalAuditRecord
{
public string Id { get; set; } = UlidGenerator.New();
public DateTimeOffset OccurredAtUtc { get; set; } = DateTimeOffset.UtcNow;
public string Action { get; set; } = "";
public string Actor { get; set; } = "";
public string Outcome { get; set; } = "";
public string Detail { get; set; } = "";
}

View file

@ -16,6 +16,7 @@ public sealed class FingerprintScanner : IDisposable
private DateTimeOffset _registrationExpiresAtUtc;
private DateTimeOffset _lastProcessedCapture = DateTimeOffset.MinValue;
private bool _fingerOnSensor;
private int _cleanupScheduled;
public event Action<int, int>? FingerRecognized;
public event Action<int>? RegistrationProgress;
@ -271,26 +272,63 @@ public sealed class FingerprintScanner : IDisposable
_captureCancellation?.Cancel();
try
{
_captureTask?.Wait(TimeSpan.FromSeconds(2));
if (_captureTask is not null && !_captureTask.Wait(TimeSpan.FromSeconds(2)))
{
if (Interlocked.Exchange(ref _cleanupScheduled, 1) == 0)
{
_ = _captureTask.ContinueWith(
_ =>
{
ReleaseNativeResources();
ConnectionLost?.Invoke();
},
CancellationToken.None,
TaskContinuationOptions.ExecuteSynchronously,
TaskScheduler.Default);
}
throw new TimeoutException(
"Поток чтения сканера не завершился вовремя. Ресурсы будут освобождены сразу после безопасной остановки чтения.");
}
}
catch (AggregateException exception)
{
CrashLogService.Write(exception.Flatten());
}
_captureCancellation = null;
_captureTask = null;
if (_database != IntPtr.Zero)
{
zkfp2.DBFree(_database);
_database = IntPtr.Zero;
}
if (_device != IntPtr.Zero)
{
zkfp2.CloseDevice(_device);
_device = IntPtr.Zero;
}
zkfp2.Terminate();
ReleaseNativeResources();
}
public void Dispose() => Disconnect();
private void ReleaseNativeResources()
{
lock (_gate)
{
_captureCancellation?.Dispose();
_captureCancellation = null;
_captureTask = null;
_fingerOnSensor = false;
if (_database != IntPtr.Zero)
{
zkfp2.DBFree(_database);
_database = IntPtr.Zero;
}
if (_device != IntPtr.Zero)
{
zkfp2.CloseDevice(_device);
_device = IntPtr.Zero;
}
zkfp2.Terminate();
Interlocked.Exchange(ref _cleanupScheduled, 0);
}
}
public void Dispose()
{
try
{
Disconnect();
}
catch (TimeoutException exception)
{
CrashLogService.Write(exception);
}
}
}

View file

@ -24,17 +24,8 @@ public static class StartupService
private static string ResolveExecutablePath()
{
var executablePath = Environment.ProcessPath
return Environment.ProcessPath
?? throw new InvalidOperationException("Не удалось определить путь программы.");
var debugSegment = $"{Path.DirectorySeparatorChar}Debug{Path.DirectorySeparatorChar}";
if (!executablePath.Contains(debugSegment, StringComparison.OrdinalIgnoreCase)) return executablePath;
var releasePath = executablePath.Replace(
debugSegment,
$"{Path.DirectorySeparatorChar}Release{Path.DirectorySeparatorChar}",
StringComparison.OrdinalIgnoreCase);
if (File.Exists(releasePath)) return releasePath;
throw new InvalidOperationException("Автозагрузка доступна после сборки Release-версии программы.");
}
public static bool IsEnabled()

View file

@ -1,6 +1,7 @@
using System.Security.Cryptography;
using System.Text.Json;
using KronaStaffTerminal.Models;
using Microsoft.Win32;
namespace KronaStaffTerminal.Services;
@ -8,6 +9,9 @@ public sealed class TerminalStore
{
private static readonly byte[] Entropy = "Krona.Staff.Terminal.v1"u8.ToArray();
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
private const string RegistryPath = @"Software\Krona\StaffTerminal";
private const string InitializedValueName = "StateInitialized";
private static readonly TimeSpan AutomaticRecoveryPointInterval = TimeSpan.FromHours(12);
private readonly SemaphoreSlim _saveGate = new(1, 1);
private readonly string _filePath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
@ -15,10 +19,30 @@ public sealed class TerminalStore
"StaffTerminal",
"terminal-state.dat");
public string? RecoveryNotice { get; private set; }
public async Task<TerminalState> LoadAsync()
{
RecoveryNotice = null;
var sourcePath = File.Exists(_filePath) ? _filePath : FindLegacyPackagedState();
if (sourcePath is null) return new TerminalState();
if (sourcePath is null)
{
var recovered = await TryRecoverFromBackupAsync();
if (recovered is not null)
{
RecoveryNotice = "Основной файл отсутствовал. Данные восстановлены из защищённой резервной копии.";
await SaveAsync(recovered);
return recovered;
}
if (WasPreviouslyInitialized() || HasRecoveryArtifacts())
{
throw new InvalidDataException(
"Основной файл состояния отсутствует, но терминал уже был настроен. Первичная настройка заблокирована до восстановления данных.");
}
return new TerminalState();
}
try
{
@ -28,9 +52,15 @@ public sealed class TerminalStore
{
await SaveAsync(state);
}
else
{
MarkInitialized();
}
return state;
}
catch (Exception primaryException) when (string.Equals(sourcePath, _filePath, StringComparison.OrdinalIgnoreCase))
catch (Exception primaryException) when (
string.Equals(sourcePath, _filePath, StringComparison.OrdinalIgnoreCase) &&
IsCorruptStateException(primaryException))
{
var corruptPath = _filePath + $".corrupt-{DateTimeOffset.UtcNow:yyyyMMdd-HHmmss}";
try
@ -44,34 +74,65 @@ public sealed class TerminalStore
new AggregateException(primaryException, moveException));
}
foreach (var backupPath in BackupPaths())
var recovered = await TryRecoverFromBackupAsync();
if (recovered is not null)
{
if (!File.Exists(backupPath)) continue;
try
{
var recovered = await ReadStateAsync(backupPath);
MigrateFingerprints(recovered);
await SaveAsync(recovered);
return recovered;
}
catch (Exception backupException)
{
CrashLogService.Write(backupException);
}
RecoveryNotice = $"Повреждённый файл сохранён как {Path.GetFileName(corruptPath)}. Данные восстановлены из защищённой резервной копии.";
await SaveAsync(recovered);
return recovered;
}
throw new InvalidDataException(
$"Хранилище терминала повреждено. Исходный файл сохранён как {Path.GetFileName(corruptPath)}. Работа заблокирована до восстановления резервной копии.",
primaryException);
}
catch (IOException exception) when (string.Equals(sourcePath, _filePath, StringComparison.OrdinalIgnoreCase))
{
throw new IOException(
"Хранилище терминала временно недоступно. Файл не изменён; закройте программу, которая его удерживает, и повторите запуск.",
exception);
}
catch (UnauthorizedAccessException exception) when (string.Equals(sourcePath, _filePath, StringComparison.OrdinalIgnoreCase))
{
throw new UnauthorizedAccessException(
"Нет доступа к хранилищу терминала. Файл не изменён; проверьте права текущего пользователя.",
exception);
}
}
private async Task<TerminalState?> TryRecoverFromBackupAsync()
{
foreach (var backupPath in BackupPaths())
{
if (!File.Exists(backupPath)) continue;
try
{
var recovered = await ReadStateAsync(backupPath);
MigrateFingerprints(recovered);
return recovered;
}
catch (Exception backupException) when (IsCorruptStateException(backupException))
{
CrashLogService.Write(backupException);
}
}
return null;
}
private static bool IsCorruptStateException(Exception exception) =>
exception is CryptographicException or JsonException or InvalidDataException or NotSupportedException;
private static async Task<TerminalState> ReadStateAsync(string path)
{
var encrypted = await File.ReadAllBytesAsync(path);
var clear = ProtectedData.Unprotect(encrypted, Entropy, DataProtectionScope.CurrentUser);
return JsonSerializer.Deserialize<TerminalState>(clear, JsonOptions)
?? throw new InvalidDataException("Файл состояния не содержит данных.");
var state = JsonSerializer.Deserialize<TerminalState>(clear, JsonOptions)
?? throw new InvalidDataException("Файл состояния не содержит данных.");
if (state.Employees is null || state.Attendance is null || state.SecurityAudit is null || state.Settings is null)
{
throw new InvalidDataException("Файл состояния имеет неполную структуру.");
}
return state;
}
private static bool MigrateFingerprints(TerminalState state)
@ -101,7 +162,7 @@ public sealed class TerminalStore
return changed;
}
public async Task SaveAsync(TerminalState state)
public async Task SaveAsync(TerminalState state, bool createRecoveryPoint = false)
{
await _saveGate.WaitAsync();
try
@ -124,8 +185,25 @@ public sealed class TerminalStore
stream.Flush(true);
}
RotateBackups();
string? previousStatePath = null;
if (File.Exists(_filePath) && ShouldCreateRecoveryPoint(createRecoveryPoint))
{
previousStatePath = _filePath + ".previous";
File.Copy(_filePath, previousStatePath, true);
}
File.Move(temporaryPath, _filePath, true);
MarkInitialized();
if (previousStatePath is not null)
{
try
{
RotateBackups(previousStatePath);
}
finally
{
File.Delete(previousStatePath);
}
}
}
finally
{
@ -133,19 +211,48 @@ public sealed class TerminalStore
}
}
private bool ShouldCreateRecoveryPoint(bool requested)
{
var latestBackup = BackupPaths().First();
var minimumInterval = requested ? TimeSpan.FromHours(24) : AutomaticRecoveryPointInterval;
return !File.Exists(latestBackup) ||
DateTimeOffset.UtcNow - File.GetLastWriteTimeUtc(latestBackup) >= minimumInterval;
}
private IEnumerable<string> BackupPaths()
{
for (var index = 1; index <= 3; index++) yield return _filePath + $".bak{index}";
}
private void RotateBackups()
private void RotateBackups(string previousStatePath)
{
var backups = BackupPaths().ToArray();
for (var index = backups.Length - 1; index > 0; index--)
{
if (File.Exists(backups[index - 1])) File.Copy(backups[index - 1], backups[index], true);
}
if (File.Exists(_filePath)) File.Copy(_filePath, backups[0], true);
File.Copy(previousStatePath, backups[0], true);
}
private bool HasRecoveryArtifacts()
{
if (BackupPaths().Any(File.Exists) || File.Exists(_filePath + ".tmp") || File.Exists(_filePath + ".previous")) return true;
var directory = Path.GetDirectoryName(_filePath)!;
return Directory.Exists(directory) &&
Directory.EnumerateFiles(directory, "terminal-state.dat.corrupt-*").Any();
}
private static bool WasPreviouslyInitialized()
{
using var key = Registry.CurrentUser.OpenSubKey(RegistryPath);
return key?.GetValue(InitializedValueName) is int value && value == 1;
}
private static void MarkInitialized()
{
using var key = Registry.CurrentUser.CreateSubKey(RegistryPath, writable: true)
?? throw new InvalidOperationException("Не удалось открыть защищённый маркер настройки терминала.");
key.SetValue(InitializedValueName, 1, RegistryValueKind.DWord);
}
private static string? FindLegacyPackagedState()

View file

@ -1,30 +1,52 @@
services:
app:
build: .
env_file: .env
environment:
NODE_ENV: ${NODE_ENV:-development}
PORT: ${PORT:-8080}
LOG_LEVEL: ${LOG_LEVEL:-info}
STAFF_RUNTIME_MODE: ${STAFF_RUNTIME_MODE:-standalone}
DATABASE_URL: postgres://krona_staff_app:${POSTGRES_APP_PASSWORD:-local-app-only}@postgres:5432/krona_staff
PLATFORM_GATEWAY_URL: ${PLATFORM_GATEWAY_URL:-}
PLATFORM_JWT_ISSUER: ${PLATFORM_JWT_ISSUER:-}
PLATFORM_JWT_AUDIENCE: ${PLATFORM_JWT_AUDIENCE:-}
PLATFORM_JWKS_URL: ${PLATFORM_JWKS_URL:-}
STANDALONE_TENANT_ID: ${STANDALONE_TENANT_ID:-01J00000000000000000000000}
STANDALONE_ACTOR_ID: ${STANDALONE_ACTOR_ID:-01J00000000000000000000001}
ports: ['8080:8080']
depends_on:
postgres: { condition: service_healthy }
migrate: { condition: service_completed_successfully }
restart: unless-stopped
read_only: true
tmpfs: [/tmp]
security_opt: ['no-new-privileges:true']
migrate:
healthcheck:
test: ['CMD-SHELL', 'wget -q -O - http://127.0.0.1:8080/api/v1/staff/live || exit 1']
interval: 30s
timeout: 3s
retries: 3
db-role:
build: { context: ., target: build }
env_file: .env
environment:
DATABASE_MIGRATION_URL: postgres://krona_migrator:${POSTGRES_MIGRATION_PASSWORD:-local-migration-only}@postgres:5432/krona_staff
command: ['node', 'scripts/migrate.mjs', 'up']
DATABASE_MIGRATION_URL: postgres://${POSTGRES_MIGRATION_USER:-krona_migrator}:${POSTGRES_MIGRATION_PASSWORD:-local-migration-only}@postgres:5432/krona_staff
POSTGRES_APP_PASSWORD: ${POSTGRES_APP_PASSWORD:-local-app-only}
command: ['node', 'scripts/create-app-role.mjs']
depends_on:
postgres: { condition: service_healthy }
profiles: ['tools']
restart: 'no'
migrate:
build: { context: ., target: build }
environment:
DATABASE_MIGRATION_URL: postgres://${POSTGRES_MIGRATION_USER:-krona_migrator}:${POSTGRES_MIGRATION_PASSWORD:-local-migration-only}@postgres:5432/krona_staff
command: ['node', 'scripts/migrate.mjs', 'up']
depends_on:
db-role: { condition: service_completed_successfully }
restart: 'no'
postgres:
image: postgres:17-alpine@sha256:18cfe3ef5e6815560c98237d6216d1e5119702fb0f3894c8785dd58b8bbe5d73
environment:
POSTGRES_DB: krona_staff
POSTGRES_USER: krona_migrator
POSTGRES_USER: ${POSTGRES_MIGRATION_USER:-krona_migrator}
POSTGRES_PASSWORD: ${POSTGRES_MIGRATION_PASSWORD:-local-migration-only}
POSTGRES_APP_PASSWORD: ${POSTGRES_APP_PASSWORD:-local-app-only}
volumes:
@ -32,7 +54,8 @@ services:
- ./docker/postgres/init-app-role.sh:/docker-entrypoint-initdb.d/10-app-role.sh:ro
healthcheck:
{
test: ['CMD-SHELL', 'pg_isready -U krona_migrator -d krona_staff'],
test:
['CMD-SHELL', 'pg_isready -U ${POSTGRES_MIGRATION_USER:-krona_migrator} -d krona_staff'],
interval: 5s,
timeout: 3s,
retries: 20,

View file

@ -1,9 +1,11 @@
# Развёртывание
Одна команда локально: скопировать `.env.example` в `.env`, затем `docker compose --profile tools run --rm migrate` и `docker compose up --build`. Compose поднимает приложение и PostgreSQL. Контейнер PostgreSQL создаёт отдельную роль `krona_staff_app` с `NOSUPERUSER NOBYPASSRLS`; миграции выполняются только ролью `krona_migrator`. Redis и object storage не включаются до появления реального сценария очередей или файлов.
Локально скопируйте `.env.example` в `.env` и выполните `docker compose up --build`. Compose последовательно проверит PostgreSQL, создаст или обновит отдельную роль `krona_staff_app` с `NOSUPERUSER NOBYPASSRLS`, выполнит миграции ролью `krona_migrator` и только затем запустит приложение. Runtime-контейнер не получает пароль или URL мигратора. Redis и object storage не включаются до появления реального сценария очередей или файлов.
Для обновления старого Docker volume, созданного версией с ролью `krona_staff`, задайте в `.env` `POSTGRES_MIGRATION_USER=krona_staff` и прежний `POSTGRES_MIGRATION_PASSWORD`, затем выполните обычный `docker compose up --build`. Сохраняйте эту роль владельцем миграций для данного volume: автоматического переименования владельца существующей базы нет, а перенос владения требует отдельного DBA-плана с повторной настройкой default privileges.
Production: один stateless app container/process, отдельный PostgreSQL, TLS/reverse proxy, secret manager и централизованные logs/metrics/traces. Приложению выдаётся только роль без `SUPERUSER` и `BYPASSRLS`; миграционный URL хранится отдельно и не передаётся runtime-контейнеру. `/api/v1/staff/live` проверяет процесс, `/api/v1/staff/ready` — обязательные зависимости, `/api/v1/staff/version` — release и runtime mode. Container работает non-root, read-only filesystem.
HA сейчас не требуется. Для будущего scale-out: общий PostgreSQL/Redis/S3, несколько API/worker nodes, leader election для scheduler, outbox relay и идемпотентные consumers. Локальные вложения запрещены. Linux и Windows deployment сохраняют паритет на уровне Node/container contract.
Миграции запускаются отдельным one-shot job до rollout, учитываются в `schema_migrations` и защищены advisory lock. Down откатывает только последнюю миграцию и требует backup плюс `ALLOW_MIGRATION_DOWN=true`.
Миграции запускаются отдельным one-shot job до rollout, учитываются в `schema_migrations` вместе с SHA-256 исходного SQL и защищены advisory lock. Для старых записей без checksum первый запуск фиксирует текущую версию как доверенную базовую; последующие изменения применённого SQL блокируются. Down откатывает только последнюю миграцию и требует backup плюс `ALLOW_MIGRATION_DOWN=true`.

View file

@ -5,11 +5,11 @@
## Context
Platform contracts are TypeScript, baseline API is REST/OpenAPI 3.1, PostgreSQL/Redis/BullMQ are normative, blocks need independent deployment without duplicating Core identity. Staff business rules are unknown.
Platform contracts are TypeScript, baseline API is REST/OpenAPI 3.1, PostgreSQL is the current durable store, and blocks need independent deployment without duplicating Core identity. Staff business rules are unknown. Redis/BullMQ are intentionally deferred until a confirmed queue or worker scenario exists.
## Decision
Use Node.js 22 + strict TypeScript, Fastify, PostgreSQL 17, Redis/BullMQ, Zod, Vitest and a single stateless container. Keep domain code behind ports. Select either standalone adapters or platform-gateway adapters through configuration. Use ULID `VARCHAR(26)`, UTC, `BIGINT` minor money, transactional outbox/inbox and S3 abstraction.
Use Node.js 22 + strict TypeScript, Fastify, PostgreSQL 17, Zod, Vitest and a single stateless container. Keep domain code behind ports. Select either standalone adapters or platform-gateway adapters through configuration. Use ULID `VARCHAR(26)`, UTC, `BIGINT` minor money, transactional outbox/inbox and S3 abstraction. Add Redis/BullMQ only with a confirmed workload and a separate ADR.
Standalone provides only explicit development tenant/actor references; no durable user/role source. Platform mode fails closed until verified Core JWT/gateway contracts are implemented.

View file

@ -0,0 +1,4 @@
ALTER DEFAULT PRIVILEGES IN SCHEMA public
REVOKE SELECT, INSERT, UPDATE, DELETE ON TABLES FROM krona_staff_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
REVOKE USAGE, SELECT ON SEQUENCES FROM krona_staff_app;

View file

@ -0,0 +1,17 @@
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'krona_staff_app') THEN
RAISE EXCEPTION 'Required database role krona_staff_app does not exist. Run scripts/create-app-role.mjs before migrations.';
END IF;
END
$$;
GRANT USAGE ON SCHEMA public TO krona_staff_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON staff_outbox, staff_inbox, staff_idempotency_keys TO krona_staff_app;
GRANT SELECT, INSERT ON staff_audit_log TO krona_staff_app;
REVOKE UPDATE, DELETE, TRUNCATE ON staff_audit_log FROM krona_staff_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO krona_staff_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT USAGE, SELECT ON SEQUENCES TO krona_staff_app;

View file

@ -1,6 +1,11 @@
import { readFile, readdir } from 'node:fs/promises';
import path from 'node:path';
import pg from 'pg';
import {
assertAppliedMigrationsPresent,
assertMigrationChecksum,
migrationChecksum,
} from './migration-checksum.mjs';
const direction = process.argv[2];
if (direction !== 'up' && direction !== 'down') {
@ -28,27 +33,50 @@ try {
await client.query(`
CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
checksum TEXT,
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`);
await client.query('ALTER TABLE schema_migrations ADD COLUMN IF NOT EXISTS checksum TEXT');
if (direction === 'up') {
const appliedResult = await client.query('SELECT version FROM schema_migrations');
const applied = new Set(appliedResult.rows.map((row) => String(row.version)));
const appliedResult = await client.query('SELECT version, checksum FROM schema_migrations');
const applied = new Map(
appliedResult.rows.map((row) => [String(row.version), row.checksum && String(row.checksum)]),
);
const availableVersions = new Set(files.map((file) => file.replace(/\.up\.sql$/, '')));
assertAppliedMigrationsPresent(applied.keys(), availableVersions);
for (const file of files) {
const version = file.replace(/\.up\.sql$/, '');
if (applied.has(version)) continue;
const sql = await readFile(path.join(migrationsDirectory, file), 'utf8');
const content = await readFile(path.join(migrationsDirectory, file));
const checksum = migrationChecksum(content);
if (applied.has(version)) {
const storedChecksum = applied.get(version);
if (storedChecksum) {
assertMigrationChecksum(version, storedChecksum, checksum);
} else {
await client.query('UPDATE schema_migrations SET checksum = $2 WHERE version = $1', [
version,
checksum,
]);
console.warn(`baselined checksum for legacy migration ${version}`);
}
continue;
}
const sql = content.toString('utf8');
await applyTransaction(async () => {
await client.query(sql);
await client.query('INSERT INTO schema_migrations (version) VALUES ($1)', [version]);
await client.query('INSERT INTO schema_migrations (version, checksum) VALUES ($1, $2)', [
version,
checksum,
]);
});
console.log(`applied ${file}`);
}
} else {
const requestedVersion = process.argv[3];
const latestResult = await client.query(
'SELECT version FROM schema_migrations ORDER BY version DESC LIMIT 1',
'SELECT version, checksum FROM schema_migrations ORDER BY version DESC LIMIT 1',
);
const latestVersion = latestResult.rows[0]?.version;
if (!latestVersion) {
@ -60,6 +88,18 @@ try {
);
}
const downFile = `${latestVersion}.down.sql`;
const upContent = await readFile(path.join(migrationsDirectory, `${latestVersion}.up.sql`));
const currentChecksum = migrationChecksum(upContent);
if (!latestResult.rows[0].checksum) {
throw new Error(
`Migration ${latestVersion} has no checksum. Run the up command once to establish the legacy baseline before rollback.`,
);
}
assertMigrationChecksum(
latestVersion,
String(latestResult.rows[0].checksum),
currentChecksum,
);
const sql = await readFile(path.join(migrationsDirectory, downFile), 'utf8');
await applyTransaction(async () => {
await client.query(sql);

View file

@ -0,0 +1,6 @@
export function migrationChecksum(content: Uint8Array): string;
export function assertMigrationChecksum(version: string, expected: string, actual: string): void;
export function assertAppliedMigrationsPresent(
appliedVersions: Iterable<string>,
availableVersions: Iterable<string>,
): void;

View file

@ -0,0 +1,22 @@
import { createHash } from 'node:crypto';
export function migrationChecksum(content) {
return createHash('sha256').update(content).digest('hex');
}
export function assertMigrationChecksum(version, expected, actual) {
if (expected !== actual) {
throw new Error(
`Applied migration ${version} was modified: expected sha256 ${expected}, got ${actual}`,
);
}
}
export function assertAppliedMigrationsPresent(appliedVersions, availableVersions) {
const available = new Set(availableVersions);
for (const version of appliedVersions) {
if (!available.has(version)) {
throw new Error(`Applied migration ${version} is missing from the repository`);
}
}
}

View file

@ -6,15 +6,23 @@ const configSchema = z.object({
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace', 'silent']).default('info'),
STAFF_RUNTIME_MODE: z.enum(['standalone', 'platform']).default('standalone'),
DATABASE_URL: z.string().url(),
REDIS_URL: z.string().url().optional(),
PLATFORM_GATEWAY_URL: z.string().url().optional(),
PLATFORM_JWT_ISSUER: z.string().url().optional(),
PLATFORM_JWT_AUDIENCE: z.string().optional(),
PLATFORM_JWKS_URL: z.string().url().optional(),
REDIS_URL: optionalUrl(),
PLATFORM_GATEWAY_URL: optionalUrl(),
PLATFORM_JWT_ISSUER: optionalUrl(),
PLATFORM_JWT_AUDIENCE: emptyStringAsUndefined(z.string().optional()),
PLATFORM_JWKS_URL: optionalUrl(),
STANDALONE_TENANT_ID: z.string().regex(/^[0-9A-HJKMNP-TV-Z]{26}$/),
STANDALONE_ACTOR_ID: z.string().regex(/^[0-9A-HJKMNP-TV-Z]{26}$/),
});
function emptyStringAsUndefined<T extends z.ZodTypeAny>(schema: T) {
return z.preprocess((value) => (value === '' ? undefined : value), schema);
}
function optionalUrl() {
return emptyStringAsUndefined(z.string().url().optional());
}
export type AppConfig = z.infer<typeof configSchema>;
export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {

View file

@ -18,4 +18,14 @@ describe('runtime modes', () => {
expect(() => loadConfig({ ...base, NODE_ENV: 'production' })).toThrow(
/standalone is not allowed/,
));
it('treats empty optional platform variables as absent in compose', () =>
expect(
loadConfig({
...base,
PLATFORM_GATEWAY_URL: '',
PLATFORM_JWT_ISSUER: '',
PLATFORM_JWT_AUDIENCE: '',
PLATFORM_JWKS_URL: '',
}).PLATFORM_GATEWAY_URL,
).toBeUndefined());
});

View file

@ -0,0 +1,27 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
import { parse } from 'yaml';
describe('container deployment boundaries', () => {
const compose = parse(readFileSync('compose.yaml', 'utf8')) as {
services: Record<string, { environment?: Record<string, string>; depends_on?: unknown }>;
};
it('probes the versioned liveness route', () => {
expect(readFileSync('Dockerfile', 'utf8')).toContain('/api/v1/staff/live');
expect(readFileSync('Dockerfile', 'utf8')).not.toMatch(/8080\/live\b/);
});
it('does not expose migration credentials to the runtime service', () => {
const environment = compose.services.app?.environment ?? {};
expect(environment).not.toHaveProperty('DATABASE_MIGRATION_URL');
expect(environment).not.toHaveProperty('POSTGRES_MIGRATION_PASSWORD');
expect(environment).not.toHaveProperty('POSTGRES_APP_PASSWORD');
});
it('provisions the app role and migrates before app startup', () => {
expect(compose.services['db-role']).toBeDefined();
expect(compose.services.migrate?.depends_on).toHaveProperty('db-role');
expect(compose.services.app?.depends_on).toHaveProperty('migrate');
});
});

View file

@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import {
assertAppliedMigrationsPresent,
assertMigrationChecksum,
migrationChecksum,
} from '../../scripts/migration-checksum.mjs';
describe('migration checksums', () => {
it('is stable for the exact migration bytes', () => {
const content = Buffer.from('SELECT 1;\n', 'utf8');
expect(migrationChecksum(content)).toBe(migrationChecksum(content));
});
it('rejects a modified applied migration', () => {
const expected = migrationChecksum(Buffer.from('SELECT 1;\n', 'utf8'));
const actual = migrationChecksum(Buffer.from('SELECT 2;\n', 'utf8'));
expect(() => assertMigrationChecksum('0001', expected, actual)).toThrow(
/Applied migration 0001 was modified/,
);
});
it('rejects an applied migration removed from the repository', () => {
expect(() => assertAppliedMigrationsPresent(['0001', '0002'], ['0002'])).toThrow(
/Applied migration 0001 is missing/,
);
});
});