fix: harden terminal recovery and deployment
Some checks are pending
ci / verify (push) Waiting to run
Some checks are pending
ci / verify (push) Waiting to run
This commit is contained in:
parent
7c8e7fcaf1
commit
d45bee960a
20 changed files with 585 additions and 142 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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; } = "";
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
41
compose.yaml
41
compose.yaml
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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`.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
4
migrations/0003_app_role_privileges.down.sql
Normal file
4
migrations/0003_app_role_privileges.down.sql
Normal 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;
|
||||||
17
migrations/0003_app_role_privileges.up.sql
Normal file
17
migrations/0003_app_role_privileges.up.sql
Normal 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;
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
6
scripts/migration-checksum.d.mts
Normal file
6
scripts/migration-checksum.d.mts
Normal 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;
|
||||||
22
scripts/migration-checksum.mjs
Normal file
22
scripts/migration-checksum.mjs
Normal 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`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
});
|
});
|
||||||
|
|
|
||||||
27
tests/unit/deployment.test.ts
Normal file
27
tests/unit/deployment.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
27
tests/unit/migration-checksum.test.ts
Normal file
27
tests/unit/migration-checksum.test.ts
Normal 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/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue