Przejdź do treści

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).

Powiązane