Część przewodnika KSeF od środka. Tu rozbieramy
ksef-pdf-api: skąd wziął się kod renderujący, jak liczony jest kod QR weryfikacyjny i dlaczego serwis odmawia drukowania etykiety OFFLINE.
Problem: ministerstwo nie daje wizualizacji serwerowej
Faktura KSeF to XML zgodny ze schematem FA. Ministerstwo Finansów nie publikuje XSLT ani oficjalnego szablonu do renderowania po stronie serwera. Jedyne, co istnieje, to referencyjna biblioteka TypeScript CIRFMF/ksef-pdf-generator. Problem w tym, że jest to biblioteka frontendowa: API oparte o przeglądarkowe File, FileReader i Blob, build przez Vite i brak publikacji w npm. Na serwerze nie użyjesz jej wprost.
ksef-pdf-api rozwiązuje to minimalną adaptacją zamiast przepisywania: bierze z repozytorium MF wyłącznie warstwę renderowania i wystawia ją jako bezstanowe HTTP API w kontenerze. Bez persystencji i bez kontaktu z API KSeF: request z XML-em wchodzi, PDF wychodzi. Konsumentem jest n8n w zamkniętej sieci Docker.
Vendoring biblioteki MF
Około 140 plików kodu ministerstwa (generatory FA(1), FA(2), FA(3), FA_RR i UPO) znajduje się w src/pdf/vendor/ i jest traktowane jak zewnętrzna zależność: wszystko oznaczone @ts-nocheck, wyłączone z typechecku i pomiaru pokrycia, z pinowanym commitem upstreamu i udokumentowaną procedurą aktualizacji w vendor/README.md.
Adaptacja do Node dotknęła dokładnie trzech plików:
XML-parser.ts: wejście zmienione z przeglądarkowegoFilena string, parsowanie synchroniczne (xml-jsw trybie compact, z obcinaniem prefiksów namespace’ów),generate-invoice.ts: przyjmuje już sparsowany obiekt zamiast surowego XML-a (dzięki temu metadane typu numer faktury wyciąga się bez podwójnego parsowania), zwracaBufferi rzucaUnsupportedSchemaErrortam, gdzie upstream po cichu zwracał pusty PDF,UPO-generator.ts: analogiczne przejście na API Node.
Wybór generatora to switch po polu Naglowek/KodFormularza/@kodSystemowy z XML-a: FA (1), FA (2), FA (3), FA_RR (1). Nieznana wartość kończy się odpowiedzią 422, łącznie ze złośliwym przypadkiem, w którym ktoś wyśle FA(2) bez spacji. Sam rendering robi pdfmake; dla FA(3) obsługiwane są między innymi faktury korygujące (typ KOR przełącza tabelę pozycji w tryb rabatowy), zamówienia, procedura marży i podsumowania stawek VAT. Układ sekcji odpowiada wzorowi ministerstwa, bo to dosłownie jego kod.
API
Serwis wystawia dwa endpointy. GET /health to liveness probe bez autoryzacji (używa go HEALTHCHECK Dockera). Całą robotę wykonuje POST /convert, autoryzowany nagłówkiem X-API-Key:
| Aspekt | Szczegóły |
|---|---|
| Wejście, tryb 1 | surowy XML w ciele, Content-Type: application/xml, text/xml lub application/octet-stream |
| Wejście, tryb 2 | multipart/form-data z pojedynczym polem file |
| Wyjście | 200, application/pdf, Content-Disposition: attachment; filename="<numer faktury>.pdf", Cache-Control: no-store |
| Limit ciała | 5 MiB (konfigurowalne przez MAX_BODY_SIZE) |
Opcje przekazuje się query paramem albo nagłówkiem (nagłówek wygrywa), bo ciało to surowy XML i nie ma gdzie włożyć koperty JSON:
?qr=false/X-KSeF-QR: falsewyłącza kod QR (domyślnie włączony),?env=test/X-KSeF-Env: testkieruje link QR na środowisko testowe KSeF,?ksefNumber=.../X-KSeF-Number: ...podaje numer KSeF do wydrukowania na fakturze.
Błędy mają stabilny słownik kodów w src/errors.ts (INVALID_XML, UNSUPPORTED_SCHEMA, PAYLOAD_TOO_LARGE, MISSING_API_KEY itd.) i format { "error": "...", "code": "..." }. Odpowiedź 500 zwraca wyłącznie komunikat generyczny. Stack trace idzie do logów i jest test pilnujący, żeby nigdy nie wyciekł do klienta.
Nazwa pliku w Content-Disposition pochodzi z pola Fa/P_2 faktury, więc przechodzi przez sanityzację (usunięcie znaków niebezpiecznych dla nagłówka HTTP, limit 120 znaków, fallback invoice.pdf). Bez tego numer faktury byłby wektorem header injection.
Cykl życia żądania
timingSafeEqual na X-API-Key"] B --> C["odczyt ciała:
raw XML albo multipart"] C --> D["parseXML + walidacja
root <Faktura>, kodSystemowy"] D --> E["buildKodILink:
NIP + data + SHA-256 XML-a"] E --> F["generator FA1/FA2/FA3/FA_RR
(pdfmake, kod MF)"] F --> G["200: PDF, Content-Disposition,
Cache-Control: no-store"] D -->|błąd| H["globalny error handler:
400/415/422 + kod błędu"]
Stack: Node 22, TypeScript uruchamiany przez tsx bez kroku kompilacji, Fastify 5, walidacja konfiguracji zodem (błędny env = proces nie wstaje), logi strukturalne pino. Etykiety na fakturze są dwujęzyczne (polski/angielski) przez i18next, rozgrzewany przy starcie, żeby pierwsze żądanie nie płaciło za inicjalizację.
Kod QR: KOD I liczony w całości z XML-a
Specyfikacja KSeF przewiduje dwa kody QR na wizualizacji. KOD I to link weryfikacyjny: po zeskanowaniu strona ministerstwa potwierdza, że faktura istnieje w systemie i ma dokładnie tę treść. KOD II to kod certyfikatu dla faktur wystawionych w trybie offline; wymaga podpisu kluczem prywatnym certyfikatu KSeF typu 2, więc bezstanowe API świadomie go nie generuje.
KOD I budowany jest w src/pdf/ksef-qr.ts bez żadnych danych spoza faktury:
https://qr.ksef.mf.gov.pl/invoice/{NIP}/{DD-MM-RRRR}/{SHA256(xml) w Base64URL}
NIP sprzedawcy pochodzi z Podmiot1/DaneIdentyfikacyjne/NIP (walidowany regexem), data wystawienia z pola Fa/P_1 przeformatowana z ISO, a hash to SHA-256 surowych bajtów przysłanego XML-a, zakodowany Base64URL. Funkcja hashująca ma test na wektor kontrolny porównany z OpenSSL, więc regresja formatu nie przejdzie niezauważona. Gdy w XML-u brakuje NIP-u albo daty, link nie powstaje i kod jest po prostu pomijany. Dotyczy to na przykład FA_RR, gdzie podmiot z Podmiot1 nie jest właściwym wystawcą.
Etykieta OFFLINE, czyli najciekawszy przypadek brzegowy
W kodzie ministerstwa pod kodem QR drukowany jest podpis: numer KSeF, jeśli jest znany, albo słowo „OFFLINE”, jeśli faktura ma KOD II. Kusząca droga na skróty brzmi: „nie znam numeru KSeF, więc piszę OFFLINE”. Serwis robi dokładnie odwrotnie i jest to udokumentowana decyzja (komentarz w src/pdf/generate.ts, commit 6aa0172): w specyfikacji MF „OFFLINE” oznacza fakturę faktycznie wystawioną w trybie offline, a tego nie da się wywnioskować z samego braku numeru. Drukowanie OFFLINE „na wszelki wypadek” wprowadzałoby odbiorcę w błąd co do statusu prawnego dokumentu. Numer KSeF pojawia się więc pod kodem tylko wtedy, gdy caller poda go jawnie w X-KSeF-Number; w przeciwnym razie pod kodem nie ma nic.
Hardening kontenera
Serwis przetwarza dane finansowe, więc kontener produkcyjny jest dokręcony mocniej niż przeciętny:
- multi-stage build na
node:22-alpine, proces jako usernode(non-root),tinijako PID 1 dla poprawnej obsługi sygnałów, - w
docker-compose.prod.yml:read_only: truez dwoma małymi tmpfs,cap_drop: ALL,no-new-privileges, bind wyłącznie na127.0.0.1, sieć zewnętrzna współdzielona z n8n, - limity zasobów (1 CPU / 256 MB), które przy okazji wcześnie wyłapałyby wyciek pamięci, rotacja logów i
stop_grace_periodzsynchronizowany z graceful shutdownem Fastify, - healthcheck czystym
node -e, bez instalowania curla do obrazu, Cache-Control: no-storena każdym PDF, bo żaden pośrednik nie ma prawa cache’ować faktur.
Do tego timing-safe auth w src/plugins/auth.ts: klucze API są pre-buforowane, a porównanie robi crypto.timingSafeEqual z wcześniejszym sprawdzeniem długości, bo timingSafeEqual na buforach różnej długości rzuca wyjątek, którym dałoby się wywracać proces. Test pokrywa również atak kluczem o wspólnym prefiksie.
Testy i CI
Vitest, 7 plików spec, ~54 testy, w tym e2e na realnej fakturze FA(3) z produkcji (fixtury z systemu Sellasist). Pokrycie własnego kodu: 99,5% linii przy progu 80% na wszystkich osiach. Vendor jest z pomiaru wyłączony, bo testuje go upstream. CI podwójne, jak w całym ekosystemie: GitHub Actions (typecheck, lint, testy, budowa obrazu do GHCR z tagowaniem SemVer) i GitLab CI (testy + budowa obrazu do rejestru GitLab, na MR tylko przy zmianach w plikach istotnych dla obrazu). Produkcja pinuje obraz do konkretnej wersji zamiast :latest.