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 LOG_LEVEL=info
STAFF_RUNTIME_MODE=standalone STAFF_RUNTIME_MODE=standalone
POSTGRES_MIGRATION_PASSWORD=local-migration-only POSTGRES_MIGRATION_PASSWORD=local-migration-only
POSTGRES_MIGRATION_USER=krona_migrator
POSTGRES_APP_PASSWORD=local-app-only POSTGRES_APP_PASSWORD=local-app-only
DATABASE_MIGRATION_URL=postgres://krona_migrator:local-migration-only@postgres:5432/krona_staff 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 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 COPY --from=build /app/dist ./dist
USER app USER app
EXPOSE 8080 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"] CMD ["node", "dist/server.js"]

View file

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

View file

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

View file

@ -67,6 +67,8 @@ public sealed class TerminalSettings
public string AdminPinSalt { get; set; } = ""; public string AdminPinSalt { get; set; } = "";
public string AdminPinHash { get; set; } = ""; public string AdminPinHash { get; set; } = "";
public int AdminPinIterations { get; set; } = 120_000; 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 DuplicateScanGuardSeconds { get; set; } = 120;
public int MaximumShiftHours { get; set; } = 20; public int MaximumShiftHours { get; set; } = 20;
public bool ShowEmployeeNamesOnTerminal { get; set; } public bool ShowEmployeeNamesOnTerminal { get; set; }
@ -82,5 +84,16 @@ public sealed class TerminalState
{ {
public List<EmployeeRecord> Employees { get; set; } = []; public List<EmployeeRecord> Employees { get; set; } = [];
public List<AttendanceRecord> Attendance { get; set; } = []; public List<AttendanceRecord> Attendance { get; set; } = [];
public List<TerminalAuditRecord> SecurityAudit { get; set; } = [];
public TerminalSettings Settings { get; set; } = new(); 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 _registrationExpiresAtUtc;
private DateTimeOffset _lastProcessedCapture = DateTimeOffset.MinValue; private DateTimeOffset _lastProcessedCapture = DateTimeOffset.MinValue;
private bool _fingerOnSensor; private bool _fingerOnSensor;
private int _cleanupScheduled;
public event Action<int, int>? FingerRecognized; public event Action<int, int>? FingerRecognized;
public event Action<int>? RegistrationProgress; public event Action<int>? RegistrationProgress;
@ -271,14 +272,39 @@ public sealed class FingerprintScanner : IDisposable
_captureCancellation?.Cancel(); _captureCancellation?.Cancel();
try 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) catch (AggregateException exception)
{ {
CrashLogService.Write(exception.Flatten()); CrashLogService.Write(exception.Flatten());
} }
ReleaseNativeResources();
}
private void ReleaseNativeResources()
{
lock (_gate)
{
_captureCancellation?.Dispose();
_captureCancellation = null; _captureCancellation = null;
_captureTask = null; _captureTask = null;
_fingerOnSensor = false;
if (_database != IntPtr.Zero) if (_database != IntPtr.Zero)
{ {
zkfp2.DBFree(_database); zkfp2.DBFree(_database);
@ -290,7 +316,19 @@ public sealed class FingerprintScanner : IDisposable
_device = IntPtr.Zero; _device = IntPtr.Zero;
} }
zkfp2.Terminate(); zkfp2.Terminate();
Interlocked.Exchange(ref _cleanupScheduled, 0);
}
} }
public void Dispose() => Disconnect(); 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() private static string ResolveExecutablePath()
{ {
var executablePath = Environment.ProcessPath return Environment.ProcessPath
?? throw new InvalidOperationException("Не удалось определить путь программы."); ?? 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() public static bool IsEnabled()

View file

@ -1,6 +1,7 @@
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text.Json; using System.Text.Json;
using KronaStaffTerminal.Models; using KronaStaffTerminal.Models;
using Microsoft.Win32;
namespace KronaStaffTerminal.Services; 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 byte[] Entropy = "Krona.Staff.Terminal.v1"u8.ToArray();
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; 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 SemaphoreSlim _saveGate = new(1, 1);
private readonly string _filePath = Path.Combine( private readonly string _filePath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
@ -15,10 +19,30 @@ public sealed class TerminalStore
"StaffTerminal", "StaffTerminal",
"terminal-state.dat"); "terminal-state.dat");
public string? RecoveryNotice { get; private set; }
public async Task<TerminalState> LoadAsync() public async Task<TerminalState> LoadAsync()
{ {
RecoveryNotice = null;
var sourcePath = File.Exists(_filePath) ? _filePath : FindLegacyPackagedState(); 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 try
{ {
@ -28,9 +52,15 @@ public sealed class TerminalStore
{ {
await SaveAsync(state); await SaveAsync(state);
} }
else
{
MarkInitialized();
}
return state; 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}"; var corruptPath = _filePath + $".corrupt-{DateTimeOffset.UtcNow:yyyyMMdd-HHmmss}";
try try
@ -44,6 +74,34 @@ public sealed class TerminalStore
new AggregateException(primaryException, moveException)); new AggregateException(primaryException, moveException));
} }
var recovered = await TryRecoverFromBackupAsync();
if (recovered is not null)
{
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()) foreach (var backupPath in BackupPaths())
{ {
if (!File.Exists(backupPath)) continue; if (!File.Exists(backupPath)) continue;
@ -51,27 +109,30 @@ public sealed class TerminalStore
{ {
var recovered = await ReadStateAsync(backupPath); var recovered = await ReadStateAsync(backupPath);
MigrateFingerprints(recovered); MigrateFingerprints(recovered);
await SaveAsync(recovered);
return recovered; return recovered;
} }
catch (Exception backupException) catch (Exception backupException) when (IsCorruptStateException(backupException))
{ {
CrashLogService.Write(backupException); CrashLogService.Write(backupException);
} }
} }
return null;
}
throw new InvalidDataException( private static bool IsCorruptStateException(Exception exception) =>
$"Хранилище терминала повреждено. Исходный файл сохранён как {Path.GetFileName(corruptPath)}. Работа заблокирована до восстановления резервной копии.", exception is CryptographicException or JsonException or InvalidDataException or NotSupportedException;
primaryException);
}
}
private static async Task<TerminalState> ReadStateAsync(string path) private static async Task<TerminalState> ReadStateAsync(string path)
{ {
var encrypted = await File.ReadAllBytesAsync(path); var encrypted = await File.ReadAllBytesAsync(path);
var clear = ProtectedData.Unprotect(encrypted, Entropy, DataProtectionScope.CurrentUser); var clear = ProtectedData.Unprotect(encrypted, Entropy, DataProtectionScope.CurrentUser);
return JsonSerializer.Deserialize<TerminalState>(clear, JsonOptions) var state = JsonSerializer.Deserialize<TerminalState>(clear, JsonOptions)
?? throw new InvalidDataException("Файл состояния не содержит данных."); ?? 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) private static bool MigrateFingerprints(TerminalState state)
@ -101,7 +162,7 @@ public sealed class TerminalStore
return changed; return changed;
} }
public async Task SaveAsync(TerminalState state) public async Task SaveAsync(TerminalState state, bool createRecoveryPoint = false)
{ {
await _saveGate.WaitAsync(); await _saveGate.WaitAsync();
try try
@ -124,8 +185,25 @@ public sealed class TerminalStore
stream.Flush(true); 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); File.Move(temporaryPath, _filePath, true);
MarkInitialized();
if (previousStatePath is not null)
{
try
{
RotateBackups(previousStatePath);
}
finally
{
File.Delete(previousStatePath);
}
}
} }
finally 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() private IEnumerable<string> BackupPaths()
{ {
for (var index = 1; index <= 3; index++) yield return _filePath + $".bak{index}"; for (var index = 1; index <= 3; index++) yield return _filePath + $".bak{index}";
} }
private void RotateBackups() private void RotateBackups(string previousStatePath)
{ {
var backups = BackupPaths().ToArray(); var backups = BackupPaths().ToArray();
for (var index = backups.Length - 1; index > 0; index--) 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(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() private static string? FindLegacyPackagedState()

View file

@ -1,30 +1,52 @@
services: services:
app: app:
build: . build: .
env_file: .env
environment: 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 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'] ports: ['8080:8080']
depends_on: depends_on:
postgres: { condition: service_healthy } migrate: { condition: service_completed_successfully }
restart: unless-stopped restart: unless-stopped
read_only: true read_only: true
tmpfs: [/tmp] tmpfs: [/tmp]
security_opt: ['no-new-privileges:true'] 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 } build: { context: ., target: build }
env_file: .env
environment: environment:
DATABASE_MIGRATION_URL: postgres://krona_migrator:${POSTGRES_MIGRATION_PASSWORD:-local-migration-only}@postgres:5432/krona_staff 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'] POSTGRES_APP_PASSWORD: ${POSTGRES_APP_PASSWORD:-local-app-only}
command: ['node', 'scripts/create-app-role.mjs']
depends_on: depends_on:
postgres: { condition: service_healthy } 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: postgres:
image: postgres:17-alpine@sha256:18cfe3ef5e6815560c98237d6216d1e5119702fb0f3894c8785dd58b8bbe5d73 image: postgres:17-alpine@sha256:18cfe3ef5e6815560c98237d6216d1e5119702fb0f3894c8785dd58b8bbe5d73
environment: environment:
POSTGRES_DB: krona_staff POSTGRES_DB: krona_staff
POSTGRES_USER: krona_migrator POSTGRES_USER: ${POSTGRES_MIGRATION_USER:-krona_migrator}
POSTGRES_PASSWORD: ${POSTGRES_MIGRATION_PASSWORD:-local-migration-only} POSTGRES_PASSWORD: ${POSTGRES_MIGRATION_PASSWORD:-local-migration-only}
POSTGRES_APP_PASSWORD: ${POSTGRES_APP_PASSWORD:-local-app-only} POSTGRES_APP_PASSWORD: ${POSTGRES_APP_PASSWORD:-local-app-only}
volumes: volumes:
@ -32,7 +54,8 @@ services:
- ./docker/postgres/init-app-role.sh:/docker-entrypoint-initdb.d/10-app-role.sh:ro - ./docker/postgres/init-app-role.sh:/docker-entrypoint-initdb.d/10-app-role.sh:ro
healthcheck: 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, interval: 5s,
timeout: 3s, timeout: 3s,
retries: 20, 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. 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. 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 ## 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 ## 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. 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 { readFile, readdir } from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import pg from 'pg'; import pg from 'pg';
import {
assertAppliedMigrationsPresent,
assertMigrationChecksum,
migrationChecksum,
} from './migration-checksum.mjs';
const direction = process.argv[2]; const direction = process.argv[2];
if (direction !== 'up' && direction !== 'down') { if (direction !== 'up' && direction !== 'down') {
@ -28,27 +33,50 @@ try {
await client.query(` await client.query(`
CREATE TABLE IF NOT EXISTS schema_migrations ( CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY, version TEXT PRIMARY KEY,
checksum TEXT,
applied_at TIMESTAMPTZ NOT NULL DEFAULT now() applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
) )
`); `);
await client.query('ALTER TABLE schema_migrations ADD COLUMN IF NOT EXISTS checksum TEXT');
if (direction === 'up') { if (direction === 'up') {
const appliedResult = await client.query('SELECT version FROM schema_migrations'); const appliedResult = await client.query('SELECT version, checksum FROM schema_migrations');
const applied = new Set(appliedResult.rows.map((row) => String(row.version))); 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) { for (const file of files) {
const version = file.replace(/\.up\.sql$/, ''); const version = file.replace(/\.up\.sql$/, '');
if (applied.has(version)) continue; const content = await readFile(path.join(migrationsDirectory, file));
const sql = await readFile(path.join(migrationsDirectory, file), 'utf8'); 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 applyTransaction(async () => {
await client.query(sql); 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}`); console.log(`applied ${file}`);
} }
} else { } else {
const requestedVersion = process.argv[3]; const requestedVersion = process.argv[3];
const latestResult = await client.query( 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; const latestVersion = latestResult.rows[0]?.version;
if (!latestVersion) { if (!latestVersion) {
@ -60,6 +88,18 @@ try {
); );
} }
const downFile = `${latestVersion}.down.sql`; 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'); const sql = await readFile(path.join(migrationsDirectory, downFile), 'utf8');
await applyTransaction(async () => { await applyTransaction(async () => {
await client.query(sql); 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'), LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace', 'silent']).default('info'),
STAFF_RUNTIME_MODE: z.enum(['standalone', 'platform']).default('standalone'), STAFF_RUNTIME_MODE: z.enum(['standalone', 'platform']).default('standalone'),
DATABASE_URL: z.string().url(), DATABASE_URL: z.string().url(),
REDIS_URL: z.string().url().optional(), REDIS_URL: optionalUrl(),
PLATFORM_GATEWAY_URL: z.string().url().optional(), PLATFORM_GATEWAY_URL: optionalUrl(),
PLATFORM_JWT_ISSUER: z.string().url().optional(), PLATFORM_JWT_ISSUER: optionalUrl(),
PLATFORM_JWT_AUDIENCE: z.string().optional(), PLATFORM_JWT_AUDIENCE: emptyStringAsUndefined(z.string().optional()),
PLATFORM_JWKS_URL: z.string().url().optional(), PLATFORM_JWKS_URL: optionalUrl(),
STANDALONE_TENANT_ID: z.string().regex(/^[0-9A-HJKMNP-TV-Z]{26}$/), STANDALONE_TENANT_ID: z.string().regex(/^[0-9A-HJKMNP-TV-Z]{26}$/),
STANDALONE_ACTOR_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 type AppConfig = z.infer<typeof configSchema>;
export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig { 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( expect(() => loadConfig({ ...base, NODE_ENV: 'production' })).toThrow(
/standalone is not allowed/, /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/,
);
});
});