This commit is contained in:
2026-07-15 15:48:10 +03:00
parent 128e80cea4
commit 44ef251fc0

View File

@@ -1,755 +0,0 @@
<!-- /autoplan restore point: ~/.gstack/projects/ROACONT/main-autoplan-restore-20260707-114014.md -->
# PRD — Obținere automată token OAuth2 ANAF eFactura (fără copy/paste)
**Data:** 2026-07-07
**Proiect:** ROACONT (ERP contabilitate, Visual FoxPro) + site romfast.ro (PHP)
**Status:** Draft
## 1. Problemă
Funcționalitatea eFactura SPV necesită un token OAuth2 emis de ANAF (`logincert.anaf.ro`).
Obținerea unui token NOU cere autentificare mutual-TLS cu certificatul calificat de pe
tokenul USB, care funcționează doar într-un browser real (utilizatorul introduce PIN-ul
semnăturii electronice). Fluxul actual:
1. ROACONT (`anaf_efactura.prg``newToken`) deschide browserul pe `https://romfast.ro/oauth2/`.
2. `index.php` redirecționează la ANAF authorize; ANAF apelează înapoi callback-ul cu `?code=...`.
3. `index.php` schimbă codul pe token și **afișează Access Token + Refresh Token în pagină**.
4. Utilizatorul **copiază manual** cele două valori și le lipește în formularul `anaf_token` din ROACONT.
Pasul 4 este sursă de erori (trunchieri, spații, confuzie între cele două tokenuri) și
o experiență proastă pentru utilizatori. Refresh-ul tokenului (fără browser) funcționează
deja corect și NU se modifică.
**Variante respinse:**
- Apel direct `/authorize` din VFP cu `WinHttp.SetClientCertificate` — testat anterior,
probleme la citirea certificatelor de pe tokenurile USB (drivere capricioase).
- WebView2 încorporat în VFP — runtime-ul e azi preinstalat pe Windows 10/11 (via Edge),
dar integrarea cere wrapper ActiveX/OLE per stație și tratarea dialogului de certificat
în control; efort și suprafață de suport disproporționate față de varianta aleasă.
- Protocol handler custom (`roacont://`) — setup registry per stație, prompt browser, fragil.
- **Token broker pe romfast.ro** (serverul păstrează refresh token-urile per client și
servește access token-uri la cerere; zero gestiune de token în aplicație, sincronizare
multi-stație automată) — respins CA SCOPE AICI: vendorul ar deveni custode al accesului
la datele fiscale ale clienților (răspundere, GDPR, consimțământ contractual). Decizie
de business, nu tehnică. Arhitectura aleasă (serverul e deja intermediar la schimbul
code→token) lasă ușa deschisă pentru broker mai târziu. Mutat în TODOS ca direcție
de evaluat comercial.
**Încadrare:** funcționalitatea e igienă/paritate competitivă (aplicațiile cloud nu au
deloc problema; competitorii desktop rezolvă similar), nu diferențiator. Beneficiul
măsurabil: reducere tichete suport pe subiectul "token" și un motiv de churn în minus.
## 2. Soluție aleasă
**Browser + polling cu identificator de sesiune (`state`).** Browserul se deschide în
continuare (necesar pentru PIN-ul certificatului), dar tokenurile nu mai sunt afișate:
serverul romfast.ro le stochează temporar sub un ID unic generat de ROACONT, iar ROACONT
le ridică automat prin polling. Utilizatorul doar introduce PIN-ul și revine în aplicație.
### Flux nou
```
ROACONT romfast.ro/oauth2/ ANAF
| | |
|-- genereaza STATE (unic, secret) ->| |
|-- deschide browser ?state=STATE -->| |
| |-- redirect authorize&state --->|
| | [salveaza state in SESIUNE] |
| | (utilizator: PIN) |
| |<-- callback ?code[&state?] ----|
| | [state OPTIONAL in callback; |
| | legarea reala = sesiunea PHP] |
| |-- POST code -> token --------->|
| |<-- access+refresh token -------|
| | salveaza STATE.json in |
| | oauth2_tokens/ (extra-docroot) |
| | afiseaza "Autorizare reusita" |
|-- polling pick.php?state=STATE --->| |
|<-- JSON tokens (apoi sterge) ------| |
| SaveToken() in baza de date | |
```
## 3. Cerințe funcționale
### 3.0 Faza 0 — Spike de validare ANAF (~1 oră, ÎNAINTE de restul implementării)
Se adaugă DOAR propagarea `state` + sesiunea + logarea în `index.php` (fără `pick.php`,
fără modificări VFP) și se rulează o generare reală de token cu certificat + tokenul USB.
Se verifică empiric: (a) ANAF acceptă parametrul `state` la authorize fără eroare;
(b) ANAF îl returnează sau nu în callback; (c) cookie-ul de sesiune supraviețuiește
redirectului (SameSite). Rezultatul decide detaliile FR-2 înainte de a construi restul.
### 3.1 Server PHP (`D:\PROIECTE\SITE_ROMFAST_2025\oauth2\`)
**FR-1 — `index.php`: propagare `state`.** La intrarea inițială (fără `code`), dacă
există parametrul `state`, acesta se adaugă la URL-ul de authorize ANAF (parametru
standard OAuth2, returnat neschimbat în callback).
**FR-2 — `index.php`: legare prin sesiune (PRIMAR).** Verificare 2026-07-07: documentul
oficial ANAF (Oauth_procedura, pag. 23) instruiește „State se lasă necompletat" — nu ne
putem baza pe propagarea `state` de către ANAF. Prin urmare **sesiunea PHP (cookie) este
mecanismul primar de legare**: la intrarea inițială `state` se salvează în sesiune
(`$_SESSION['roa_state']`); la callback, identificatorul se ia din sesiune. Dacă ANAF
returnează totuși `state` în callback, acesta se folosește suplimentar ca validare CSRF
(trebuie să coincidă cu cel din sesiune; nepotrivire → eroare, fără stocare de tokenuri).
Primul test real logează empiric dacă ANAF propagă `state` (vezi planul de teste).
**Cerință cookie:** callback-ul ANAF e o navigare top-level dintr-un alt domeniu —
cookie-ul de sesiune trebuie emis cu `SameSite=Lax; Secure`
(`session_set_cookie_params(['samesite' => 'Lax', 'secure' => true])`), altfel cu
`SameSite=Strict` legarea pică silențios și fluxul cade mereu pe timeout.
Sintaxa cu array cere **PHP ≥ 7.3** — versiunea se verifică la spike (Faza 0); sub 7.3,
fallback documentat `session_set_cookie_params(0, '/; SameSite=Lax', ...)`. Apelul
precede `session_start()` pe AMBELE intrări (inițială și callback).
**Igienă sesiune (anti fund-de-sac):**
- `roa_state` se stochează cu timestamp și se IGNORĂ dacă e mai vechi de 15 minute;
- la intrarea inițială FĂRĂ `state` (fluxul manual/fallback), `unset($_SESSION['roa_state'])`
explicit — altfel o sesiune rămasă de la o tentativă automată eșuată ar redirecționa
fallback-ul manual pe pagina de succes fără tokenuri (fund de sac);
- la intrarea CU `state` când există deja un `roa_state` proaspăt neconsumat (altă
instanță ROACONT în același browser — două firme deschise simultan), pagina afișează
avertisment „O altă generare de token este în curs dintr-o altă fereastră a aplicației.
Finalizați-o pe aceea sau reluați peste câteva minute." și NU suprascrie tăcut.
**FR-3 — `index.php`: stocare tokenuri.** La callback cu `code` + `state` cunoscut,
după schimbul code→token, răspunsul JSON de la ANAF se salvează în directorul de
tokenuri (conform FR-11: extra-docroot, ex. `../oauth2_tokens/<state>.json`) și pagina
afișează doar un mesaj de succes ("Autorizare reușită. Reveniți în aplicația ROA.
Puteți închide această pagină."), fără a mai afișa tokenurile. Dacă schimbul eșuează,
se salvează în același director `{"error": "..."}` ca ROACONT să afle imediat, și
pagina afișează eroarea (fără a suprascrie un fișier de succes existent — FR-10).
**FR-4 — `index.php`: compatibilitate retro.** Fără `state` (versiuni vechi ROACONT),
comportamentul actual rămâne neschimbat: tokenurile se afișează în pagină pentru
copiere manuală. Ramura `refresh_token` rămâne neatinsă.
**FR-5 — `pick.php` (nou).** Endpoint de polling; clientul trimite `state` prin
**POST body** (nu în query string — nu ajunge în access-log-urile hostingului);
GET rămâne acceptat pentru diagnosticare, cu răspunsuri identice:
- `state` valid + fișier existent → „claim" atomic prin `rename()` către un nume
temporar propriu, apoi citire + ștergere (citire unică; pickup-uri concurente au
un singur câștigător), răspuns JSON, status 200.
- fișier inexistent → **status 200** cu `{"status":"pending"}` (NU 404: pe shared
hosting, lanțuri Apache/WAF pot înlocui corpul 404-urilor cu pagini custom, iar
clientul ar declara eronat „pick.php lipsește"). Răspunsul pentru „state necunoscut"
și „încă negenerat" este IDENTIC (fără oracle de existență). HTTP 404 real rămâne
astfel semnalul natural pentru „pick.php nedesfășurat" (M10).
- `state` invalid ca format → 400, fără acces la disc; validarea formatului se face
ÎNAINTE de orice logare (anti log-injection: nimic din state-urile invalide nu se
scrie în log neescapat; prefixul logat se sanitizează la `[A-Za-z0-9]`).
- `pick.php` NU apelează `session_start()` (evită serializarea pe lock-ul de sesiune).
- La fiecare apel, curăță fișierele de tokenuri mai vechi de 10 minute (TTL).
**FR-10 — Curățare securitate `index.php` (în aceeași livrare).**
- Eliminare `parse_str()` peste query string (injecție de variabile); citire explicită
`$_GET['refresh_token']`, `$_GET['code']`, `$_GET['state']`, `$_GET['error']`.
- `client_secret` NU se mai trimite în query-ul URL-ului de authorize (ajunge în istoricul
browserului fiecărui client și în log-uri; OAuth2 nu îl cere la authorize).
- Tratare explicită callback de eroare ANAF (`?error=...`): pagină de eroare + scriere
`{"error":...}` pentru state-ul sesiunii (fără buclă de re-redirect la authorize).
- Tratare erori curl / status HTTP / JSON de eroare de la ANAF la schimbul code→token
(azi un eșec afișează tokenuri goale).
- Regulă anti-suprascriere: un rezultat de eroare NU suprascrie un fișier de tokenuri
deja scris pentru același `state` (cazul refresh pe pagina de callback după succes).
- Arhivare/eliminare fișiere moarte din `oauth2/`: `index1.php`, `index2.php`, `info.php`
(expunere `phpinfo()`).
**FR-11 — Protecție și observabilitate `pick.php` + stocare.**
- Fișierele de tokenuri se stochează ÎN AFARA docroot-ului (ex. `../oauth2_tokens/`);
dacă hostingul nu permite, fallback: director în docroot cu `.htaccess` deny all.
- **Nume de fișier = `hash('sha256', state)` ÎNTOTDEAUNA** (nu doar în fallback) —
secretul nu apare în listing-uri de directoare sau backup-uri de hosting.
- **Scriere atomică**: callback-ul scrie în `<hash>.json.tmp` apoi `rename()` (atomic
pe același filesystem) — pick.php nu poate citi niciodată un JSON parțial scris.
- Fișierul JSON include și **IP-ul callback-ului**; la pickup, nepotrivirea IP pickup
vs IP callback se LOGHEAZĂ (nu se blochează — proxy/VPN split ar produce false
pozitive), ca semnal forensic pentru un eventual pickup furat.
- Rate-limit per IP pe `pick.php`: **max 240 cereri/min/IP** → 429 (headroom pentru
birouri cu mai multe stații după același NAT: un client la 2s = 30 req/min; 240
acoperă 8 stații simultane). ROACONT trece la 5s după un 429 și rămâne la 5s până
la finalul fluxului curent. Contorul per IP: fișiere per-minut per-IP în directorul
extra-docroot (shared hosting nu garantează APCu); curățate de același TTL.
- Log de evenimente: fișier text pe server cu numele lunar `oauth2_events_YYYYMM.log`
(rotire implicită prin numele fișierului — fără cron): timestamp, IP, eveniment
(`authorize_start`, `callback_ok`, `callback_error`, `pickup_ok`, `pickup_pending`,
`pickup_ip_mismatch`, `rate_limited`, `ttl_cleanup`), prefix 8 caractere sanitizat
din `state`. **Niciodată valori de tokenuri.** Contoare zilnice succes/eroare/timeout.
- Schimbul code→token din `index.php`: `CURLOPT_CONNECTTIMEOUT=10`, `CURLOPT_TIMEOUT=30`
și `session_write_close()` înainte de apelul curl (nu ținem lock-ul de sesiune cât
răspunde ANAF).
### 3.2 Client VFP (`D:\ROA\ROACONT\COMUN\programe\anaf_efactura.prg`)
**FR-6 — Generare `state`.** Funcția `newToken` (ambele clase: `AnafeFacturaServer`
~linia 505 și `ANAFeFactura` ~linia 2724) generează un identificator unic cu minim
128 biți de entropie criptografică. **Obligatoriu prin API Windows**: două GUID-uri
generate cu `CoCreateGuid` concatenate și normalizate la `[A-Za-z0-9]`. Declarația
exactă (ca implementarea să nu alunece înapoi spre SYS(2015)):
```foxpro
DECLARE INTEGER CoCreateGuid IN ole32 STRING @pguid
lcBuf = REPLICATE(CHR(0), 16)
CoCreateGuid(@lcBuf) && de 2 ori; cele 32 de octeți se convertesc hex → 64 caractere
```
`SYS(2015)` este INTERZIS ca sursă de entropie (derivat din timestamp, predictibil) —
`state` e singurul secret care protejează tokenurile la `pick.php`.
**FR-7 — Deschidere browser + polling.** După `open_default_app(url + '?state=' + state)`,
ROACONT intră în buclă de polling pe `pick.php` (cu `state` în POST body — FR-5)
folosind `WinHttp.WinHttpRequest.5.1` (fallback `MSXML2.ServerXMLHTTP.6.0` — vezi
punctul despre timeout-uri):
- interval: 2 secunde (5s după un răspuns 429, rămâne 5s până la finalul fluxului);
**timeout total: 10 minute**, aliniat cu TTL-ul serverului (PIN greșit, dialog
selecție certificat, lentoare ANAF — utilizatorii nu sunt tehnici; 3 minute ar fi
subdimensionat);
- **timeout-uri HTTP explicite per iterație**: `loHTTP.SetTimeouts(5000,5000,5000,5000)`
— fără ele, default-urile WinHttp (30-60s) îngheață Esc-ul și WAIT WINDOW la o rețea
căzută; fallback-ul este `MSXML2.ServerXMLHTTP.6.0` (are `setTimeouts`), NU
`MSXML2.XMLHTTP.6.0` (nu are);
- **helper HTTP cu erori garantat prinse**: TRY imbricat și pentru transportul de
fallback (pattern-ul din refreshToken lasă excepția din CATCH nehandled), iar flag-ul
gărzii de reintrare se resetează garantat (FINALLY) — altfel un crash în buclă lasă
aplicația blocată pe M8 până la restart;
- clientul apelează `pick.php` cu `state` în **POST body** (FR-5 — nu în query string);
- feedback vizual: `WAIT WINDOW ... NOWAIT` cu mesaje PROGRESIVE (M2 la start, M3 după
90s, M4 după 5 min, cu minutele scurse — texte finale în §3.3; fereastra statică
10 minute ar arăta a aplicație blocată); verificare `INKEY(0.5)` (Esc <1s);
- gardă de reintrare (flag flux în curs" M8) și verificare rezultat
lansării browserului (eșec M9 + fallback manual) printr-un **wrapper local** în
`anaf_efactura.prg` care returnează `ShellExecute(...) > 32` funcția globală
`open_default_app` (oexport.prg:302) NU returnează nimic, deci M9 ar fi
neimplementabil prin ea; helper-ul global (folosit în zeci de locuri) nu se atinge;
- **gardă de context interactiv**: implementarea unică stă în `AnafeFacturaServer.newToken`,
iar clasa `ANAFeFactura` DELEAGĂ (fără duplicare); în context non-interactiv/headless
(ROAEFACTURA server, `_SCREEN` invizibil) funcția refuză cu mesaj în log, fără browser
și fără WAIT WINDOW;
- erori de rețea tranzitorii per iterație se continuă polling-ul; **HTTP 404** repetat
(N5 consecutiv) `pick.php` nedesfășurat pe server (pending-ul legitim e 200 FR-5,
deci 404 e neambiguu) M10 + fallback manual;
- la succes: parsare cu `nfjsonread()`, populare `cToken`, `cRefreshToken`,
`dTokenGendate = DATE()`, `dTokenExpdate` din `expires_in`, apel `SaveToken()`,
mesaj de confirmare cu data expirării;
- la `{"error":...}` sau timeout: mesaj clar + **fallback pe fluxul vechi** (FR-8);
mesajul de timeout recomandă regenerarea tokenului dacă autorizarea în browser
păruse reușită (protecție împotriva unui pickup furat forensics în log server FR-11);
- **kill switch**: opțiune `ANAF_TOKEN_AUTOFLOW` (implicit 1); pe 0, `newToken` revine
la comportamentul actual (doar deschidere browser, completare manuală) rollback
fără redistribuire de exe. Citirea opțiunii e robustă la NULL/opțiune inexistentă/
Oracle indisponibil (`NVL(...)`, default 1 atenție: pattern-ul de la linia 462 nu
are NVL). Opțiunile fiind per firmă, kill switch-ul e **per firmă** (intenționat
permite dezactivare selectivă la un client cu probleme).
- **Comportament headless (clasa server / ROAEFACTURA) DECIS la poarta finală (D4)**:
în context non-interactiv, funcția unificată păstrează EXACT comportamentul actual
al `AnafeFacturaServer.newToken` (linia ~505): log cu instrucțiunile de completare
a tokenurilor în `gcGeneralIniFile` + deschiderea browserului. Fluxul automat nou
(polling) rulează DOAR în context interactiv (ROACONT).
- **Intrarea pe fluxul nou la expirarea refresh token-ului preluată de utilizator
(D3)**: legarea mesajului de eșec de refresh de apelul `newToken` o implementează
utilizatorul direct în VFP; NU face parte din acest plan.
**FR-8 — Fallback manual.** Dacă polling-ul eșuează (timeout, eroare rețea), utilizatorul
primește opțiunea de a relua fluxul vechi (mesajul actual + completare manuală în
formularul `anaf_token`). Formularul existent nu se elimină. **Detalii obligatorii:**
- Fallback-ul deschide URL-ul **FĂRĂ parametrul `?state`** doar așa `index.php`
afișează tokenurile pentru copiere (ramura retro-compat FR-4). Reluarea cu `state`
ar duce pe pagina de succes fără tokenuri vizibile = fund de sac.
- Mesajul de fallback avertizează explicit: va fi necesară reintroducerea PIN-ului
(codul de autorizare e single-use), iar codurile vor fi afișate pentru copiere manuală.
- Textele exacte: vezi §3.3 (tabelul de mesaje).
**FR-9 — Mesaj utilizator actualizat.** Mesajul inițial din `newToken` nu mai cere
copierea tokenurilor. Devine dialog **OK/Anulare** (nu OK-only ca azi utilizatorul
fără tokenul USB la îndemână trebuie poată renunța curat înainte de deschiderea
browserului) și menționează explicit pasul de **selecție a certificatului** (dialogul
Windows care sperie utilizatorii netehnici). Textul exact: vezi §3.3. Ghidarea
detaliată NU se încarcă toată în modalul inițial se mută în mesajele progresive
din fereastra de așteptare (FR-73.3).
### 3.3 Specificații UI/UX (texte finale și layout — Faza 2, design review)
**Regula diacriticelor (decisă):** mesajele VFP se scriu FĂRĂ diacritice (consistent
cu toată aplicația codepage 1250/852 corupe diacriticele; vezi mesajele existente
Introduceti", Copiati" la liniile 508-510/2725-2727). Paginile de browser (PHP) se
scriu CU diacritice + `<meta charset="utf-8">`.
**Gardă de reintrare:** flag flux în curs" pe clasă; re-apăsarea Token nou" în timpul
polling-ului afișează M8 fără a porni un al doilea flux.
**Eșec lansare browser:** rezultatul `open_default_app()` se verifică; la eșec
mesaj imediat M9 + fallback manual (fără a intra în polling).
**Kill switch (`ANAF_TOKEN_AUTOFLOW=0`):** se afișează mesajul VECHI, nemodificat
(cel cu instrucțiuni de copiere) aplicația nu promite preluare automată" când
pagina va afișa tokenuri de copiat.
**Tabelul mesajelor VFP (texte finale, fără diacritice):**
| # | Moment | Text | Butoane |
|---|--------|------|---------|
| M1 | Modal inițial (FR-9) | Se va genera un token nou eFactura.(CR)1. Introduceti tokenul USB cu semnatura electronica in calculator.(CR)2. Se va deschide pagina ANAF in browser: selectati certificatul dvs. si introduceti PIN-ul.(CR)3. Reveniti in aplicatie tokenul se preia automat, fara copiere manuala.(CR)(CR)Continuati?" | OK / Anulare |
| M2 | WAIT WINDOW 0-90s | Asteptare autorizare ANAF... Continuati in fereastra browserului: selectati certificatul si introduceti PIN-ul. (Esc = anulare)" | |
| M3 | WAIT WINDOW 90s-5min | Inca astept autorizarea ANAF (<N> min)... Nu a aparut fereastra de PIN? Verificati daca browserul s-a deschis (poate fi in spatele acestei ferestre). (Esc = anulare)" | — |
| M4 | WAIT WINDOW 5-10min | „Inca astept autorizarea ANAF (<N> min)... Daca nu reusiti, apasati Esc pentru anulare si completare manuala." | — |
| M5 | Timeout 10 min | „Nu s-a primit tokenul in 10 minute. Verificati ca tokenul USB este introdus si ca browserul a deschis pagina ANAF.(CR)Reluati generarea? (Va fi necesar PIN-ul din nou.)(CR)Da = reluare automata, Nu = completare manuala (se deschide pagina care afiseaza codurile)" | Da / Nu |
| M6 | Anulare Esc | „Ati anulat asteptarea. Daca ati introdus deja PIN-ul, autorizarea respectiva va fi ignorata; la reluare va trebui sa introduceti PIN-ul din nou." | OK |
| M7 | Succes | „S-a generat token-ul eFactura, valabil pana la <data>. Nu este necesara nicio copiere manuala." | OK |
| M8 | Reintrare in flux | „O generare de token este deja in curs. Asteptati finalizarea sau anulati-o cu Esc." | OK |
| M9 | Esec lansare browser | „Nu s-a putut deschide browserul. Se continua cu fluxul manual: se va deschide pagina care afiseaza codurile pentru copiere." | OK |
| M10 | pick.php absent (HTTP 404 ≥5) | „Componenta de server pentru preluarea automata nu este inca instalata. Se continua cu fluxul manual: copiati codurile din pagina de browser. Anuntati administratorul (actualizare site romfast.ro)." | OK |
| M11 | Esec SaveToken | „Tokenul a fost generat, dar salvarea in baza de date a esuat: <detaliu>. Reincercati salvarea?" — **Da = retry SaveToken** (tokenul e inca in memorie in `This.cToken`; reluarea PIN-ului ar fi disproportionata pentru un esec de scriere DB). Dupa 3 esecuri: „Salvarea nu a reusit. Reluati operatia Token nou (va fi necesar PIN-ul din nou) sau contactati suportul ROA." | Da / Nu |
| M12 | Eroare ANAF (din fisierul {"error"}) | vezi tabelul de traduceri de mai jos | OK |
**Traducerea erorilor ANAF în limbaj uman (M12):**
| Cod ANAF | Text VFP |
|---|---|
| `invalid_grant` / cod expirat | „Codul de autorizare a expirat (valabilitate ~1 minut). Reluati operatia Token nou." |
| `access_denied` | „Autorizarea a fost refuzata sau anulata in pagina ANAF. Reluati operatia daca doriti." |
| orice alt cod | „Eroare la autorizarea ANAF: <cod>. Reluati operatia sau contactati suportul ROA." |
**Layout pagini browser (PHP, UTF-8, cu diacritice):**
- Reguli comune: un H1 mare (scanabil dintr-o privire), o singură propoziție de corp,
font ≥16px, contrast înalt (text închis pe fundal deschis), fără linkuri, fără
butoane, fără carduri/decor; logo/numele ROA opțional, discret, sus. **Un singur
job per pagină.** Nu se promite auto-close (`window.close()` nu funcționează pe
taburi deschise de utilizator).
- **Succes:** H1 „Autorizare reușită" (verde). Corp: „Reveniți în aplicația ROA —
tokenul se preia automat. Puteți închide această pagină."
- **Eroare:** H1 „Autorizarea nu a reușit" (roșu). Corp: cauza pe scurt în limbaj
uman (o propoziție, fără jargon) + „Reveniți în aplicația ROA — de acolo puteți
relua operația sau folosi completarea manuală." **Fără link de reluare în browser**
(reluarea cere `state` nou generat de aplicație; un retry în browser ar produce
buclă de confuzie).
- Ramura retro-compat (fără `state`, FR-4): pagina actuală cu tokenurile afișate
rămâne neschimbată.
## 4. Cerințe non-funcționale / securitate
**NFR-1 — Entropie `state`.** Minim 128 biți de entropie; doar caractere `[A-Za-z0-9]`;
lungime fixă validată strict în PHP (protecție path traversal — `state` intră în nume
de fișier).
**NFR-2 — Fereastră de expunere minimă.** Tokenurile stau pe server doar între callback
și pick (secunde). Ștergere la prima citire + TTL 10 minute + stocare în afara
docroot-ului conform FR-11 (fallback: `.htaccess` deny all + nume fișier `hash(state)`).
**NFR-3 — HTTPS obligatoriu** pe toate apelurile (deja existent pe romfast.ro).
**NFR-4 — Fără logging de tokenuri** în PHP sau în log-ul ROACONT (`This.Log` nu
primește valorile tokenurilor, doar statusuri).
**NFR-5 — Non-blocant rezonabil.** Polling-ul din VFP nu blochează complet UI-ul
(WAIT WINDOW + INKEY permit anularea); nu se folosește `SLEEP` blocant fără ieșire.
## 5. În afara scope-ului
- Refresh-ul tokenului (funcționează, rămâne neschimbat).
- Apelurile API eFactura (upload, listare mesaje, descărcare) — neschimbate.
- Stocarea tokenurilor în ROACONT (`scrie_optiune`/`citeste_optiune`) — neschimbată.
- Rotația `client_id`/`client_secret` ANAF.
- Formularul `anaf_token` (rămâne neschimbat, ca fallback manual — e VCX binar, editabil
doar din IDE-ul VFP; butonul existent apelează deja `newToken`, deci fluxul nou nu
necesită modificări de formular).
## 6. Riscuri și mitigări
| Risc | Impact | Mitigare |
|---|---|---|
| ANAF nu returnează `state` în callback | Fără impact pe legare (aceasta e prin sesiune) | FR-2: legarea primară e sesiunea PHP (cookie, același browser face ambele request-uri); `state` e doar validare CSRF oportunistă |
| Utilizatorul are browser default cu profil fără certificat | Autorizarea eșuează în browser | Mesaj + fallback manual (FR-8); comportament identic cu azi |
| Două stații generează token simultan | Fișiere separate per `state`, fără coliziune | Entropia `state` (NFR-1) |
| Firewall/proxy blochează polling-ul | Timeout | FR-8 fallback manual (validat implicit: refreshToken face deja apeluri identice către romfast.ro) |
| Fișier de token orfan în `oauth2_tokens/` (utilizator abandonează) | Token valid rămas pe disc | TTL 10 min (FR-5) + ștergere la citire |
| Cookie de sesiune pierdut la redirectul cross-site (SameSite=Strict, browser agresiv) | Legarea primară pică silențios → timeout | `SameSite=Lax; Secure` explicit (FR-2) + verificat la spike (Faza 0) |
| Migrare hosting Apache→nginx ignoră `.htaccess` | Director de tokenuri devine public | Stocare în afara docroot-ului ca variantă principală (FR-11) |
| ANAF respinge parametrul `state` la authorize | Fluxul nou nu pornește | Spike Faza 0 detectează; fallback: flux doar pe sesiune, fără state în URL-ul ANAF |
| Link forjat cu state-ul atacatorului (CSRF de autorizare) | Victima autorizează, atacatorul culege tokenul | Risc rezidual acceptat: cere ca victima să parcurgă deliberat PIN-ul; TTL scurt + citire unică + log forensics (FR-11); clasă de risc echivalentă cu phishing-ul pe fluxul manual actual |
## 7. Criterii de acceptanță
1. Pe o stație cu token USB funcțional: utilizatorul apasă "Token nou" în ROA,
introduce PIN-ul în browser, iar în maximum ~10 secunde ROA afișează
"S-a generat token-ul, valabil până la <data>" — **fără nicio operație de copy/paste**.
2. Tokenul și refresh tokenul salvate în baza de date sunt identice cu cele emise de ANAF
(verificabil printr-un apel API eFactura reușit imediat după).
3. Anularea cu Esc oprește polling-ul fără eroare.
4. Timeout după 10 minute → mesaj clar + instrucțiuni fallback manual.
5. Un client cu versiune veche ROACONT (fără `state`) vede exact pagina actuală cu
tokenurile afișate (regresie zero).
6. `pick.php` returnează 404/`pending` după prima citire (citire unică) și 400 la
`state` malformat.
7. Ramura refresh (`?refresh_token=...`) funcționează identic cu înainte.
## 8. Fișiere afectate
| Fișier | Modificare |
|---|---|
| `D:\PROIECTE\SITE_ROMFAST_2025\oauth2\index.php` | propagare `state`, legare primară prin sesiune (state = CSRF oportunist), stocare tokenuri extra-docroot, pagină succes, curățare securitate (FR-10) |
| `D:\PROIECTE\SITE_ROMFAST_2025\oauth2\pick.php` | NOU — endpoint polling, citire unică, TTL, rate-limit, log evenimente |
| `D:\ROA\ROACONT\COMUN\programe\anaf_efactura.prg` | `newToken`: implementare unică în `AnafeFacturaServer` (state + browser + polling + salvare automată + gardă headless + kill switch); clasa `ANAFeFactura` deleagă; mesaje actualizate |
| `D:\PROIECTE\SITE_ROMFAST_2025\oauth2_tokens\` (extra-docroot) | NOU — stocare temporară tokenuri (FR-11) |
---
# REVIZIE CEO (via /autoplan, 2026-07-07) — Faza 1
Mod: SELECTIVE EXPANSION. Voci: [subagent-only] — Codex CLI indisponibil.
Premise confirmate de utilizator la poarta D2, cu verificarea P4 efectuată
(doc oficial ANAF: „State se lasă necompletat" → FR-2 inversat pe sesiune-primar).
Planul CEO complet: `~/.gstack/projects/ROACONT/ceo-plans/2026-07-07-efactura-oauth2-autoflow.md`.
## Arhitectură (Secțiunea 1)
```
ROACONT (VFP, per stație) romfast.ro (PHP) ANAF
┌────────────────────────┐ ┌─────────────────────────┐ ┌──────────────┐
│ AnafeFacturaServer │ 1. gen │ index.php │ 2. 302 │ logincert │
│ .newToken() │ state ───▶│ session[roa_state] │─────────▶│ /authorize │
│ (gardă headless, │ browser │ (+state la ANAF, CSRF) │ │ (mTLS + PIN) │
│ kill switch) │ │ │◀─ 3. cb ─│ │
│ │ │ code→token POST ───────┼─────────▶│ /token │
│ bucla polling ◀───────┼─ 4. GET ──▶│ pick.php │ └──────────────┘
│ 2s/10min, INKEY(0.5) │ JSON │ ../oauth2_tokens/ │
│ nfjsonread→SaveToken │ │ <hash(state)>.json │
└────────────────────────┘ │ TTL 10m, citire unică │
ANAFeFactura.newToken ──deleagă──▶ │ rate-limit 60/min/IP │
└─────────────────────────┘
```
Mașina de stări a rezultatului (server): `PENDING` (fără fișier) → `READY` (fișier scris
la callback) → `CONSUMED` (șters la prima citire) sau `EXPIRED` (TTL 10 min). Tranziții
invalide prevenite prin ștergere-la-citire + TTL; eroarea nu suprascrie `READY` (FR-10).
Constatări: cuplare nouă VFP↔pick.php — URL-ul `pick.php` se derivă din aceeași bază
configurată `ANAF_URL_TOKEN` folosită de `cTokenUrl` (o singură sursă, fallback hardcodat).
SPOF: romfast.ro (pre-existent, identic cu fluxul actual). Scalare: ~zeci de generări/zi
la vârf — nicio problemă. Rollback: kill switch (FR-7) + retro-compat PHP (FR-4).
## Registru erori & recuperare (Secțiunea 2)
| Codepath | Ce poate eșua | Tratare | Utilizatorul vede |
|---|---|---|---|
| index.php intrare | sesiune indisponibilă / state malformat | validare strictă format; eroare explicită | pagină de eroare clară |
| ANAF authorize | utilizator anulează / cert respins → `?error=` | FR-10: pagină eroare + fișier `{"error"}` pentru state | mesaj în browser + ROA oprește polling-ul cu mesaj |
| callback fără code și fără error | buclă redirect (bug actual) | FR-10: detectare și pagină de eroare, fără re-redirect | mesaj clar, nu buclă |
| code→token (curl) | timeout/eroare rețea/HTTP≠200/JSON eroare (code expirat >60s) | FR-10: verificare curl_errno + status + câmp `error`; scriere fișier eroare | mesaj în browser + ROA afișează eroarea |
| pick.php | state malformat → 400; necunoscut/negenerat → 200 pending; 429 la abuz | FR-5/FR-11 | ROA: continuă (2s→5s la 429) / abandonează cu mesaj |
| VFP polling | eroare rețea tranzitorie / iterație lentă | continuă bucla; SetTimeouts(5s) previne înghețarea Esc | nimic (transparent) |
| VFP polling | HTTP 404 ≥5 consecutiv (pick.php nedesfășurat) | M10 + fallback | mesaj + flux manual |
| VFP parse JSON | răspuns corupt (fișier parțial imposibil — scriere atomică FR-11) | abort + mesaj + fallback manual | mesaj de eroare |
| VFP buclă | excepție în helper HTTP (ambele transporturi) | TRY imbricat + resetare gardă în FINALLY | mesaj de eroare, fără blocare pe M8 |
| SaveToken (Oracle) | eșec scriere | M11 cu retry — tokenul e încă în memorie; după 3 eșecuri → reluare flux | dialog Da/Nu |
| pick→răspuns pierdut pe transport | tokenul șters pe server, neprimit de client | risc asumat (rar): recuperare = reluare flux (M5) | timeout + mesaj |
| Fallback manual în același browser | sesiune stale ar redirecționa pe pagina fără tokenuri | FR-2: unset roa_state la intrarea fără state + timestamp 15 min | pagina cu tokenuri, corectă |
| Esc / timeout 10 min | abandon | curățare stare locală; TTL curăță serverul | mesaj cu opțiuni (reluare / manual) |
**GAP-uri pre-existente închise de FR-10:** eșec curl afișa tokenuri goale; callback de
eroare ANAF intra în buclă de redirect. Zero GAP-uri rămase cunoscute.
## Securitate & threat model (Secțiunea 3)
| Amenințare | Prob. | Impact | Mitigare |
|---|---|---|---|
| Ghicire state pe pick.php | Foarte mică (128 biți) | Mare | entropie FR-6, rate-limit, răspuns uniform pending |
| Interceptare state local (istoric browser) | Mică | Mare | fereastră secunde (ROA culege imediat), citire unică, TTL |
| CSRF autorizare cu state forjat | Mică-medie | Mare | risc rezidual acceptat, dar MAI GRAV decât phishing-ul pe fluxul manual (exfiltrare silențioasă vs. copiere activă de către victimă); mitigare forensic: IP callback stocat în JSON + log `pickup_ip_mismatch` (FR-11) |
| `state` în access-log-urile hostingului | Mică | Mare | `state` prin POST body la pick.php (FR-5); la index.php rămâne în URL (necesar pentru flux) — fereastră de secunde + delete-on-read |
| Injecție în log prin state invalid | Mică | Mică | validare format înainte de orice logare + prefix sanitizat [A-Za-z0-9] (FR-5/FR-11) |
| Expunere director tokenuri la migrare hosting | Medie | Mare | stocare extra-docroot (FR-11) |
| client_secret în istoric browser | — (pre-existent) | Mare | eliminat din authorize URL (FR-10) |
| Injecție variabile parse_str | — (pre-existent) | Medie | eliminat (FR-10) |
| info.php / fișiere moarte | — (pre-existent) | Medie | eliminate/arhivate (FR-10) |
## Edge cases date/interacțiune (Secțiunea 4) — mapate, toate tratate
Dublă lansare newToken (state-uri independente, ultimul salvat câștigă) · browser închis
înainte de PIN (timeout+mesaj) · refresh pe pagina de callback (code single-use → eroare,
anti-suprascriere FR-10) · abandon ROA + finalizare browser (TTL curăță) · Esc în timpul
polling (curat) · două stații simultan (state-uri distincte) · ceas server (TTL pe mtime local).
## Calitate cod (Secțiunea 5)
Implementare unică `newToken` în clasa server + delegare (decis, FR-7) · helper HTTP GET
JSON nou, reutilizând pattern-ul WinHttp→MSXML din `refreshToken` (fără refactorizarea
acestuia — diff minim) · numele `pick.php` OK · complexitate buclă polling ținută sub
control prin extragerea parsării în helper.
## Teste (Secțiunea 6) — detaliat în Faza 3 (Eng); cerințe CEO:
Spike Faza 0 obligatoriu · test E2E cu certificat real (criteriu de acceptanță #1) ·
pick.php testabil complet cu curl (toate ramurile) · index.php ramuri de eroare testabile
fără certificat (code fals → eroare ANAF) · test empiric propagare state (logat).
## Performanță (Secțiunea 7)
≤300 cereri pick/generare (10 min @2s), o generare la ~90 zile/client — neglijabil.
INKEY(0.5) păstrează UI-ul responsiv. Fără probleme.
## Observabilitate (Secțiunea 8)
Log evenimente server (FR-11, fără tokenuri) + `This.Log` la fiecare tranziție în VFP +
contoare zilnice succes/eroare/timeout (măsurarea adopției — încadrarea „reducere tichete
suport" devine măsurabilă). Runbook: eșec raportat → log server + log ROA reconstruiesc
fluxul după prefixul de state.
## Deployment (Secțiunea 9)
Ordine obligatorie: 1) PHP (retro-compat, FR-4) → 2) verificare curl → 3) exe ROACONT.
Fereastră mixtă exe-nou/PHP-vechi: detectată de client (HTTP 404 pe pick.php) → M10 + fallback.
Precondiții verificate la spike: PHP ≥ 7.3, comportamentul 404 al hostingului, SameSite.
Kill switch ANAF_TOKEN_AUTOFLOW pentru rollback fără redistribuire. Post-deploy: curl
pick.php cu state fals → `{"status":"pending"}`; index.php fără parametri → 302 către ANAF.
## Traiectorie (Secțiunea 10)
Reversibilitate 4/5 · datorie tehnică minimă (stocare pe fișiere OK la scara actuală) ·
potențial de platformă: aceeași infrastructură poate servi celelalte produse ROA
(COMUNROA) — în TODOS · întrebarea de 1 an: PRD + comentarii în cod suficiente.
## Design & UX (Secțiunea 11) — CEO-level; adâncime în Faza 2
Harta stărilor: LOADING (wait window cu Esc) / ERROR (timeout·ANAF·rețea, mesaje distincte)
/ SUCCESS (data expirării) / PARTIAL (browser OK, pick eșuat → fallback manual).
Pagina browser: succes (revenire în ROA) și eroare (detaliu + reluare). Arc emoțional:
anxietate → ghidare → confirmare.
## Tabel consens voci CEO (Codex indisponibil → [subagent-only])
| Dimensiune | Claude (principal) | Subagent independent | Consens |
|---|---|---|---|
| Premise valide? | DA (validate la gate) | PARȚIAL (cerea verificări) | REZOLVAT — spike Faza 0 + SameSite + extra-docroot adoptate |
| Problema corectă? | DA | DA | CONFIRMAT |
| Calibrare scope? | DA | DA (cu fix client_secret) | CONFIRMAT — FR-10 adoptat |
| Alternative explorate? | PARȚIAL | PARȚIAL | REZOLVAT — broker + WebView2 documentate |
| Riscuri competitive? | neacoperit | NU | REZOLVAT — încadrare igienă/paritate adăugată |
| Traiectorie 6 luni? | DA | PARȚIAL | REZOLVAT — telemetrie + securitate hosting adoptate |
## NOT in scope / Ce există deja / Dream state — vezi §5 PRD și planul CEO (leverage map complet acolo).
## Decision Audit Trail (Faza 1)
| # | Faza | Decizie | Clasificare | Principiu | Rațiune | Respins |
|---|------|---------|-------------|-----------|---------|---------|
| 1 | CEO | Abordare A (polling state+sesiune) vs B (protocol handler) vs C (WebView2) | Mecanică | P1 | Completitudine 9/10, zero deployment per stație; B/C respinse empiric | B, C |
| 2 | CEO | Cross-project learnings activat | Mecanică | P6 | Dezvoltator solo, produse ROA înrudite | — |
| 3 | CEO | Sesiune primară, state doar CSRF (FR-2 inversat) | Mecanică | P1/P5 | Doc oficial ANAF nu garantează state; sesiunea funcționează oricum | state-primar |
| 4 | CEO | Curățare securitate index.php (FR-10) | Mecanică | P2 | Blast radius (fișier oricum modificat), <1 zi, severitate high | amânare |
| 5 | CEO | Rate-limit + log evenimente pick.php (FR-11) | Mecanică | P1/P2 | Observabilitate = scope; S effort | fără log |
| 6 | CEO | Buton nou în formularul anaf_token | Mecanică | P4 | Duplicat butonul existent apelează newToken; VCX binar | adăugare buton |
| 7 | CEO | Notificare expirare token | Mecanică | P4 | Duplicat RefreshTokenAuto există | |
| 8 | CEO | Auto-lansare flux la eșec definitiv refresh | DECIS la gate (D3) | | Utilizatorul o implementează direct în VFP; în afara planului | includere în plan |
| 9 | CEO | Timeout 10 min (nu 3) | Mecanică | P1 | Consistență cu TTL; utilizatori netehnici | 3 min |
| 10 | CEO | Kill switch ANAF_TOKEN_AUTOFLOW | Mecanică | P2 | Rollback fără exe; deploy SVN lent | fără flag |
| 11 | CEO | Stocare extra-docroot | Mecanică | P1 | Migrarea hosting nu trebuie producă leak | doar .htaccess |
| 12 | CEO | state doar din CoCreateGuid/BCryptGenRandom | Mecanică | P1 | SYS(2015) predictibil; state = secret unic | SYS(2015) |
| 13 | CEO | Spike Faza 0 validare ANAF | Mecanică | P3/P6 | 1 oră care decide FR-2 înainte de a construi restul | build-then-learn |
| 14 | CEO | Implementare unică newToken + delegare | Mecanică | P4/P5 | Două implementări diverg în timp | duplicare |
| 15 | CEO | Token broker | Mecanică (defer) | P3 | Decizie de business (GDPR/răspundere) TODOS | adoptare acum |
| 16 | CEO | Delete-on-read păstrat (fără two-phase confirm) | Mecanică | P3/P5 | Eșecul SaveToken e rar; recuperare = reluare flux; two-phase adaugă complexitate | two-phase pickup |
| 17 | CEO | Risc CSRF autorizare = rezidual acceptat | Mecanică | P3 | Cere PIN deliberat al victimei; echivalent phishing flux manual; forensics în log | pickup_secret suplimentar |
# REVIZIE ENG (via /autoplan, 2026-07-07) — Faza 3
Voci: [subagent-only] (Codex indisponibil). Subagentul independent a citit codul real
(anaf_efactura.prg 447-586/2690-2746, index.php, oexport.prg:302) și a returnat 18
constatări (1 critică, 5 high, 7 medium, 5 low) TOATE adoptate prin auto-decizie și
propagate în FR-2/FR-5/FR-6/FR-7/FR-11, în tabelele de mesaje, registre și threat model.
Punctul C9 (comportamentul headless al `AnafeFacturaServer.newToken`, care azi
instruiește completarea în `gcGeneralIniFile`) DECIZIE DE GUST la poarta finală.
## Constatări cheie adoptate (cu confidence)
| # | Constatare | Conf. | Rezolvare |
|---|---|---|---|
| C1 | [P1] Sesiune stale rupe fallback-ul manual (același browser, roa_state rămas pagina de succes fără tokenuri = fund de sac) | 9/10 | FR-2: unset la intrarea fără state + timestamp 15 min + test #18 |
| C2 | [P1] Două instanțe ROACONT (firme diferite) coliziune sesiune, tokenul firmei A salvat la firma B | 8/10 | FR-2: avertisment pe pagină la state proaspăt neconsumat; test #20 |
| C3 | [P1] Scriere fișier non-atomică pick citește JSON parțial PIN refăcut degeaba | 8/10 | FR-11: temp+rename ambele direcții (write și claim) |
| C4 | [P1] WinHttp default 30-60s Esc mort la rețea căzută; XMLHTTP nu are setTimeouts | 9/10 | FR-7: SetTimeouts(5s×4) + fallback ServerXMLHTTP |
| C5 | [P1] Pattern TRY/CATCH din refreshToken:536-547 lasă excepția din CATCH nehandled garda blocată pe M8 | 9/10 | FR-7: TRY imbricat + FINALLY resetează garda |
| C6 | [P1] open_default_app (oexport.prg:302) nu returnează nimic M9 neimplementabil | 9/10 | FR-7: wrapper local ShellExecute>32 |
| C7 | [P2] 60/min/IP fără headroom NAT (2 stații = exact limita) | 8/10 | FR-11: 240/min/IP + contor pe fișiere + 5s persistent |
| C8 | [P2] 404-pending fragil pe shared hosting (ErrorDocument suprascrie corpul) | 8/10 | FR-5: pending = 200; 404 devine semnal pur pentru M10 |
| C10-C11 | [P2] PHP≥7.3 necertificat; session lock ținut în timpul curl | 8/10 | FR-2 spike + FR-11 session_write_close + curl timeouts |
| C12-C15 | [P2/P3] CSRF mai grav decât echivalarea inițială; state în access-log; log injection; nume fișier | 7-8/10 | threat model actualizat; POST body; hash(state) întotdeauna; sanitizare |
| C16-C18 | [P3] răspuns pierdut după delete; NVL la kill switch; M11 retry (tokenul încă în memorie) | 8/10 | registru + FR-7 + M11 rescris cu retry |
## Diagramă acoperire teste (Secțiunea 3 Eng)
```
CĂI DE COD FLUXURI UTILIZATOR
[+] oauth2/index.php [+] Generare token automată
├── intrare fără parametri (302, fără secret) ├── [TEST #11] happy path E2E cu USB
├── intrare cu state (+sesiune, SameSite) ├── [TEST #12] Esc înainte/după PIN
├── intrare fără state → unset roa_state [#9,18] ├── [TEST #3,#13,#19] timeout + rețea moartă
├── state peste state proaspăt → avertisment[#20] ├── [TEST #14] exe nou + PHP vechi → M10
├── callback ?code (schimb OK → fișier atomic) ├── [TEST #5,#16,#24] kill switch/firmă
├── callback ?code fals → {"error"} [#6] ├── [TEST #6] gardă reintrare M8
├── callback ?error= → {"error"}, fără buclă [#7] ├── [TEST #8,#23] browser eșuat/fără cert
├── anti-suprascriere succes [#6] └── [TEST #17,#20] dublă instanță/reluare
└── ?refresh_token → REGRESIE [#12,curl] [+] Fallback manual
[+] oauth2/pick.php ├── [TEST #3-Nu] pagina CU tokenuri (C1!)
├── POST state valid+fișier → claim+200+șterge └── [TEST #13-form] completare manuală OK
├── pending 200 uniform [#1,#3] [+] Server headless
├── malformat → 400 fără log injection [#2] └── [TEST #9-headless] refuz/flux INI (C9)
├── TTL cleanup [#4] · rate limit 429 [#5,#22]
└── fără session_start
[+] anaf_efactura.prg (newToken unificat + helper HTTP + wrapper ShellExecute)
├── state CoCreateGuid [#11-format] · polling 2s/5s/10min [#3,#19,#22]
├── mesaje M1-M12 progresive [#4] · SaveToken retry [#21] · delegare client→server
└── TRY imbricat + FINALLY gardă [#19 implicit]
ACOPERIRE: 25/25 căi au test în matricea manuală (VFP #11-25, curl #1-10) — 100% din
căile noi; fără test runner automat (constrângere de proiect), matricea e scriptată
curl unde se poate și manuală unde nu.
```
Artefact plan de teste (consumat de /qa): `~/.gstack/projects/ROACONT/mmari-main-eng-review-test-plan-20260707-123000.md`
## Paralelizare implementare (lanes)
| Pas | Module atinse | Depinde de |
|---|---|---|
| Spike Faza 0 | oauth2/ (index.php minim) | — |
| PHP complet (FR-1..5, 10, 11) | oauth2/ | Spike (decide detaliile FR-2) |
| VFP (FR-6..9, §3.3) | COMUN/programe/ | Spike (contractul e fixat de PRD; poate începe în paralel cu PHP) |
| Deploy + verificare | ambele | PHP + VFP |
Lane A: Spike → PHP complet (secvențial, același fișier). Lane B: VFP (paralel cu PHP
după spike). Merge: deploy ordonat PHP→exe. Fără conflicte (module disjuncte).
## Tabel consens voci Eng ([subagent-only])
| Dimensiune | Claude (principal) | Subagent independent | Consens |
|---|---|---|---|
| Arhitectură solidă? | DA (cu fixurile adoptate) | PARȚIAL (C1-C3) | REZOLVAT — C1-C3 adoptate în FR-2/FR-11 |
| Acoperire teste suficientă? | PARȚIAL (matrice de bază) | PARȚIAL (lipsă edge) | REZOLVAT — matricea extinsă la 25 teste |
| Riscuri performanță? | DA (volum trivial) | PARȚIAL (C4, C7) | REZOLVAT — SetTimeouts + 240/min |
| Securitate acoperită? | DA (FR-10/11) | PARȚIAL (C12-C15) | REZOLVAT — threat model extins, POST body, hash, sanitizare |
| Căi de eroare tratate? | DA (registru 11 căi) | PARȚIAL (C1, C5, C6) | REZOLVAT — registru extins la 14 căi, 0 GAP critice |
| Risc deployment gestionabil? | DA | DA | CONFIRMAT |
## Decision Audit Trail (Faza 3)
| # | Faza | Decizie | Clasificare | Principiu | Rațiune | Respins |
|---|------|---------|-------------|-----------|---------|---------|
| 27 | Eng | Igienă sesiune: unset la intrare fără state + timestamp 15 min | Mecanică | P1/P5 | C1 (9/10): fallback-ul manual nu trebuie să moștenească sesiunea tentativei eșuate | ignorare |
| 28 | Eng | Avertisment la suprascriere de state proaspăt (multi-instanță) | Mecanică | P5 | C2: suprascrierea tăcută trimite tokenul firmei A la firma B | listă de state-uri (fără state în callback nu discriminezi) |
| 29 | Eng | Scriere atomică temp+rename + claim prin rename la pickup | Mecanică | P1 | C3: JSON parțial = PIN refăcut degeaba; cost o linie | write direct |
| 30 | Eng | SetTimeouts(5s×4) + fallback ServerXMLHTTP.6.0 | Mecanică | P1/P5 | C4: default 30-60s îngheață Esc; XMLHTTP nu are setTimeouts | XMLHTTP |
| 31 | Eng | TRY imbricat + resetare gardă în FINALLY | Mecanică | P1 | C5: excepția din CATCH ar bloca aplicația pe M8 | pattern refreshToken |
| 32 | Eng | Wrapper local ShellExecute>32 (nu modificăm open_default_app global) | Mecanică | P3/P5 | C6: helper-ul global nu returnează nimic și e folosit în zeci de locuri | modificare globală |
| 33 | Eng | Rate limit 240/min/IP, contor pe fișiere, 5s persistent după 429 | Mecanică | P1 | C7: birou cu >2 stații după NAT ar lovi 60/min exact la deploy | 60/min |
| 34 | Eng | pending = HTTP 200; 404 doar pentru pick.php lipsă | Mecanică | P5 | C8: hostingurile suprascriu corpul 404 → M10 fals pozitiv | 404-pending |
| 35 | Eng | Verificare PHP≥7.3 la spike + fallback SameSite hack | Mecanică | P6 | C10: sintaxa array nu există sub 7.3 | presupunere |
| 36 | Eng | session_write_close înainte de curl + CURLOPT timeouts | Mecanică | P1 | C11: lock de sesiune ținut cât răspunde ANAF | fără timeouts |
| 37 | Eng | IP callback în JSON + log pickup_ip_mismatch (fără blocare) | Mecanică | P1 | C12: CSRF-ul e mai grav decât echivalarea inițială; forensic ieftin | blocare strictă (false pozitive VPN) |
| 38 | Eng | hash('sha256', state) ca nume fișier ÎNTOTDEAUNA | Mecanică | P5 | C13: rezolvă și contradicția FR-3 vs diagramă; secret scos din listing | doar în fallback |
| 39 | Eng | state prin POST body la pick.php | Mecanică | P1 | C14: scoate secretul din access-log-urile hostingului | GET query |
| 40 | Eng | Validare înainte de logare + prefix sanitizat | Mecanică | P1 | C15: anti log-injection prin CR/LF în state invalid | logare brută |
| 41 | Eng | Risc „răspuns pierdut după delete" documentat ca asumat | Mecanică | P3 | C16: two-phase respins deja (#16); recuperare = M5 | two-phase |
| 42 | Eng | NVL + default 1 la citirea kill switch; scope per firmă intenționat | Mecanică | P5 | C17: pattern-ul liniei 462 crapă pe NULL | copiere pattern |
| 43 | Eng | M11 cu retry SaveToken (tokenul încă în This.cToken) | Mecanică | P1 | C18: reluarea PIN-ului e disproporționată pentru un eșec de scriere DB | doar mesaj |
| 44 | Eng | Comportament headless server (INI flow vs refuz) | DECIS la gate (D4) | — | Utilizatorul a ales: se păstrează mecanismul INI actual, neatins | refuz simplu |
## Sumar completare Faza 3 (Eng)
```
Scope challenge: 4 fișiere, 0 clase noi — fără declanșare | Cod citit: prg (4 funcții), index.php, oexport.prg
S1 Arhitectură: 6 constatări critice/high (C1-C6) — toate adoptate | S2 Calitate: helper HTTP nou,
wrapper local, dedup cu decizie C9 la gate | S3 Teste: diagramă 25 căi / 25 acoperite, artefact
scris pe disc | S4 Performanță: C4+C7 adoptate, volum trivial confirmat
Registru erori: 14 căi, 0 GAP critice | Failure modes: toate cu tratare + test + mesaj vizibil
Paralelizare: 2 lanes după spike | Voci: [subagent-only], 18 constatări, consens 6/6 după adoptare
Decizii: 17 mecanice adoptate (#27-43), 1 taste la gate (#44) | Lake Score: 17/17 varianta completă
```
# REVIZIE DESIGN (via /autoplan, 2026-07-07) — Faza 2
Voci: [subagent-only] (Codex indisponibil). Designer vizual indisponibil (necesită bun)
→ review text-only, permis de skill. DESIGN.md inexistent — sistemul de design efectiv:
dialoguri Windows clasice VFP (fără diacritice) + pagini server simple (UTF-8).
## Scoruri pe pase (înainte → după fixuri)
| Pasa | Scor | Ce lipsea → ce s-a adăugat |
|---|---|---|
| 1. Ierarhia informației | 6 → 9 | ghidarea era integral front-loaded în modalul inițial; mutată progresiv în WAIT WINDOW (M2-M4), pasul de selecție certificat numit explicit |
| 2. Acoperirea stărilor | 6 → 9 | adăugate: eșec lansare browser (M9), browser ascuns (M3), reintrare (M8), kill-switch (mesaj vechi), Esc după PIN (M6), eșec SaveToken (M11) |
| 3. Arc emoțional | 6 → 9 | storyboard mai jos; punctele de rupere fixate: dialog certificat anunțat (M1), gol de feedback umplut (M3/M4), fallback cu avertisment PIN (FR-8) |
| 4. Specificitate (AI slop) | 5 → 9 | 12 texte finale definite verbatim (§3.3) + layout pagini browser (un job/pagină, H1 scanabil, fără retry în browser) |
| 5. Aliniere sistem design | 7 → 8 | regula diacriticelor decisă (VFP fără, PHP cu + UTF-8) — consistent cu codul existent; fără DESIGN.md (nejustificat pentru 1 pagină server) |
| 6. Responsive & accesibilitate | 5 → 8 | pagini browser: ≥16px, contrast înalt, zero interacțiune necesară, fără motion; VFP: Esc <1s (INKEY 0.5) |
| 7. Decizii nerezolvate | 6 rezolvate, 0 amânate | F1 fallback fără state, F5 diacritice, F6 job pagină eroare, F7 OK/Anulare, F8 reintrare, F9 kill-switch |
**Scor design general: 6/10 → 9/10.**
## Storyboard călătorie utilizator (Pasa 3)
| Pas | Utilizatorul face | Simte | Planul susține cu |
|---|---|---|---|
| 1 | Apasă Token nou" | incertitudine („iar tokenul...") | M1: 3 pași numerotați, promisiunea fara copiere manuala", opțiune Anulare |
| 2 | Introduce tokenul USB | teamă de dialoguri tehnice | M1 anunță explicit selecția certificatului |
| 3 | Browser: selectează certificat, PIN | anxietate maximă | pagina ANAF (nu a noastră); M2 în ROA numește exact acțiunea |
| 4 | Așteaptă / revine în ROA | a mers?" | pagina de succes cu un singur mesaj + M3/M4 progresive în ROA |
| 5 | Vede confirmarea | ușurare, încredere | M7 cu data expirării; zero copy/paste |
| 5' | (eșec) | frustrare | M5/M6/M10-M12: cauză în limbaj uman + următorul pas concret, niciodată fund de sac |
## Litmus scorecard (voci design — [subagent-only])
| Verificare (pagini browser) | Claude | Codex | Consens |
|---|---|---|---|
| Headline scanabil dintr-o privire? | DA (după fix F6) | N/A | DA (post-fix) |
| Un job per pagină? | DA (după eliminarea retry din pagina de eroare) | N/A | DA (post-fix) |
| Acțiune următoare clară? | DA („Reveniți în aplicația ROA" pe ambele) | N/A | DA (post-fix) |
| Hard rejections declanșate | 0 (pagini minimale, fără carduri/decor) | N/A | 0 |
Constatările subagentului: 1 critică (F1 fallback cu `state` = fund de sac), 5 high
(F2-F6), 4 medium (F7-F10). Toate structurale, toate rezolvate prin editările §3.3 +
FR-7/FR-8/FR-9. Zero decizii de gust rămase pentru gate.
## Decision Audit Trail (Faza 2)
| # | Faza | Decizie | Clasificare | Principiu | Rațiune | Respins |
|---|------|---------|-------------|-----------|---------|---------|
| 18 | Design | Fallback manual = URL fără `state` + avertisment PIN | Mecanică | P1 | F1 critică: cu `state`, pagina nu afișează tokenuri = fund de sac | reluare cu state |
| 19 | Design | Mesaje VFP fără diacritice, pagini PHP UTF-8 cu diacritice | Mecanică | P5 | Codepage 1250/852 corupe diacriticele; consistent cu tot codul existent | diacritice în VFP |
| 20 | Design | Mesaje progresive în WAIT WINDOW (M2-M4) | Mecanică | P1 | Fereastră statică 10 min = aplicație aparent blocată | text static |
| 21 | Design | 12 texte finale definite în PRD (M1-M12 + traduceri erori ANAF) | Mecanică | P1/P5 | Textul exact" nu se lasă implementatorului; momentul emoțional prost (M11) cere formulare atentă | parafrazare |
| 22 | Design | Modal inițial OK/Anulare + anunțarea selecției certificatului | Mecanică | P1 | Utilizatorul fără USB la îndemână trebuie poată renunța curat | OK-only |
| 23 | Design | Pagina de eroare: un job, fără retry în browser | Mecanică | P5 | Retry în browser nu poate funcționa (state nou cere aplicația) buclă de confuzie | detaliu+reluare |
| 24 | Design | Gardă de reintrare + verificare lansare browser | Mecanică | P1 | Stări nespecificate care produc tăcere de 10 min sau haos de bucle | nespecificat |
| 25 | Design | Kill-switch afișează mesajul VECHI nemodificat | Mecanică | P5 | Aplicația nu promite automat când pagina afișează tokenuri de copiat | mesaj nou |
| 26 | Design | Fără /design-consultation (DESIGN.md) | Mecanică | P3 | O pagină server minimală + dialoguri standard nu justifică sistem de design | consultanță |
## Sumar completare Faza 2 (Design)
```
Audit: DESIGN.md absent | UI scope: dialoguri VFP + 2 pagini browser | Mockups: indisponibil (text-only)
Pasa 1: 6→9 | Pasa 2: 6→9 | Pasa 3: 6→9 | Pasa 4: 5→9 | Pasa 5: 7→8 | Pasa 6: 5→8
Pasa 7: 6 rezolvate, 0 amânate | Scor general: 6/10 → 9/10
Voci: [subagent-only] — 10 constatări (1 critică, 5 high, 4 medium), toate fixate în PRD
Decizii adăugate în plan: 9 (audit #18-26) | Decizii de gust pentru gate: 0
```
## Sumar completare Faza 1 (CEO)
```
Mod: SELECTIVE EXPANSION | Audit sistem: SVN trunk, formular VCX binar, fișiere moarte oauth2/
Step 0: premise confirmate (gate D2, P4 verificat empiric → FR-2 inversat pe sesiune)
S1 Arhitectură: 1 constatare (bază URL unică) — rezolvată | S2 Erori: 11 căi mapate, 2 GAP-uri
pre-existente închise (FR-10), 0 rămase | S3 Securitate: 7 amenințări, 3 high pre-existente
fixate (FR-10/FR-11) | S4 Edge cases: 7 mapate, toate tratate | S5 Calitate: dedup newToken
decis | S6 Teste: cerințe CEO fixate, detaliu în Faza 3 | S7 Perf: fără probleme |
S8 Observabilitate: log evenimente + contoare (FR-11) | S9 Deploy: ordine + kill switch |
S10 Traiectorie: reversibilitate 4/5 | S11 Design: harta stărilor, adâncime în Faza 2
NOT in scope: 5 iteme | Ce există deja: 7 reutilizări | Registru erori: 11 căi, 0 GAP critice
Scope: 11 propuneri, 8 acceptate, 2 respinse (duplicate), 1 PENDING gate (#7)
Plan CEO: scris + spec review 3 iterații (8/10 → PASS 9/10, 11 probleme fixate)
Voce externă: [subagent-only] (Codex neinstalat) | Lake Score: 8/8 recomandări = varianta completă
Diagrame: arhitectură, flux+shadow paths, mașină de stări | Decizii nerezolvate: 1 (gate #7)
```
(Nota post-gate: itemul #7/#8 din sumar a fost DECIS la poarta finală vezi audit trail #8 și #44.)
## GSTACK REVIEW REPORT
| Review | Trigger | Why | Runs | Status | Findings |
|--------|---------|-----|------|--------|----------|
| CEO Review | `/plan-ceo-review` | Scope & strategy | 1 | CLEAR (PLAN via /autoplan) | 11 proposals, 8 accepted, 2 deferred; spec review PASS 9/10 |
| Codex Review | `/codex review` | Independent 2nd opinion | 0 | (Codex CLI neinstalat; voci = subagent Claude) | |
| Eng Review | `/plan-eng-review` | Architecture & tests (required) | 1 | CLEAR (PLAN via /autoplan) | 18 issues, 0 critical gaps rămase; acoperire teste 25/25 căi |
| Design Review | `/plan-design-review` | UI/UX gaps | 1 | CLEAR (FULL via /autoplan) | score: 6/10 9/10, 9 decisions |
| DX Review | `/plan-devex-review` | Developer experience gaps | 0 | SKIPPED | fără scope developer-facing (pick.php e endpoint intern) |
- **VERDICT:** CEO + ENG + DESIGN CLEARED ready to implement (începând cu spike-ul Faza 0).
NO UNRESOLVED DECISIONS