Audyt offline kiosku + plan hardeningu (etap-29 — propozycja)¶
W skrócie
Kiosk NIE „działa offline" jako całość. Dziś offline działają tylko: logowanie PIN/RFID (cache) i odbiór paczki (cache + outbox). Nadanie, wydanie, uzupełnianie, inwentaryzacja — twardo padają bez internetu. Fizyczne otwarcie skrytki (agent SmartConf 127.0.0.1:8800) działa offline dla wszystkich flow — problemem jest warstwa danych, nie sprzęt. Audyt znalazł realne dziury już w obecnym offline (utrata nadania przy migającym łączu, brak autoryzacji przy replay) oraz krytyczne wektory nadużyć, gdyby naiwnie dorobić offline do wydania (podwójne wykorzystanie limitu) i inwentaryzacji (nadpisanie świeższego stanu starym snapshotem).
1. Stan faktyczny — co działa offline¶
| Funkcja | Offline dziś? | Mechanizm |
|---|---|---|
| Logowanie PIN/RFID | ✅ TAK | bcrypt na cached_credentials (offline_repo.dart:48-102); sieć tylko przy cache-miss |
| Odbiór paczki (pickup) | ✅ TAK | czyta cached_shipments (DEPOSITED), otwiera lokalnym agentem, buforuje pickup.completed do outboxa, replay przy powrocie sieci (pickup_screen.dart:324-348, offline_repo.dart:298-316) |
| Fizyczne otwarcie skrytki (wszystkie flow) | ✅ TAK | Job API do agenta SmartConf 127.0.0.1:8800 (smartconf_hardware.dart:28) — bez internetu |
| Nadanie paczki (deposit) | ❌ NIE | serwer alokuje tracking+skrytkę w transakcji; deposit.completed w outboxie to martwy kod; „online-only by design" (etap-4-offline-guarantees.md:44) |
| Wydanie (dispense) | ❌ NIE | /dispense/me/catalog\|issue\|confirm — wszystko online; cache nie trzyma katalogu/limitów/stanu (dispense_screen.dart:280-298,435-456) |
| Uzupełnianie (replenish) | ❌ NIE | /dispense/me/replenish* online; brak cache produktów/slotów |
| Inwentaryzacja | ❌ NIE | /dispense/me/inventory* online; brak cache stanu |
Brak warstwy łączności. Nie ma globalnej flagi online/offline ani health-pingu — „offline" = ostatnie żądanie HTTP rzuciło wyjątkiem (sync_service.dart:72-99). SyncStatus jest tylko do wyświetlenia (banner); żaden ekran nie rozgałęzia logiki po nim — każdy flow robi własne try/catch. Błąd 500/401 wygląda identycznie jak offline → np. zrotowany klucz API (401) cicho przełącza login na cache.
Co jest w cache (SQLite C:\smartbox-kiosk\kiosk-cache.sqlite): users, credentials (hash PIN/RFID), lockery tej maszyny, tylko DEPOSITED shipments, outbox, kursor. Czego NIE ma: katalog/itemy, limity/reguły, defaultDeny, stan/StockSlot/StockUnit, dokumenty/akceptacje, taski, config/szablon. To przesądza, że wydanie/uzup./inwent. są offline niemożliwe (local_db.dart:74-141, machine-sync.service.ts:48-131).
Sync/replay: GET /machines/me/sync zwraca pełny snapshot (param since ignorowany), tombstone-sweep mirroruje cache do serwera. POST /machines/me/replay obsługuje realnie tylko pickup.completed (idempotentny przez WHERE status='DEPOSITED', ale bez klucza idempotencji/event-id); deposit.completed istnieje na backendzie, ale kiosk go nigdy nie wysyła.
2. Założenia projektowe (cytaty)¶
- „Backend remains the source of truth — anything the kiosk does offline is a local approximation that gets reconciled on the next sync tick." (
etap-4-offline-guarantees.md:3) - Nadanie offline = „rejected — kiosk shows network error. Couldn't allocate a tracking ID without server" (
:44); „Sender flow is online-only today; format reserved for etap 7" (:28). - „Honor the kiosk's clock so the audit trail reflects when the user took it" (
machine-sync.service.ts:222-223) — źródło problemu z czasem (niżej).
Czyli obecny offline = read-only auth + odbiór, świadomie. Implementacja jest zgodna z intencją — dziury (niżej) to niewypowiedziane konsekwencje zaufania zegarowi kiosku i kluczowi maszyny przy zapisie.
3. Dziury JUŻ w obecnym offline (do załatania niezależnie od rozbudowy)¶
| # | Problem | Sev | Dowód |
|---|---|---|---|
| A1 | Brak autoryzacji przy replay. Serwer ufa każdemu z kluczem maszyny: pickup.completed{shipmentId} → flip DEPOSITED→PICKED_UP, zwolnij skrytkę, powiadom odbiorcę. Offline bcrypt nigdy nie dochodzi do serwera; payload nie wiąże odbiorcy. Wyciek klucza = oznaczanie paczek odebranymi bez wydania. |
HIGH | machine-sync.service.ts:194-233, machine-sync.dto.ts:20-30 |
| A2 | Nadanie ginie przy migającym łączu (nawet online!). POST /shipments/:id/deposited jest bezpośrednie, nie buforowane. Jeśli łącze padnie między zamknięciem drzwi a POST-em → paczka fizycznie w skrytce, ale shipment zostaje ASSIGNED, skrytka RESERVED „na zawsze" (do workera wygaśnięcia → osierocona paczka). |
HIGH | deposit_size_screen.dart:160, deposit_lookup_screen.dart:318 |
| A3 | Ghost-pickup ze starego cache. Offline odbiór z 2-min-starego cache może otworzyć skrytkę dla paczki już odebranej/anulowanej gdzie indziej; replay→CONFLICT→cicho kasuje wpis; brak alertu w panelu. | MED-HIGH | offline_repo.dart:151-166, sync_service.dart:255 |
| A4 | Offline odbiór ignoruje BROKEN. Zapytanie offline nie czyta cached_lockers.status → próbuje otworzyć skrytkę, którą backend zna jako zepsutą. + report-failed nie jest buforowane (ginie offline). |
MED | offline_repo.dart:151-166, pickup_screen.dart:243,305 |
| A5 | Wygaśnięcie nieegzekwowane offline. expiresAt w cache, ale niefiltrowane → przeterminowana paczka oferowana; replay→CONFLICT, a user fizycznie ją wziął → odbiór zgubiony po stronie serwera. |
MED | offline_repo.dart:151-158 |
| A6 | Zegar kiosku bez walidacji. Replay zapisuje pickedUpAt/depositedAt = new Date(occurredAt) z niezaufanego zegaru kiosku (tylko format ISO walidowany). Backdate (RTC bez NTP w polu) psuje audyt i ewentualne okna czasowe. Sprzeczne z własną zasadą projektu „czas liczony serwerowo" (prod_resilience). |
MED | machine-sync.service.ts:224,262 |
| A7 | Awaria pull blokuje drenaż outboxa + parowanie wyników replay jest pozycyjne (kruche). Revoked user loguje się offline do następnego udanego synca. | LOW-MED | sync_service.dart:73-77,252 |
4. Wektory nadużyć, GDYBY dorobić offline (rdzeń obaw klienta „wyda więcej")¶
4a. Wydanie offline — najgorszy obszar¶
| # | Wektor | Sev | Wykrywalne przy replay? | Odzyskiwalne? |
|---|---|---|---|---|
| B1 | Podwójne wykorzystanie limitu. usedQty liczone na żywo z księgi Movement (nie ma licznika). Offline na starym snapshocie: user wybiera cały limit, a re-login/reboot/kolejny odczyt katalogu resetuje pozorny zapas → wyda 2× limit. Replay nie sprawdza limitu (żaden handler, createMovement nie woła silnika). |
CRITICAL | NIE (dziś) | NIE — towar fizycznie wyszedł |
| B2 | Default-deny otwiera się (fail-open). Offline brak DispenseSetting.defaultDeny → naiwny kod = „brak reguły = bez limitu". Org z defaultDeny=true cicho przechodzi w allow-all offline. |
HIGH | NIE | NIE |
| B3 | Cofnięte uprawnienie dalej wydaje. Snapshot katalogu zamraża ALLOW; cofnięcie grupy/reguły/usera offline nie działa do następnego synca. | HIGH | NIE | NIE |
| B4 | Przekroczenie stanu. Lokalny stan > fizyczny (po niezsynchronizowanej korekcie). createMovement odrzuca quantity<decrement — ale tylko jeśli replay idzie przez chokepoint; odrzucenie = towar już wzięty, brak rekordu. |
HIGH | TAK (jeśli przez createMovement) | NIE (fizycznie) |
| B5 | Obejście regulaminu offline (brak akceptacji w cache). | MED | NIE | — |
| B6 | Duplikat ruchu przy replay. | LOW | TAK | OK — idempotencyKey @unique + dedup w createMovement, jeśli kiosk generuje UUID lokalnie i replay idzie przez chokepoint |
Strukturalny problem: oba istniejące handlery replay (pickup/deposit) są bespoke i nie wołają ani silnika limitów, ani createMovement. Wydanie dorobione w tym stylu odziedziczy zero zabezpieczeń online. Nawet najlepszy wariant (replay przez createMovement) daje tylko reject-stanu + idempotencję — nie limit, bo bramka limitu żyje tylko w issue, które offline jest pomijane. Dowody: kiosk-dispense.service.ts:79,256,352, limit-engine.service.ts:166-189,204-222,455-461, inventory.service.ts:555-729.
4b. Inwentaryzacja offline — subtelna mina¶
| # | Ryzyko | Sev |
|---|---|---|
| C1 | Nadpisanie świeższego stanu starym snapshotem. applyStockTake pędzi slot do ABSOLUTNEJ policzonej wartości względem slot.quantity w chwili replay, bez guardu wersji (inventory.service.ts:736-790, delta :771). Spis offline z T1, replay w T2 → kasuje każde wydanie/uzup. między T1 a T2 (wskrzesza widmowe sztuki). Klucz idempotencji chroni tylko ten sam dokument, nie staleness. |
CRITICAL |
Uzupełnianie offline jest dużo bezpieczniejsze: RESTOCK jest relatywny (+delta), capacity re-clampowane serwerowo (CAPACITY_EXCEEDED), idempotentne — LOW, pod warunkiem że idempotencyKey jest zapisany w outboxie przy enqueue (nie regenerowany).
5. Zasada, która z tego wynika¶
Offline-bezpieczne są operacje, które są: (a) odczytami z cache, albo (b) zapisami RELATYWNYMI + idempotentnymi, re-walidowanymi serwerowo przy replay przez createMovement. Niebezpieczne są zapisy ABSOLUTNE (inwentaryzacja) i zapisy limitowane kwotą (wydanie) — bo bramka działa na stanie, którego kiosk offline nie widzi autorytatywnie, a akcja fizyczna jest nieodwracalna (towar wyszedł / drzwi otwarte). Wniosek: offline wydanie da się tylko OGRANICZYĆ (twardy budżet = remaining z ostatniego synca, fail-closed) i AUDYTOWAĆ (flaga over-limit), nigdy idealnie zapobiec.
6. Plan hardeningu — fazami, z oceną ryzyka zmiany¶
| Faza | Zakres | Ryzyko zmiany | Wartość |
|---|---|---|---|
| 0. Łatki istniejącego | A2 (buforuj deposit.completed/markDeposited), A1 (event-id + actor + autoryzacja na replay), A4/A5 (offline odbiór: nie oferuj przeterminowanych, nie otwieraj BROKEN, buforuj report-failed), A6 (waliduj zakres occurredAt), A3 (panel konfliktów offline), rozróżnij 5xx/401 od offline + timeouty |
LOW | WYSOKA — to bugi, robić niezależnie |
| 1. Fundament łączności | realny sygnał online/offline (health-ping + last-success), świadome bramki offline zamiast przypadkowego try/catch, persisted offline-state | LOW | bazа pod resztę |
| 2. Nadanie offline | lokalna alokacja: leasing puli skrytek/tracking dla maszyny online → offline rezerwuje z cache FREE + buforuje deposit.begin/completed; replay waliduje że skrytka dalej FREE serwerowo (inaczej CONFLICT → operator) |
MED (wyścig rezerwacji) | średnia |
| 3. Wydanie offline | cache: katalog+ceny+dokumenty, snapshot uprawnień/limitów (remaining/limitQty/reset), defaultDeny, stan per-slot. Twardy lokalny budżet = remaining z synca, trwały licznik offline per (user,item) przeżywający reboot (zamyka B1 do „najwyżej legalnie pozostały zapas"). Fail-closed gdy brak/stary snapshot polityki (B2). Replay przez createMovement z UUID kiosku + nowy handler re-uruchamiający limit.evaluate i flagujący over-limit (audyt, bo fizyczne nieodwracalne). Akceptacja resztkowego ryzyka (B3 ograniczone świeżością synca). |
HIGH (złożoność + resztkowe ryzyko) | wysoka, ale ryzykowna |
| 4. Uzupełnianie offline | buforuj replenish.confirm z idempotencyKey zapisanym przy enqueue; replay przez createMovement (RESTOCK relatywny, capacity re-clamp) |
LOW-MED | średnia |
| 5. Inwentaryzacja offline | wymóg: optimistic concurrency — nieś expectedQty baseline per linia; applyStockTake odrzuca/flaguje linie gdzie slot.quantity dryfnął od snapshotu (C1). Bez tego guardu NIE buforować. |
MED-HIGH (zależne od OCC) | średnia |
Rekomendacja kolejności: Faza 0 i tak (to produkcyjne bugi, w tym dwa nieprzyjemne: A2 gubi nadane paczki, A1 = brak autoryzacji replay). Potem decyzja jak daleko: 1→2→4 są względnie bezpieczne; 3 (wydanie) i 5 (inwentaryzacja) niosą nieusuwalne ryzyko i wymagają świadomej akceptacji + dedykowanych zabezpieczeń (budżet/fail-closed/OCC).
7. Decyzja do podjęcia¶
„Pełny offline jak przesyłki" dla wydania jest możliwy tylko jako ograniczony budżet + audyt over-limit (nie da się zagwarantować nieprzekroczenia limitu, bo towar fizycznie wychodzi przy stale cache). Trzeba świadomie wybrać apetyt na ryzyko: gdzie się zatrzymujemy (0 → 2 → 3 → 5).