From 78e7f99d6069ee2999aa1a4659b861d90278ec5d Mon Sep 17 00:00:00 2001 From: JiriUhlir <149317995+JiriUhlir@users.noreply.github.com> Date: Wed, 12 Aug 2026 15:08:25 +0200 Subject: [PATCH] Konektory do Postgresu, pristupove udaje sifrovane Pristupove udaje konektoru se ukladaji do databaze a prezijou restart. Popis v documentation/14-databaze.md. Databaze je volitelna a rezimy jsou oddelene: - postgres kdyz je DATABASE_URL i SECRETS_KEY - memory jinak, tedy pri nasazenem mockupu a lokalnim vyvoji bez DB Rozhodnuti je jen na jednom miste (src/data/connectorStore.ts). Nikde jinde se nezjistuje, jestli databaze je - kdyby se to rozlezlo po kodu, jedno misto by se zapomnelo a chovalo by se pak jinak nez zbytek. Chybejici databaze nesmi shodit start: container, ktery nenastartuje, je pro AppFactory nefunkcni sluzba. Misto toho se do logu napise proc a portal to ukaze na strance Konektory. Stejne tak kdyz migrace selzou - psat do rozbiteho schematu je horsi nez neukladat. Databaze potrebuje oboji. Bez SECRETS_KEY by se udaje ukladaly v plaintextu a to je horsi nez ztratit je pri restartu: tabulku vidi kazda zaloha a kazdy dump pri ladeni. Pridano: - pool v src/db/pool.ts vcetne transakci a dbFor(tenantId) jako sev pro budouci oddelenou databazi jednoho klienta - migrace ze src/db/migrations/*.sql pod pg_advisory_lock, jinak je pri rolling deployi pusti vsechny instance naraz. Jeden soubor je jedna transakce - sifrovani AES-256-GCM s nahodnym IV a verzi klice. Nerozsifrovatelna hodnota nepada, chova se jako nevyplnena a zaloguje se - jeden rozbity konektor nesmi shodit seznam ostatnich - /health/ready s pingem do DB. /health na databazi zamerne nezavisi, kratky vypadek by jinak vedl k restartovani containeru - GET /api/dashboard/storage a hlaska v portalu o tom, ze data jsou jen v pameti - jediny vychozi konektor na firmu a sluzbu hlida castecny unikatni index, ne jen kod. Dva soubezne zapisy by jinak udelaly dva vychozi Zmeneno: cteni i zapis konektoru je asynchronni, vcetne validace stromu. Overeno proti Postgresu 16 v kontejneru: migrace, sifrovani v tabulce, preziti restartu, rozsifrovani spravnym klicem, degradace pri spatnem klici, PATCH bez tajneho pole, prepnuti a smazani vychoziho konektoru, pametovy rezim bez DATABASE_URL. Kontejner po overeni smazan. Co-Authored-By: Claude Opus 5 (1M context) --- documentation/01-prehled-a-stav.md | 3 +- documentation/04-api.md | 4 +- documentation/14-databaze.md | 184 ++++++++++++ documentation/99-zmeny.md | 39 +++ package-lock.json | 160 +++++++++++ package.json | 2 + src/config.ts | 31 ++ src/data/connectorStore.ts | 377 ++++++++----------------- src/data/connectors/memory.ts | 176 ++++++++++++ src/data/connectors/postgres.ts | 248 ++++++++++++++++ src/data/connectors/types.ts | 180 ++++++++++++ src/db/migrate.ts | 115 ++++++++ src/db/migrations/001_connectors.sql | 41 +++ src/db/pool.ts | 164 +++++++++++ src/db/secretBox.ts | 123 ++++++++ src/index.ts | 56 ++++ src/openapi.ts | 40 +++ src/routes/connectors.ts | 36 +-- src/routes/dashboard.ts | 31 +- src/routes/scripts.ts | 19 +- web/src/pages/dashboard/Connectors.tsx | 21 +- web/src/types/dashboard.ts | 9 + 22 files changed, 1761 insertions(+), 298 deletions(-) create mode 100644 documentation/14-databaze.md create mode 100644 src/data/connectors/memory.ts create mode 100644 src/data/connectors/postgres.ts create mode 100644 src/data/connectors/types.ts create mode 100644 src/db/migrate.ts create mode 100644 src/db/migrations/001_connectors.sql create mode 100644 src/db/pool.ts create mode 100644 src/db/secretBox.ts diff --git a/documentation/01-prehled-a-stav.md b/documentation/01-prehled-a-stav.md index cc92438..0908725 100644 --- a/documentation/01-prehled-a-stav.md +++ b/documentation/01-prehled-a-stav.md @@ -36,7 +36,8 @@ React aplikaci ze slozky `dist/public`. | Sprava clenstvi z portalu | chybi | memberships jdou zmenit jen v kodu | | Bugs a wishes | chybi | vyvojarska agenda, samostatna evidence vedle ticketu | | Beh automatizaci | chybi | ulozeny strom se nevykonava, neni runtime | -| Databaze | chybi | data jsou v pameti, restart je vrati na vychozi stav | +| Databaze pro konektory | hotovo | Postgres, udaje sifrovane. Bez DATABASE_URL jede pamet | +| Databaze pro zbytek | chybi | automatizace, rozlozeni a tickety jsou v pameti | | Odesilani e-mailu z formulare | chybi | poptavka se zatim jen loguje | ## Znama omezeni diff --git a/documentation/04-api.md b/documentation/04-api.md index 58cbb27..f92c3bf 100644 --- a/documentation/04-api.md +++ b/documentation/04-api.md @@ -9,7 +9,8 @@ Verejne: | Metoda | Cesta | Popis | | ------ | ------------------- | --------------------------------------- | -| GET | `/health` | health check | +| GET | `/health` | liveness, nezavisi na databazi | +| GET | `/health/ready` | readiness, 503 pri nedostupne databazi | | GET | `/docs` | Swagger UI | | GET | `/openapi.json` | OpenAPI definice | | POST | `/api/auth/login` | prihlaseni, vraci JWT | @@ -36,6 +37,7 @@ Vyzaduji `Authorization: Bearer `: | POST | `/api/dashboard/tickets/:id/status` | | POST | `/api/dashboard/tickets/:id/comment` | | GET | `/api/dashboard/incidents` | +| GET | `/api/dashboard/storage` | | GET | `/api/dashboard/services` | | GET | `/api/dashboard/connectors/services` | | GET | `/api/dashboard/connectors` | diff --git a/documentation/14-databaze.md b/documentation/14-databaze.md new file mode 100644 index 0000000..c1ae772 --- /dev/null +++ b/documentation/14-databaze.md @@ -0,0 +1,184 @@ +# 14 - Databaze + +Naprogramovano a overeno proti Postgresu 16. Zatim se do databaze ukladaji +**konektory**, tedy pristupove udaje k sluzbam. Zbytek je v pameti procesu, +poradi dalsich kroku je na konci. + +## Databaze je volitelna, ale rezimy jsou oddelene + +Aplikace jede ve dvou rezimech a rozdil se resi **na jednom miste**, +v `src/data/connectorStore.ts`. Nikde jinde se nezjistuje, jestli databaze je - +kdyby se to rozlezlo po kodu, jedno misto by se zapomnelo. + +| Rezim | Kdy | Prezije restart | +| ---------- | --------------------------------------- | --------------- | +| `postgres` | je `DATABASE_URL` **i** `SECRETS_KEY` | ano | +| `memory` | jinak | ne | + +Chybejici databaze **nesmi shodit start**. Container, ktery nenastartuje, je pro +AppFactory nefunkcni sluzba (AGENTS.md). Misto toho se do logu napise, proc se +jede v pameti, a portal to ukaze na strance Konektory. + +Databaze potrebuje **oboji**. Bez klice by se pristupove udaje ukladaly +v plaintextu, a to je horsi nez ztratit je pri restartu - tabulku vidi kazda +zaloha a kazdy dump pri ladeni. + +Stejne tak: kdyz jsou migrace nastavene, ale selzou, jede se dal v pameti. +Psat do rozbiteho schematu je horsi nez neukladat. + +## Promenne + +| Promenna | K cemu | +| ------------------- | ---------------------------------------------------------- | +| `DATABASE_URL` | `postgres://uzivatel:heslo@host:5432/csbot` | +| `SECRETS_KEY` | klic pro sifrovani pristupovych udaju, **secret** | +| `DATABASE_POOL_MAX` | kolik spojeni si vezme jedna instance, vychozi 10 | +| `DATABASE_SSL` | `true` u spravovanych databazi, ktere vyzaduji TLS | + +`SECRETS_KEY` ma byt nahodny retezec, ne heslo: + +```bash +node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))" +``` + +**Klic se nesmi ztratit ani zmenit bez prevodu dat.** Bez nej se ulozene udaje +nerozsifruji. Nic se nerozbije, jen se konektory chovaji jako nevyplnene +a v logu je napsane proc - udaje se pak zadaji znovu. + +## Lokalni spusteni + +```bash +docker run -d --name csbot-pg -p 5433:5432 \ + -e POSTGRES_PASSWORD=devpass -e POSTGRES_DB=csbot postgres:16-alpine + +export DATABASE_URL="postgres://postgres:devpass@127.0.0.1:5433/csbot" +export SECRETS_KEY="$(node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))")" +npm run dev +``` + +Migrace se pousti samy pri startu. Kontrola, ze to jede z databaze: + +```bash +curl -s http://localhost:3000/health/ready +``` + +Bez promennych `npm run dev` funguje dal, jen v pameti. + +## Migrace + +Soubory `src/db/migrations/*.sql`, v abecednim poradi, kazdy jednou. +Co uz proslo, je v tabulce `schema_migrations`. + +Dve veci, na kterych to stoji: + +- **Poradovy zamek.** Pri rolling deployi startuje vic instanci naraz a bez + `pg_advisory_lock` by migrace pustily vsechny. +- **Jeden soubor je jedna transakce.** Pri chybe se z nej neuplatni nic, takze + nevznikne rozdelane schema, o kterem nikdo nevi. + +**Migrace se nikdy neupravuji zpetne.** Uz projely u nekoho jineho, takze zmena +souboru znamena dve rozdilna schemata se stejnym cislem. Oprava je vzdy novy +soubor. + +## Sifrovani pristupovych udaju + +`src/db/secretBox.ts`, AES-256-GCM. + +```json +{ "v": 1, "iv": "...", "tag": "...", "data": "..." } +``` + +- **GCM**, ne CBC: sifruje a zaroven overuje, ze s daty nikdo nehybal. +- **Nahodne IV** pro kazdou hodnotu, aby dve stejne hodnoty nedaly stejnou sifru. +- **`v` je verze klice.** Vymena klice pak znamena precist starym a zapsat novym, + ne zahodit vsechna napojeni. +- **Nerozsifrovatelna hodnota nepada.** Jeden rozbity konektor nesmi shodit + seznam vsech ostatnich, takze se chova jako nevyplneny a zaloguje se to. + +Sifruji se vsechna pole, i necitliva. Je to jednodussi nez rozhodovat u kazdeho +zvlast a nic to nestoji. + +## Schema + +`connectors` (migrace `001_connectors.sql`): + +| Sloupec | Poznamka | +| ------------- | ------------------------------------------------- | +| `tenant_id` | povinne, index zacina jim | +| `service_id` | odkaz do katalogu v kodu, ne do tabulky | +| `secrets` | JSONB se sifrovanymi hodnotami, nikdy plaintext | +| `is_default` | jediny vychozi na firmu a sluzbu, hlida index | + +**Sluzby v databazi nejsou.** Jsou to definice, ktere delame my, a repo je u nich +zdroj pravdy kvuli code review a historii v gitu. Rucne upraveny radek v produkci +nikdo za tri mesice nedohleda. Duvody jsou v [12-sluzby-a-konektory.md](12-sluzby-a-konektory.md). + +Vychozi konektor hlida **castecny unikatni index**, ne jen kod: + +```sql +CREATE UNIQUE INDEX connectors_one_default_idx + ON connectors (tenant_id, service_id) WHERE is_default; +``` + +Bez nej by dva soubezne zapisy udelaly dva vychozi a krok bez vybraneho +konektoru by si vybiral podle nahody. + +## Dvere k oddelene databazi + +`dbFor(tenantId)` dnes vraci vzdy tentyz pool. Je to zamerny sev: jednou prijde +klient, ktery bude chtit vlastni databazi nebo bude delat tricet procent provozu, +a presun ma byt konfigurace, ne prepisovani dotazu. + +Podminka je **nikdy nespojovat dotazem dva klienty**, coz uz vynucuje povinny +argument `tenantIds` v ulozistich. Podrobnosti +v [10-runtime-a-kapacita.md](10-runtime-a-kapacita.md). + +## Health + +| Endpoint | Zavisi na DB | K cemu | +| --------------- | ------------ | ----------------------------------------- | +| `/health` | ne | liveness, AppFactory podle nej restartuje | +| `/health/ready` | ano | readiness, vraci 503 pri nedostupne DB | + +`/health` **nesmi** na databazi zavisel. Kratky vypadek DB by jinak vedl +k restartovani containeru, coz nic nespravi. Vysledek pingu se par sekund +cachuje, aby monitoring nedelal dotaz pri kazdem pingu. + +## Pool a jedno pravidlo + +`DATABASE_POOL_MAX` je vychozi 10 a je to zamerne malo. **Worker nesmi drzet +spojeni po dobu volani ciziho API** - volani do iDokladu trva 300 ms a pri +stovce soubeznych kroku by to bylo sto obsazenych spojeni. Se spravnym poradim +(odeber ulohu, uvolni spojeni, volej, zapis) staci par. + +Az bude instanci vic, prijde PgBouncer v transakcnim rezimu. **Pozor: v nem +nefunguje `LISTEN/NOTIFY`**, na kterem ma stat sbernice udalosti pro SSE. +Ta pak potrebuje prime spojeni mimo PgBouncer. + +## Co bylo overeno + +Proti Postgresu 16 v kontejneru: + +| Co | Vysledek | +| ----------------------------------------------------- | -------- | +| Migrace projedou a zapisou se do `schema_migrations` | ano | +| Udaje jsou v tabulce sifrovane, plaintext nikde | ano | +| Konektor prezije restart procesu | ano | +| Se spravnym klicem se udaje rozsifruji | ano | +| Se spatnym klicem se chovaji jako nevyplnene a loguje se | ano | +| `PATCH` bez tajneho pole tajne pole nesmaze | ano | +| Prepnuti vychoziho konektoru | ano | +| Smazani vychoziho preda priznak zbylemu | ano | +| Bez `DATABASE_URL` jede pametovy rezim a rekne to | ano | + +## Co chybi + +| Chybi | Poznamka | +| ---------------------------- | ----------------------------------------------------- | +| Automatizace v databazi | dalsi na rade, je to to, co si clovek nastavi | +| Rozlozeni dashboardu | male a samostatne, hned po automatizacich | +| Tickety a incidenty | naposled, dnes je generuje simulace | +| Uzivatele, firmy, resitele | v prototypu je to spis konfigurace nez data | +| Sbernice udalosti pres LISTEN/NOTIFY | dnes `EventEmitter` v pameti jedne instance | +| Vymena klice (rotace) | `v` je pripravene, prevod dat napsany neni | +| Retence a partitionovani | az u tabulek behu, viz dokument 10 | diff --git a/documentation/99-zmeny.md b/documentation/99-zmeny.md index 6bf1a57..6863abd 100644 --- a/documentation/99-zmeny.md +++ b/documentation/99-zmeny.md @@ -2,6 +2,45 @@ Nejnovejsi nahore. +## 2026-08-12 - databaze pro konektory + +Konektory se ukladaji do Postgresu, pristupove udaje sifrovane. +Popis v [14-databaze.md](14-databaze.md). + +### Pridano + +- `pg` jako zavislost, pool v `src/db/pool.ts` vcetne transakci a `dbFor(tenantId)` + jako sev pro budouci oddelenou databazi jednoho klienta. +- Migrace ze souboru `src/db/migrations/*.sql`, pousti se pri startu pod + `pg_advisory_lock` - pri rolling deployi je jinak pusti vsechny instance naraz. + Jeden soubor je jedna transakce, takze pri chybe nevznikne rozdelane schema. +- Sifrovani pristupovych udaju (`src/db/secretBox.ts`), AES-256-GCM s nahodnym + IV a verzi klice. Nerozsifrovatelna hodnota nepada, chova se jako nevyplnena + a zaloguje se - jeden rozbity konektor nesmi shodit seznam ostatnich. +- Dve implementace uloziste konektoru za jednim rozhranim (`memory`, `postgres`). + Rozhodnuti je jen na jednom miste, v `src/data/connectorStore.ts`. +- `/health/ready` s pingem do databaze. `/health` na databazi zamerne nezavisi: + kratky vypadek DB by jinak vedl k restartovani containeru. +- `GET /api/dashboard/storage` a hlaska na strance Konektory o tom, ze data jsou + jen v pameti. Bez toho se clovek divi, kam se podely jeho konektory. +- Jediny vychozi konektor na firmu a sluzbu hlida castecny unikatni index, + ne jen kod. Dva soubezne zapisy by jinak udelaly dva vychozi. + +### Zmeneno + +- Cteni i zapis konektoru je asynchronni, vcetne validace stromu. +- Databaze je volitelna. Bez `DATABASE_URL` nebo `SECRETS_KEY` se jede v pameti + a rekne se to v logu i v portalu. Container, ktery nenastartuje, je pro + AppFactory nefunkcni sluzba. +- Kdyz jsou migrace nastavene a selzou, jede se dal v pameti. Psat do rozbiteho + schematu je horsi nez neukladat. + +### Overeno + +Proti Postgresu 16 v kontejneru: migrace, sifrovani v tabulce, preziti restartu, +rozsifrovani spravnym klicem, degradace pri spatnem klici, `PATCH` bez tajneho +pole, prepnuti a smazani vychoziho konektoru, a pametovy rezim bez `DATABASE_URL`. + ## 2026-08-12 - transformace dat a oprava konektoru Transformace dat popsana v [13-transformace-dat.md](13-transformace-dat.md). diff --git a/package-lock.json b/package-lock.json index a5951b8..3afafe7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "cors": "^2.8.5", "express": "^4.21.2", "jsonwebtoken": "^9.0.2", + "pg": "^8.23.0", "swagger-ui-express": "^5.0.1", "zod": "^3.24.1" }, @@ -22,6 +23,7 @@ "@types/express": "^4.17.21", "@types/jsonwebtoken": "^9.0.7", "@types/node": "^22.10.5", + "@types/pg": "^8.21.0", "@types/react": "^18.3.18", "@types/react-dom": "^18.3.5", "@types/swagger-ui-express": "^4.1.7", @@ -1618,6 +1620,18 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/pg": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.21.0.tgz", + "integrity": "sha512-AYdtudzabjLZgVgRZmAnU8bAnVUXzuJX2IYHeSIiIHm68olD+LgQYCGWdtcNYnP0uq9c4S4NibVG3Ni7VbKW7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", @@ -3220,6 +3234,95 @@ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "license": "MIT" }, + "node_modules/pg": { + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz", + "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.16.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz", + "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3269,6 +3372,45 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -3659,6 +3801,15 @@ "node": ">=0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -4488,6 +4639,15 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index cab0b7d..f6e5f29 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "cors": "^2.8.5", "express": "^4.21.2", "jsonwebtoken": "^9.0.2", + "pg": "^8.23.0", "swagger-ui-express": "^5.0.1", "zod": "^3.24.1" }, @@ -29,6 +30,7 @@ "@types/express": "^4.17.21", "@types/jsonwebtoken": "^9.0.7", "@types/node": "^22.10.5", + "@types/pg": "^8.21.0", "@types/react": "^18.3.18", "@types/react-dom": "^18.3.5", "@types/swagger-ui-express": "^4.1.7", diff --git a/src/config.ts b/src/config.ts index dd41def..f1fe905 100644 --- a/src/config.ts +++ b/src/config.ts @@ -75,6 +75,37 @@ export const config = { */ publicOrigin: (process.env.PUBLIC_ORIGIN ?? '').trim().replace(/\/+$/, ''), + // ------------------------------------------------------------------ databaze + + /** + * Pripojeni do Postgresu, napr. postgres://user:pass@host:5432/csbot + * + * Prazdna hodnota je platny stav: aplikace jede v pameti procesu. Container, + * ktery nenastartuje kvuli chybejici promenne, je pro AppFactory nefunkcni + * sluzba (AGENTS.md), takze se na tom nepada. + */ + databaseUrl: (process.env.DATABASE_URL ?? '').trim(), + /** + * Klic pro sifrovani pristupovych udaju konektoru. + * + * Ma to byt nahodny retezec, ne heslo. Jak ho vygenerovat je + * v documentation/14-databaze.md. + * + * Bez nej se konektory neukladaji do databaze ani kdyz je nastavena - + * plaintext v tabulce je horsi nez ztrata dat pri restartu. + */ + secretsKey: (process.env.SECRETS_KEY ?? '').trim(), + /** + * Kolik spojeni si smi vzit jedna instance. + * + * Nizke cislo je zamer: worker nesmi drzet spojeni po dobu volani ciziho API, + * takze i pri stovce soubeznych kroku staci par spojeni. Podrobnosti + * v documentation/10-runtime-a-kapacita.md. + */ + databasePoolMax: positiveNumber(process.env.DATABASE_POOL_MAX, 10), + /** Spravovane databaze vyzaduji TLS. */ + databaseSsl: process.env.DATABASE_SSL === 'true', + // ------------------------------------------------------- skripty konektoru /** diff --git a/src/data/connectorStore.ts b/src/data/connectorStore.ts index 28fc21e..bce875c 100644 --- a/src/data/connectorStore.ts +++ b/src/data/connectorStore.ts @@ -1,104 +1,120 @@ /** - * Konektory = napojeni jedne firmy na jednu sluzbu. + * Konektory: vyber uloziste a jedine misto, kde se rozhoduje o rezimu. * - * Sluzba (`services.ts`) rika, co je potreba: "iDoklad chce hlavicky - * X-ClientId a X-ClientSecret". Konektor drzi hodnoty. Kazda firma ma svoje. + * Dve implementace, jedno rozhrani: + * - `postgres` kdyz je `DATABASE_URL` a `SECRETS_KEY`, + * - `memory` jinak, tedy pri mockupu a lokalnim vyvoji bez databaze. * - * Proto **pristupove udaje nejsou v environment variables**. Kdyby byly, - * mela by je cela instance spolecne a dve firmy by fakturovaly ze stejneho - * uctu. Napojeni je vlastnost firmy, ne prostredi. + * Rozhodnuti je **jen tady**. Nikde jinde se nezjistuje, jestli databaze je - + * kdyby se to rozlezlo po kodu, jedno misto by se zapomnelo a chovalo by se + * pak jinak nez zbytek. * - * POZOR: data jsou v pameti procesu, restart je vrati na vychozi sadu. - * Cilovy stav je Postgres se sifrovanymi hodnotami, viz - * documentation/09-navrh-rozsireni.md, bod 9. + * Databaze potrebuje **oboji**. Bez klice by se pristupove udaje ukladaly + * v plaintextu a to je horsi nez ztratit je pri restartu, proto se v tom + * pripade jede dal v pameti a rekne se to nahlas. */ -import { randomUUID } from 'node:crypto'; -import { findService, type Service, type ServiceCredentialField } from './services.js'; +import { canSealSecrets } from '../db/secretBox.js'; +import { isDatabaseEnabled } from '../db/pool.js'; +import { memoryConnectors, seedMemoryConnectors } from './connectors/memory.js'; +import { postgresConnectors } from './connectors/postgres.js'; +import type { + Connector, + ConnectorRepository, + CreateConnectorInput, + UpdateConnectorInput, +} from './connectors/types.js'; -export interface Connector { - id: string; - tenantId: string; - serviceId: string; - /** Firma muze mit dva ucty teze sluzby, proto vlastni nazev. */ - name: string; - /** Prepis adresy sluzby. null = vychozi podle `appId` a SERVICES_BASE_URL. */ - baseUrl: string | null; - /** Hodnoty poli z `Service.credentials`. Tajne se z API nikdy nevraci. */ - values: Record; - enabled: boolean; - /** untested = jeste se neoverovalo, ok = posledni overeni proslo. */ - status: 'untested' | 'ok' | 'error'; - lastCheckAt: string | null; - lastError: string | null; - /** Krok stromu bez vybraneho konektoru pouzije vychozi. */ - isDefault: boolean; - createdAt: string; - updatedAt: string; +export type { + Connector, + ConnectorIssue, + CreateConnectorInput, + PublicConnector, + UpdateConnectorInput, +} from './connectors/types.js'; +export { toPublicConnector, validateConnectorValues } from './connectors/types.js'; + +export type StorageMode = 'postgres' | 'memory'; + +export interface StorageStatus { + mode: StorageMode; + /** Proc se jede v pameti. null u databaze. */ + reason: string | null; + /** true = restart procesu data smaze. */ + ephemeral: boolean; +} + +let repository: ConnectorRepository = memoryConnectors; +let status: StorageStatus = { + mode: 'memory', + reason: 'Databáze není nastavená.', + ephemeral: true, +}; + +export interface InitOptions { + /** + * false = databaze je nastavena, ale neni pouzitelna (napr. selhaly migrace). + * Pak se jede v pameti, protoze psat do rozbiteho schematu je horsi. + */ + databaseReady?: boolean; } /** - * Verze pro klienta. **Tajne hodnoty tady nejsou** a nikdy nesmi byt - - * secrets se nevraci z beznych endpointu (AGENTS.md). + * Vybere uloziste. Vola se jednou pri startu, po migracich. + * Volani je idempotentni, at jde zavolat i z testu. */ -export interface PublicConnector { - id: string; - tenantId: string; - serviceId: string; - name: string; - baseUrl: string | null; - enabled: boolean; - status: Connector['status']; - lastCheckAt: string | null; - lastError: string | null; - isDefault: boolean; - createdAt: string; - updatedAt: string; - /** ID poli, ktera jsou vyplnena. Hodnoty se nevraci. */ - filled: string[]; - /** ID povinnych poli, ktera jeste chybi. */ - missing: string[]; - /** Necitliva nastaveni. Tajna pole tu nejsou vubec. */ - config: Record; - /** true = vsechna povinna pole jsou vyplnena, jde volat. */ - ready: boolean; -} +export async function initConnectorStore(options: InitOptions = {}): Promise { + const databaseReady = options.databaseReady ?? true; -const connectors: Connector[] = []; - -function now(): string { - return new Date().toISOString(); -} - -/** Povinna pole, ktera nejsou vyplnena. */ -function missingFields(service: Service, values: Record): string[] { - return service.credentials - .filter((field) => field.required && (values[field.id] ?? '').trim() === '') - .map((field) => field.id); -} - -export function toPublicConnector(connector: Connector): PublicConnector { - const service = findService(connector.serviceId); - const credentials = service?.credentials ?? []; - - const filled = Object.entries(connector.values) - .filter(([, value]) => value.trim() !== '') - .map(([key]) => key); - - const config: Record = {}; - for (const field of credentials) { - if (field.secret) continue; - const value = connector.values[field.id]; - if (value !== undefined && value.trim() !== '') config[field.id] = value; + if (isDatabaseEnabled() && !databaseReady) { + repository = memoryConnectors; + status = { + mode: 'memory', + reason: 'Databáze je nastavená, ale schéma není použitelné. Zkontrolujte log migrací.', + ephemeral: true, + }; + console.error(`[storage] ${status.reason}`); + await seedMemoryConnectors(); + return status; } - const missing = service ? missingFields(service, connector.values) : []; + if (isDatabaseEnabled() && canSealSecrets()) { + repository = postgresConnectors; + status = { mode: 'postgres', reason: null, ephemeral: false }; + console.info('[storage] konektory: Postgres, přístupové údaje šifrované'); + return status; + } - const { values: _values, ...rest } = connector; - return { ...rest, filled, missing, config, ready: missing.length === 0 }; + repository = memoryConnectors; + + if (isDatabaseEnabled() && !canSealSecrets()) { + status = { + mode: 'memory', + reason: + 'Databáze je nastavená, ale chybí SECRETS_KEY. Přístupové údaje by se ukládaly ' + + 'v plaintextu, takže konektory zůstávají v paměti.', + ephemeral: true, + }; + console.error(`[storage] ${status.reason}`); + } else { + status = { + mode: 'memory', + reason: 'Databáze není nastavená (DATABASE_URL). Restart procesu data smaže.', + ephemeral: true, + }; + console.warn(`[storage] ${status.reason}`); + } + + // Ukazkovy konektor jen v pametovem rezimu. V databazi by se delal pri kazdem startu. + await seedMemoryConnectors(); + return status; } -// ------------------------------------------------------------------- cteni +export function storageStatus(): StorageStatus { + return status; +} + +// ------------------------------------------------- pruchod do zvoleneho uloziste /** * Filtr na firmu je **povinny argument**, ne volitelny. Zapomenuty filtr tak @@ -107,18 +123,13 @@ export function toPublicConnector(connector: Connector): PublicConnector { export function listConnectors( tenantIds: string[], options: { serviceId?: string } = {}, -): Connector[] { - return connectors - .filter((connector) => tenantIds.includes(connector.tenantId)) - .filter((connector) => !options.serviceId || connector.serviceId === options.serviceId) - .sort((a, b) => a.name.localeCompare(b.name, 'cs')); +): Promise { + return repository.list(tenantIds, options); } /** Cizi konektor se chova jako neexistujici, tedy undefined, ne chyba prava. */ -export function getConnector(id: string, tenantIds: string[]): Connector | undefined { - const connector = connectors.find((item) => item.id === id); - if (!connector) return undefined; - return tenantIds.includes(connector.tenantId) ? connector : undefined; +export function getConnector(id: string, tenantIds: string[]): Promise { + return repository.get(id, tenantIds); } /** @@ -126,161 +137,32 @@ export function getConnector(id: string, tenantIds: string[]): Connector | undef * Krok stromu bez vybraneho konektoru pouzije tenhle, diky tomu je vzorovy * strom prenositelny mezi firmami. */ -export function defaultConnectorFor(tenantId: string, serviceId: string): Connector | undefined { - const forService = connectors.filter( - (connector) => - connector.tenantId === tenantId && connector.serviceId === serviceId && connector.enabled, - ); - return forService.find((connector) => connector.isDefault) ?? forService[0]; +export function defaultConnectorFor( + tenantId: string, + serviceId: string, +): Promise { + return repository.defaultFor(tenantId, serviceId); } /** Kolik konektoru ma firma na kterou sluzbu. Podle toho se kresli stav v katalogu. */ -export function connectorCountsByService(tenantIds: string[]): Map { - const counts = new Map(); - for (const connector of listConnectors(tenantIds)) { - counts.set(connector.serviceId, (counts.get(connector.serviceId) ?? 0) + 1); - } - return counts; +export function connectorCountsByService(tenantIds: string[]): Promise> { + return repository.countsByService(tenantIds); } -// ------------------------------------------------------------------- validace - -export interface ConnectorIssue { - field: string; - message: string; -} - -/** - * Overi hodnoty proti tomu, co sluzba vyzaduje. - * - * Nevyplnene povinne pole **neni chyba** ukladani, ale nedodelek: konektor se - * ulozi a jen nepujde pouzit. Stejny rezim jako u rozdelane automatizace - - * rozdelana prace se nezahazuje. - * - * Chyba je jen pole, ktere sluzba vubec nema. To uz je rozbite napojeni. - */ -export function validateConnectorValues( - service: Service, - values: Record, -): ConnectorIssue[] { - const known = new Map( - service.credentials.map((field) => [field.id, field]), - ); - - const issues: ConnectorIssue[] = []; - for (const key of Object.keys(values)) { - if (!known.has(key)) { - issues.push({ field: key, message: `Služba ${service.name} pole „${key}" nemá.` }); - } - } - return issues; -} - -// -------------------------------------------------------------------- zapis - -export interface CreateConnectorInput { - tenantId: string; - serviceId: string; - name: string; - baseUrl?: string | null; - values?: Record; - isDefault?: boolean; -} - -export function createConnector(input: CreateConnectorInput): Connector { - const timestamp = now(); - const existing = listConnectors([input.tenantId], { serviceId: input.serviceId }); - - const connector: Connector = { - id: `con_${randomUUID().slice(0, 8)}`, - tenantId: input.tenantId, - serviceId: input.serviceId, - name: input.name, - baseUrl: input.baseUrl ?? null, - values: { ...(input.values ?? {}) }, - enabled: true, - status: 'untested', - lastCheckAt: null, - lastError: null, - // Prvni konektor na sluzbu je vychozi, jinak by krok bez vyberu nemel co vzit. - isDefault: input.isDefault ?? existing.length === 0, - createdAt: timestamp, - updatedAt: timestamp, - }; - - if (connector.isDefault) clearDefaults(input.tenantId, input.serviceId); - connectors.push(connector); - console.info(`[connectors] zalozen ${connector.id} (${connector.serviceId}) pro ${connector.tenantId}`); - return connector; -} - -function clearDefaults(tenantId: string, serviceId: string): void { - for (const connector of connectors) { - if (connector.tenantId === tenantId && connector.serviceId === serviceId) { - connector.isDefault = false; - } - } -} - -export interface UpdateConnectorInput { - name?: string; - baseUrl?: string | null; - /** - * Jen pole, ktera se meni. Prazdny retezec hodnotu **smaze**, chybejici klic - * ji nechá. Diky tomu jde ulozit formular, ktery tajne hodnoty neposila. - */ - values?: Record; - enabled?: boolean; - isDefault?: boolean; +export function createConnector(input: CreateConnectorInput): Promise { + return repository.create(input); } export function updateConnector( id: string, patch: UpdateConnectorInput, tenantIds: string[], -): Connector | undefined { - const connector = getConnector(id, tenantIds); - if (!connector) return undefined; - - if (patch.name !== undefined) connector.name = patch.name; - if (patch.baseUrl !== undefined) connector.baseUrl = patch.baseUrl; - if (patch.enabled !== undefined) connector.enabled = patch.enabled; - - if (patch.values) { - for (const [key, value] of Object.entries(patch.values)) { - if (value === '') delete connector.values[key]; - else connector.values[key] = value; - } - // Zmena udaju znamena, ze predchozi overeni uz nic nerika. - connector.status = 'untested'; - connector.lastError = null; - } - - if (patch.isDefault === true) { - clearDefaults(connector.tenantId, connector.serviceId); - connector.isDefault = true; - } - - connector.updatedAt = now(); - return connector; +): Promise { + return repository.update(id, patch, tenantIds); } -export function deleteConnector(id: string, tenantIds: string[]): boolean { - const connector = getConnector(id, tenantIds); - if (!connector) return false; - - const index = connectors.indexOf(connector); - connectors.splice(index, 1); - - // Kdyz zmizel vychozi, prevezme to prvni zbyly - jinak by kroky bez vyberu - // prestaly fungovat, aniz by se cokoliv jineho zmenilo. - if (connector.isDefault) { - const next = listConnectors([connector.tenantId], { serviceId: connector.serviceId })[0]; - if (next) next.isDefault = true; - } - - console.info(`[connectors] smazan ${id}`); - return true; +export function deleteConnector(id: string, tenantIds: string[]): Promise { + return repository.remove(id, tenantIds); } /** Vysledek overeni napojeni. Zapisuje ho endpoint pro test. */ @@ -289,27 +171,6 @@ export function setConnectorStatus( status: Connector['status'], error: string | null, tenantIds: string[], -): Connector | undefined { - const connector = getConnector(id, tenantIds); - if (!connector) return undefined; - - connector.status = status; - connector.lastError = error; - connector.lastCheckAt = now(); - connector.updatedAt = connector.lastCheckAt; - return connector; +): Promise { + return repository.setStatus(id, status, error, tenantIds); } - -// -------------------------------------------------------------- vychozi sada - -/** - * Jeden ukazkovy konektor bez vyplnenych udaju. - * - * Zamerne bez nich: ukazuje presne ten stav, ve kterem konektor vznikne, tedy - * "sluzba je napojena, ale chybi pristupove udaje". Vyplnit je jde v portalu. - */ -createConnector({ - tenantId: 'tnt_automia', - serviceId: 'idoklad', - name: 'iDoklad Automia', -}); diff --git a/src/data/connectors/memory.ts b/src/data/connectors/memory.ts new file mode 100644 index 0000000..ce7008e --- /dev/null +++ b/src/data/connectors/memory.ts @@ -0,0 +1,176 @@ +/** + * Konektory v pameti procesu. + * + * Rezim pro mockup a pro lokalni vyvoj bez databaze. **Restart procesu data + * smaze**, vcetne pristupovych udaju. Pri vyvoji s `tsx watch` to znamena + * po kazde uprave kodu, takze je to potreba mit na pameti. + * + * Podle stejneho rozhrani jede i `postgres.ts`. Rozdil se resi jen pri vyberu + * uloziste v `../connectorStore.ts`. + */ + +import { randomUUID } from 'node:crypto'; +import { + missingFields, + nowIso, + type Connector, + type ConnectorRepository, + type CreateConnectorInput, + type UpdateConnectorInput, +} from './types.js'; +import { findService } from '../services.js'; + +const rows: Connector[] = []; + +function byTenant(tenantIds: string[]): Connector[] { + return rows.filter((row) => tenantIds.includes(row.tenantId)); +} + +function clearDefaults(tenantId: string, serviceId: string): void { + for (const row of rows) { + if (row.tenantId === tenantId && row.serviceId === serviceId) row.isDefault = false; + } +} + +export const memoryConnectors: ConnectorRepository = { + kind: 'memory', + + async list(tenantIds, options = {}) { + return byTenant(tenantIds) + .filter((row) => !options.serviceId || row.serviceId === options.serviceId) + .sort((a, b) => a.name.localeCompare(b.name, 'cs')) + .map((row) => ({ ...row, values: { ...row.values } })); + }, + + async get(id, tenantIds) { + const row = rows.find((item) => item.id === id); + // Cizi konektor se chova jako neexistujici, ne jako chyba prava. + if (!row || !tenantIds.includes(row.tenantId)) return undefined; + return { ...row, values: { ...row.values } }; + }, + + async defaultFor(tenantId, serviceId) { + const forService = rows.filter( + (row) => row.tenantId === tenantId && row.serviceId === serviceId && row.enabled, + ); + const chosen = forService.find((row) => row.isDefault) ?? forService[0]; + return chosen ? { ...chosen, values: { ...chosen.values } } : undefined; + }, + + async countsByService(tenantIds) { + const counts = new Map(); + for (const row of byTenant(tenantIds)) { + counts.set(row.serviceId, (counts.get(row.serviceId) ?? 0) + 1); + } + return counts; + }, + + async create(input: CreateConnectorInput) { + const timestamp = nowIso(); + const existing = rows.filter( + (row) => row.tenantId === input.tenantId && row.serviceId === input.serviceId, + ); + + const connector: Connector = { + id: `con_${randomUUID().slice(0, 8)}`, + tenantId: input.tenantId, + serviceId: input.serviceId, + name: input.name, + baseUrl: input.baseUrl ?? null, + values: { ...(input.values ?? {}) }, + enabled: true, + status: 'untested', + lastCheckAt: null, + lastError: null, + // Prvni konektor na sluzbu je vychozi, jinak by krok bez vyberu nemel co vzit. + isDefault: input.isDefault ?? existing.length === 0, + createdAt: timestamp, + updatedAt: timestamp, + }; + + if (connector.isDefault) clearDefaults(input.tenantId, input.serviceId); + rows.push(connector); + return { ...connector, values: { ...connector.values } }; + }, + + async update(id, patch: UpdateConnectorInput, tenantIds) { + const row = rows.find((item) => item.id === id); + if (!row || !tenantIds.includes(row.tenantId)) return undefined; + + if (patch.name !== undefined) row.name = patch.name; + if (patch.baseUrl !== undefined) row.baseUrl = patch.baseUrl; + if (patch.enabled !== undefined) row.enabled = patch.enabled; + + if (patch.values) { + for (const [key, value] of Object.entries(patch.values)) { + if (value === '') delete row.values[key]; + else row.values[key] = value; + } + // Zmena udaju znamena, ze predchozi overeni uz nic nerika. + row.status = 'untested'; + row.lastError = null; + } + + if (patch.isDefault === true) { + clearDefaults(row.tenantId, row.serviceId); + row.isDefault = true; + } + + row.updatedAt = nowIso(); + return { ...row, values: { ...row.values } }; + }, + + async remove(id, tenantIds) { + const row = rows.find((item) => item.id === id); + if (!row || !tenantIds.includes(row.tenantId)) return false; + + rows.splice(rows.indexOf(row), 1); + + // Kdyz zmizel vychozi, prevezme to prvni zbyly - jinak by kroky bez vyberu + // prestaly fungovat, aniz by se cokoliv jineho zmenilo. + if (row.isDefault) { + const next = rows.find( + (item) => item.tenantId === row.tenantId && item.serviceId === row.serviceId, + ); + if (next) next.isDefault = true; + } + return true; + }, + + async setStatus(id, status, error, tenantIds) { + const row = rows.find((item) => item.id === id); + if (!row || !tenantIds.includes(row.tenantId)) return undefined; + + row.status = status; + row.lastError = error; + row.lastCheckAt = nowIso(); + row.updatedAt = row.lastCheckAt; + return { ...row, values: { ...row.values } }; + }, +}; + +/** + * Ukazkovy konektor bez vyplnenych udaju. + * + * Zamerne bez nich: ukazuje presne ten stav, ve kterem konektor vznikne, tedy + * "sluzba je napojena, ale chybi pristupove udaje". Zaklada se jen v pametovem + * rezimu - v databazi by se pri kazdem startu delal znovu. + */ +export async function seedMemoryConnectors(): Promise { + if (rows.length > 0) return; + if (!findService('idoklad')) return; + + await memoryConnectors.create({ + tenantId: 'tnt_automia', + serviceId: 'idoklad', + name: 'iDoklad Automia', + }); +} + +/** Jen pro diagnostiku: kolik povinnych poli chybi napric vsemi konektory. */ +export function memoryMissingCount(): number { + return rows.reduce((total, row) => { + const service = findService(row.serviceId); + return total + (service ? missingFields(service, row.values).length : 0); + }, 0); +} diff --git a/src/data/connectors/postgres.ts b/src/data/connectors/postgres.ts new file mode 100644 index 0000000..dbbfe87 --- /dev/null +++ b/src/data/connectors/postgres.ts @@ -0,0 +1,248 @@ +/** + * Konektory v Postgresu. + * + * Rozdil proti pametovemu ulozisti je jen v tomhle souboru. Volajici nepozna, + * ktere uloziste je zapnute - obe maji stejne rozhrani. + * + * **Tajne hodnoty se sifruji.** Do tabulky nikdy nesmi plaintext, jinak by + * kazda zaloha a kazdy dump pri ladeni nesl klientske klice. Necitliva pole + * (napr. jazyk odpovedi) se sifruji taky - je to jednodussi nez rozhodovat + * u kazdeho pole a nic to nestoji. + */ + +import { randomUUID } from 'node:crypto'; +import { query, queryOne, transaction } from '../../db/pool.js'; +import { openAll, sealAll } from '../../db/secretBox.js'; +import type { + Connector, + ConnectorRepository, + CreateConnectorInput, + UpdateConnectorInput, +} from './types.js'; + +interface ConnectorRow { + id: string; + tenant_id: string; + service_id: string; + name: string; + base_url: string | null; + secrets: unknown; + enabled: boolean; + status: string; + last_check_at: Date | null; + last_error: string | null; + is_default: boolean; + created_at: Date; + updated_at: Date; +} + +function toConnector(row: ConnectorRow): Connector { + return { + id: row.id, + tenantId: row.tenant_id, + serviceId: row.service_id, + name: row.name, + baseUrl: row.base_url, + values: openAll(row.secrets), + enabled: row.enabled, + status: row.status as Connector['status'], + lastCheckAt: row.last_check_at ? row.last_check_at.toISOString() : null, + lastError: row.last_error, + isDefault: row.is_default, + createdAt: row.created_at.toISOString(), + updatedAt: row.updated_at.toISOString(), + }; +} + +const COLUMNS = ` + id, tenant_id, service_id, name, base_url, secrets, enabled, status, + last_check_at, last_error, is_default, created_at, updated_at +`; + +export const postgresConnectors: ConnectorRepository = { + kind: 'postgres', + + async list(tenantIds, options = {}) { + // Prazdny seznam firem znamena "nic", ne "vse". Bez teto vetve by + // `= ANY('{}')` sice nic nevratilo, ale spolehat se na to je past. + if (tenantIds.length === 0) return []; + + const rows = await query( + `SELECT ${COLUMNS} FROM connectors + WHERE tenant_id = ANY($1) + AND ($2::text IS NULL OR service_id = $2) + ORDER BY name`, + [tenantIds, options.serviceId ?? null], + ); + return rows.map(toConnector); + }, + + async get(id, tenantIds) { + if (tenantIds.length === 0) return undefined; + const row = await queryOne( + `SELECT ${COLUMNS} FROM connectors WHERE id = $1 AND tenant_id = ANY($2)`, + [id, tenantIds], + ); + return row ? toConnector(row) : undefined; + }, + + async defaultFor(tenantId, serviceId) { + // Vychozi vyhrava, jinak prvni podle nazvu. Vypnute konektory se neberou. + const row = await queryOne( + `SELECT ${COLUMNS} FROM connectors + WHERE tenant_id = $1 AND service_id = $2 AND enabled + ORDER BY is_default DESC, name + LIMIT 1`, + [tenantId, serviceId], + ); + return row ? toConnector(row) : undefined; + }, + + async countsByService(tenantIds) { + if (tenantIds.length === 0) return new Map(); + const rows = await query<{ service_id: string; count: string }>( + `SELECT service_id, count(*) AS count FROM connectors + WHERE tenant_id = ANY($1) + GROUP BY service_id`, + [tenantIds], + ); + return new Map(rows.map((row) => [row.service_id, Number(row.count)])); + }, + + async create(input: CreateConnectorInput) { + return transaction(async (client) => { + const existing = await client.query<{ count: string }>( + 'SELECT count(*) AS count FROM connectors WHERE tenant_id = $1 AND service_id = $2', + [input.tenantId, input.serviceId], + ); + // Prvni konektor na sluzbu je vychozi, jinak by krok bez vyberu nemel co vzit. + const isDefault = input.isDefault ?? Number(existing.rows[0]?.count ?? 0) === 0; + + // Jediny vychozi na dvojici firma a sluzba hlida i unikatni index. + // Uvolnit misto pred zapisem je proto nutne, ne jen uklidne. + if (isDefault) { + await client.query( + 'UPDATE connectors SET is_default = false WHERE tenant_id = $1 AND service_id = $2', + [input.tenantId, input.serviceId], + ); + } + + const row = await client.query( + `INSERT INTO connectors (id, tenant_id, service_id, name, base_url, secrets, is_default) + VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7) + RETURNING ${COLUMNS}`, + [ + `con_${randomUUID().slice(0, 8)}`, + input.tenantId, + input.serviceId, + input.name, + input.baseUrl ?? null, + JSON.stringify(sealAll(input.values ?? {})), + isDefault, + ], + ); + + return toConnector(row.rows[0]); + }); + }, + + async update(id, patch: UpdateConnectorInput, tenantIds) { + if (tenantIds.length === 0) return undefined; + + return transaction(async (client) => { + const current = await client.query( + `SELECT ${COLUMNS} FROM connectors WHERE id = $1 AND tenant_id = ANY($2) FOR UPDATE`, + [id, tenantIds], + ); + const row = current.rows[0]; + if (!row) return undefined; + + // Hodnoty se skladaji ze stavajicich a novych. Prazdny retezec maze, + // chybejici klic nechava - formular tajne hodnoty neposila. + let secrets = row.secrets; + let statusReset = false; + if (patch.values) { + const merged = openAll(row.secrets); + for (const [key, value] of Object.entries(patch.values)) { + if (value === '') delete merged[key]; + else merged[key] = value; + } + secrets = sealAll(merged); + statusReset = true; + } + + if (patch.isDefault === true) { + await client.query( + 'UPDATE connectors SET is_default = false WHERE tenant_id = $1 AND service_id = $2', + [row.tenant_id, row.service_id], + ); + } + + const updated = await client.query( + `UPDATE connectors SET + name = COALESCE($2, name), + base_url = CASE WHEN $3::boolean THEN $4 ELSE base_url END, + secrets = $5::jsonb, + enabled = COALESCE($6, enabled), + is_default = CASE WHEN $7::boolean THEN true ELSE is_default END, + status = CASE WHEN $8::boolean THEN 'untested' ELSE status END, + last_error = CASE WHEN $8::boolean THEN NULL ELSE last_error END, + updated_at = now() + WHERE id = $1 + RETURNING ${COLUMNS}`, + [ + id, + patch.name ?? null, + patch.baseUrl !== undefined, + patch.baseUrl ?? null, + JSON.stringify(secrets), + patch.enabled ?? null, + patch.isDefault === true, + statusReset, + ], + ); + + return toConnector(updated.rows[0]); + }); + }, + + async remove(id, tenantIds) { + if (tenantIds.length === 0) return false; + + return transaction(async (client) => { + const removed = await client.query<{ tenant_id: string; service_id: string; is_default: boolean }>( + 'DELETE FROM connectors WHERE id = $1 AND tenant_id = ANY($2) RETURNING tenant_id, service_id, is_default', + [id, tenantIds], + ); + const row = removed.rows[0]; + if (!row) return false; + + // Kdyz zmizel vychozi, prevezme to prvni zbyly - jinak by kroky bez vyberu + // prestaly fungovat, aniz by se cokoliv jineho zmenilo. + if (row.is_default) { + await client.query( + `UPDATE connectors SET is_default = true + WHERE id = ( + SELECT id FROM connectors + WHERE tenant_id = $1 AND service_id = $2 + ORDER BY name LIMIT 1 + )`, + [row.tenant_id, row.service_id], + ); + } + return true; + }); + }, + + async setStatus(id, status, error, tenantIds) { + if (tenantIds.length === 0) return undefined; + const row = await queryOne( + `UPDATE connectors + SET status = $3, last_error = $4, last_check_at = now(), updated_at = now() + WHERE id = $1 AND tenant_id = ANY($2) + RETURNING ${COLUMNS}`, + [id, tenantIds, status, error], + ); + return row ? toConnector(row) : undefined; + }, +}; diff --git a/src/data/connectors/types.ts b/src/data/connectors/types.ts new file mode 100644 index 0000000..87c3a0e --- /dev/null +++ b/src/data/connectors/types.ts @@ -0,0 +1,180 @@ +/** + * Konektor = napojeni jedne firmy na jednu sluzbu. + * + * Sluzba (`services.ts`) rika, co je potreba: "iDoklad chce hlavicky + * X-ClientId a X-ClientSecret". Konektor drzi hodnoty. Kazda firma ma svoje. + * + * Tenhle soubor je jen model a to, co plati v obou ulozistich. Samotna + * uloziste jsou vedle: `memory.ts` a `postgres.ts`. + */ + +import { findService, type Service, type ServiceCredentialField } from '../services.js'; + +export interface Connector { + id: string; + tenantId: string; + serviceId: string; + /** Firma muze mit dva ucty teze sluzby, proto vlastni nazev. */ + name: string; + /** Prepis adresy sluzby. null = vychozi podle `appId` a SERVICES_BASE_URL. */ + baseUrl: string | null; + /** Hodnoty poli z `Service.credentials`. Tajne se z API nikdy nevraci. */ + values: Record; + enabled: boolean; + /** untested = jeste se neoverovalo, ok = posledni overeni proslo. */ + status: 'untested' | 'ok' | 'error'; + lastCheckAt: string | null; + lastError: string | null; + /** Krok stromu bez vybraneho konektoru pouzije vychozi. */ + isDefault: boolean; + createdAt: string; + updatedAt: string; +} + +/** + * Verze pro klienta. **Tajne hodnoty tady nejsou** a nikdy nesmi byt - + * secrets se nevraci z beznych endpointu (AGENTS.md). + */ +export interface PublicConnector { + id: string; + tenantId: string; + serviceId: string; + name: string; + baseUrl: string | null; + enabled: boolean; + status: Connector['status']; + lastCheckAt: string | null; + lastError: string | null; + isDefault: boolean; + createdAt: string; + updatedAt: string; + /** ID poli, ktera jsou vyplnena. Hodnoty se nevraci. */ + filled: string[]; + /** ID povinnych poli, ktera jeste chybi. */ + missing: string[]; + /** Necitliva nastaveni. Tajna pole tu nejsou vubec. */ + config: Record; + /** true = vsechna povinna pole jsou vyplnena, jde volat. */ + ready: boolean; +} + +export interface CreateConnectorInput { + tenantId: string; + serviceId: string; + name: string; + baseUrl?: string | null; + values?: Record; + isDefault?: boolean; +} + +export interface UpdateConnectorInput { + name?: string; + baseUrl?: string | null; + /** + * Jen pole, ktera se meni. Prazdny retezec hodnotu **smaze**, chybejici klic + * ji nechava. Diky tomu jde ulozit formular, ktery tajne hodnoty neposila. + */ + values?: Record; + enabled?: boolean; + isDefault?: boolean; +} + +export interface ConnectorIssue { + field: string; + message: string; +} + +/** Rozhrani uloziste. Dve implementace, vyber je na jednom miste. */ +export interface ConnectorRepository { + readonly kind: 'memory' | 'postgres'; + list(tenantIds: string[], options?: { serviceId?: string }): Promise; + get(id: string, tenantIds: string[]): Promise; + defaultFor(tenantId: string, serviceId: string): Promise; + countsByService(tenantIds: string[]): Promise>; + create(input: CreateConnectorInput): Promise; + update( + id: string, + patch: UpdateConnectorInput, + tenantIds: string[], + ): Promise; + remove(id: string, tenantIds: string[]): Promise; + setStatus( + id: string, + status: Connector['status'], + error: string | null, + tenantIds: string[], + ): Promise; +} + +// ------------------------------------------------------------------- pomocne + +export function nowIso(): string { + return new Date().toISOString(); +} + +/** Povinna pole, ktera nejsou vyplnena. */ +export function missingFields(service: Service, values: Record): string[] { + return service.credentials + .filter((field) => field.required && (values[field.id] ?? '').trim() === '') + .map((field) => field.id); +} + +/** + * Prevod na verzi pro klienta. + * + * Jedno misto pro obe uloziste. Kdyby to bylo dvakrat, jednou by se zapomnelo + * vyhodit tajne hodnoty - a to je presne ta chyba, kterou nikdo nevidi. + */ +export function toPublicConnector(connector: Connector): PublicConnector { + const service = findService(connector.serviceId); + const credentials = service?.credentials ?? []; + + const filled = Object.entries(connector.values) + .filter(([, value]) => value.trim() !== '') + .map(([key]) => key); + + const config: Record = {}; + for (const field of credentials) { + if (field.secret) continue; + const value = connector.values[field.id]; + if (value !== undefined && value.trim() !== '') config[field.id] = value; + } + + const missing = service ? missingFields(service, connector.values) : []; + + const { values: _values, ...rest } = connector; + return { ...rest, filled, missing, config, ready: missing.length === 0 }; +} + +/** + * Overi hodnoty proti tomu, co sluzba vyzaduje. + * + * Nevyplnene povinne pole **neni chyba** ukladani, ale nedodelek: konektor se + * ulozi a jen nepujde pouzit. Stejny rezim jako u rozdelane automatizace. + * + * Chyba je jen pole, ktere sluzba vubec nema. To uz je rozbite napojeni. + */ +export function validateConnectorValues( + service: Service, + values: Record, +): ConnectorIssue[] { + const known = new Map( + service.credentials.map((field) => [field.id, field]), + ); + + const issues: ConnectorIssue[] = []; + for (const key of Object.keys(values)) { + if (!known.has(key)) { + issues.push({ field: key, message: `Služba ${service.name} pole „${key}" nemá.` }); + } + } + return issues; +} + +/** Ktera pole sluzby jsou tajna. Podle toho se rozhoduje o sifrovani. */ +export function secretFieldIds(serviceId: string): Set { + const service = findService(serviceId); + return new Set( + (service?.credentials ?? []).filter((field) => field.secret).map((field) => field.id), + ); +} diff --git a/src/db/migrate.ts b/src/db/migrate.ts new file mode 100644 index 0000000..365e662 --- /dev/null +++ b/src/db/migrate.ts @@ -0,0 +1,115 @@ +/** + * Migrace schematu. + * + * Soubory `src/db/migrations/*.sql` se spousti v abecednim poradi, kazdy jednou. + * Co uz proslo, je v tabulce `schema_migrations`. + * + * Dve veci, na kterych to stoji: + * + * 1. **Poradovy zamek.** Pri rolling deployi startuje vic instanci naraz + * a bez zamku by migrace pustily vsechny. `pg_advisory_lock` zaridi, ze + * projede jedna a ostatni pockaji. + * 2. **Jeden soubor je jedna transakce.** Pri chybe se nic z nej neuplatni, + * takze nevznikne rozdelane schema, o kterem nikdo nevi. + * + * Migrace se **nikdy neupravuji zpetne**. Uz projely u nekoho jineho, takze + * zmena souboru znamena dve rozdilna schemata se stejnym cislem. Oprava je + * vzdy novy soubor. + */ + +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { db } from './pool.js'; + +/** Libovolne, ale stabilni cislo. Musi byt stejne ve vsech instancich. */ +const LOCK_ID = 918_273_645; + +const here = path.dirname(fileURLToPath(import.meta.url)); + +/** + * Slozka s migracemi. + * + * V nasazeni bezi zkompilovany kod z `dist/`, ale `.sql` soubory tsc nekopiruje. + * Zkousi se proto obe cesty - vedle prelozeneho souboru i v `src`. + */ +async function migrationsDir(): Promise { + const candidates = [path.join(here, 'migrations'), path.resolve(here, '../../src/db/migrations')]; + for (const candidate of candidates) { + try { + await fs.access(candidate); + return candidate; + } catch { + continue; + } + } + throw new Error(`Slozku s migracemi nelze najit. Zkouseno: ${candidates.join(', ')}`); +} + +export interface MigrationResult { + applied: string[]; + skipped: number; +} + +export async function runMigrations(): Promise { + const pool = db(); + const dir = await migrationsDir(); + + const files = (await fs.readdir(dir)).filter((name) => name.endsWith('.sql')).sort(); + if (files.length === 0) { + console.warn(`[db] ve ${dir} nejsou zadne migrace`); + return { applied: [], skipped: 0 }; + } + + const client = await pool.connect(); + const applied: string[] = []; + let skipped = 0; + + try { + // Zamek drzi jedna instance, ostatni tady pockaji. Uvolni se s odpojenim. + await client.query('SELECT pg_advisory_lock($1)', [LOCK_ID]); + + await client.query(` + CREATE TABLE IF NOT EXISTS schema_migrations ( + name text PRIMARY KEY, + applied_at timestamptz NOT NULL DEFAULT now() + ) + `); + + const done = new Set( + (await client.query<{ name: string }>('SELECT name FROM schema_migrations')).rows.map( + (row) => row.name, + ), + ); + + for (const file of files) { + if (done.has(file)) { + skipped += 1; + continue; + } + + const sql = await fs.readFile(path.join(dir, file), 'utf8'); + + try { + await client.query('BEGIN'); + await client.query(sql); + await client.query('INSERT INTO schema_migrations (name) VALUES ($1)', [file]); + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK').catch(() => undefined); + // Rozbita migrace nesmi projit potichu. Bez schematu nema smysl bezet. + const message = err instanceof Error ? err.message : String(err); + throw new Error(`Migrace ${file} selhala: ${message}`); + } + + applied.push(file); + console.info(`[db] migrace ${file} proslá`); + } + } finally { + await client.query('SELECT pg_advisory_unlock($1)', [LOCK_ID]).catch(() => undefined); + client.release(); + } + + if (applied.length === 0) console.info(`[db] schema je aktualni (${skipped} migraci)`); + return { applied, skipped }; +} diff --git a/src/db/migrations/001_connectors.sql b/src/db/migrations/001_connectors.sql new file mode 100644 index 0000000..b3bd8d9 --- /dev/null +++ b/src/db/migrations/001_connectors.sql @@ -0,0 +1,41 @@ +-- Konektory: napojeni jedne firmy na jednu sluzbu. +-- +-- Sluzby zustavaji v katalogu v kodu (src/data/services.ts). Do databaze +-- nepatri: jsou to definice, ktere delame my, a repo je u nich zdroj pravdy +-- kvuli code review a historii v gitu. Viz documentation/12. +-- +-- `tenant_id` je povinne u kazde business tabulky a indexy zacinaji jim. +-- Hodnoty pristupovych udaju jsou sifrovane, tabulka nikdy nedrzi plaintext. + +CREATE TABLE IF NOT EXISTS connectors ( + id text PRIMARY KEY, + tenant_id text NOT NULL, + service_id text NOT NULL, + name text NOT NULL, + base_url text, + -- Sifrovane hodnoty poli podle Service.credentials. + -- Klic je id pole, hodnota je obalka se sifrou (viz src/db/secretBox.ts). + -- JSONB, protoze se cte a zapisuje cele a nikdo se nad tim nedotazuje po polich. + secrets jsonb NOT NULL DEFAULT '{}'::jsonb, + enabled boolean NOT NULL DEFAULT true, + status text NOT NULL DEFAULT 'untested', + last_check_at timestamptz, + last_error text, + is_default boolean NOT NULL DEFAULT false, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + + CONSTRAINT connectors_status_check + CHECK (status IN ('untested', 'ok', 'error')) +); + +-- Seznam konektoru firmy je nejcastejsi dotaz, proto index vede tenant_id. +CREATE INDEX IF NOT EXISTS connectors_tenant_service_idx + ON connectors (tenant_id, service_id); + +-- Vychozi konektor smi byt na dvojici firma a sluzba jen jeden. Vynuceno +-- databazi, ne jen kodem: bez toho by soubezne dva zapisy udelaly dva vychozi +-- a krok bez vybraneho konektoru by si vybiral podle nahody. +CREATE UNIQUE INDEX IF NOT EXISTS connectors_one_default_idx + ON connectors (tenant_id, service_id) + WHERE is_default; diff --git a/src/db/pool.ts b/src/db/pool.ts new file mode 100644 index 0000000..116884a --- /dev/null +++ b/src/db/pool.ts @@ -0,0 +1,164 @@ +/** + * Pripojeni do Postgresu. + * + * Databaze je **volitelna**. Kdyz `DATABASE_URL` chybi, aplikace nastartuje + * a jede v pameti procesu. Nemuze byt jinak: container, ktery nenastartuje, + * je pro AppFactory nefunkcni sluzba (AGENTS.md). + * + * Rozdil mezi obema rezimy se resi na **jednom miste**, a to pri vyberu + * implementace uloziste (`src/data/connectorStore.ts`). Nikde jinde se + * nezjistuje, jestli databaze je - jinak by se to rozlezlo po celem kodu + * a jedno misto by se zapomnelo. + */ + +import { Pool, type PoolClient, type QueryResultRow } from 'pg'; +import { config } from '../config.js'; + +let pool: Pool | null = null; + +/** true = mame kam ukladat. Rozhoduje se podle toho vyber uloziste. */ +export function isDatabaseEnabled(): boolean { + return config.databaseUrl !== ''; +} + +/** + * Pool. Vytvori se az pri prvnim pouziti, aby si aplikace bez databaze + * nezakladala spojeni, ktere nikdy nepouzije. + */ +export function db(): Pool { + if (!isDatabaseEnabled()) { + throw new Error('DATABASE_URL neni nastavena, databaze se nesmi pouzivat.'); + } + + if (!pool) { + pool = new Pool({ + connectionString: config.databaseUrl, + max: config.databasePoolMax, + // Kratky timeout na ziskani spojeni. Radsi hlasnou chybu nez visici request. + connectionTimeoutMillis: 5_000, + idleTimeoutMillis: 30_000, + ...(config.databaseSsl ? { ssl: { rejectUnauthorized: false } } : {}), + }); + + // Chyba na necinnem spojeni nesmi shodit proces. Pool si spojeni obnovi sam. + pool.on('error', (err) => { + console.error('[db] chyba na necinnem spojeni:', err.message); + }); + + console.info(`[db] pool vytvoren, max ${config.databasePoolMax} spojeni`); + } + + return pool; +} + +/** + * Dotaz. + * + * Zamerne tenka obalka, ne query builder. Cely projekt je psany tak, ze server + * je autorita a filtr na firmu je povinny argument - to se hlida lip nad + * viditelnym SQL nez pod nadstavbou. + */ +export async function query( + sql: string, + params: unknown[] = [], +): Promise { + const result = await db().query(sql, params); + return result.rows; +} + +/** Prvni radek, nebo undefined. */ +export async function queryOne( + sql: string, + params: unknown[] = [], +): Promise { + const rows = await query(sql, params); + return rows[0]; +} + +/** + * Transakce. Pri vyjimce se vraci zpatky. + * + * Az bude runtime automatizaci, bude tohle to podstatne: vysledek kroku + * a zarazeni dalsiho ukolu musi byt jedna transakce, jinak vznikne beh + * s hotovym krokem a bez pokracovani (viz documentation/10). + */ +export async function transaction(work: (client: PoolClient) => Promise): Promise { + const client = await db().connect(); + try { + await client.query('BEGIN'); + const result = await work(client); + await client.query('COMMIT'); + return result; + } catch (err) { + await client.query('ROLLBACK').catch(() => undefined); + throw err; + } finally { + client.release(); + } +} + +/** + * Spojeni pro danou firmu. + * + * Dnes vraci vzdy tentyz pool. Je to zamerny sev: jednou prijde klient, ktery + * bude chtit vlastni databazi nebo bude delat tricet procent provozu, a presun + * ma byt konfigurace, ne prepisovani dotazu. Podminka je nikdy nespojovat + * dotazem dva klienty, coz uz vynucuje povinny argument `tenantIds` + * v ulozistich. Podrobnosti v documentation/10-runtime-a-kapacita.md. + */ +export function dbFor(_tenantId: string): Pool { + return db(); +} + +export interface DatabaseHealth { + enabled: boolean; + ok: boolean; + latencyMs: number | null; + error: string | null; +} + +let cachedHealth: { at: number; value: DatabaseHealth } | null = null; +const HEALTH_CACHE_MS = 5_000; + +/** + * Stav databaze pro `/health/ready`. + * + * Vysledek se par sekund cachuje, aby monitoring nedelal dotaz pri kazdem + * pingu. `/health` na databazi zamerne nezavisi - kratky vypadek DB by jinak + * vedl k restartovani containeru, coz nic nespravi. + */ +export async function databaseHealth(): Promise { + if (!isDatabaseEnabled()) { + return { enabled: false, ok: true, latencyMs: null, error: null }; + } + + if (cachedHealth && Date.now() - cachedHealth.at < HEALTH_CACHE_MS) { + return cachedHealth.value; + } + + const startedAt = Date.now(); + let value: DatabaseHealth; + try { + await query('select 1'); + value = { enabled: true, ok: true, latencyMs: Date.now() - startedAt, error: null }; + } catch (err) { + value = { + enabled: true, + ok: false, + latencyMs: null, + error: err instanceof Error ? err.message : String(err), + }; + } + + cachedHealth = { at: Date.now(), value }; + return value; +} + +/** Zavre pool pri ukonceni procesu. */ +export async function closeDatabase(): Promise { + if (!pool) return; + await pool.end().catch((err: unknown) => { + console.warn('[db] pool se nepodarilo zavrit:', err); + }); + pool = null; +} diff --git a/src/db/secretBox.ts b/src/db/secretBox.ts new file mode 100644 index 0000000..fb8af38 --- /dev/null +++ b/src/db/secretBox.ts @@ -0,0 +1,123 @@ +/** + * Sifrovani pristupovych udaju konektoru. + * + * Do databaze nikdy nesmi plaintext. Kdyby ano, tak by kazda zaloha, kazdy dump + * pri ladeni a kazdy, kdo ma pristup ke cteni, mel klientske klice k iDokladu. + * + * AES-256-GCM: sifruje a zaroven overuje, ze se s daty nikdo nehral. Nahodne + * IV pro kazdou hodnotu, aby dve stejne hodnoty nedaly stejnou sifru. + * + * `v` je verze klice. Vymena klice pak znamena precist starym, zapsat novym, + * ne zahodit vsechna napojeni. + */ + +import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto'; +import { config } from '../config.js'; + +/** Obalka, ktera se uklada do JSONB. */ +export interface SealedValue { + /** Verze klice. */ + v: number; + /** Inicializacni vektor, base64url. */ + iv: string; + /** Autentizacni tag GCM, base64url. */ + tag: string; + /** Sifrovana hodnota, base64url. */ + data: string; +} + +const ALGORITHM = 'aes-256-gcm'; +const KEY_VERSION = 1; + +/** + * Klic z konfigurace, srovnany na 32 bajtu. + * + * SHA-256 z hodnoty promenne, aby fungoval jakkoliv dlouhy retezec. Neni to + * derivace hesla (na to by patril scrypt), ale `SECRETS_KEY` ma byt nahodny + * klic, ne heslo - a to je v dokumentaci napsane. + */ +function key(): Buffer { + if (config.secretsKey === '') { + throw new Error('SECRETS_KEY neni nastavena, pristupove udaje nelze sifrovat.'); + } + return createHash('sha256').update(config.secretsKey).digest(); +} + +export function canSealSecrets(): boolean { + return config.secretsKey !== ''; +} + +export function seal(value: string): SealedValue { + const iv = randomBytes(12); + const cipher = createCipheriv(ALGORITHM, key(), iv); + const data = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]); + + return { + v: KEY_VERSION, + iv: iv.toString('base64url'), + tag: cipher.getAuthTag().toString('base64url'), + data: data.toString('base64url'), + }; +} + +/** + * Rozsifruje hodnotu. + * + * Vraci `null`, kdyz to nejde - typicky po vymene klice bez prevodu dat. + * Zamerne se **nepada**: jeden nerozsifrovatelny konektor nesmi shodit seznam + * vsech ostatnich. Chybejici hodnota se pak chova jako nevyplnena, takze + * uzivatel dostane "chybi udaje" a muze je zadat znovu. + */ +export function open(sealed: unknown): string | null { + if (!isSealed(sealed)) return null; + + try { + const decipher = createDecipheriv(ALGORITHM, key(), Buffer.from(sealed.iv, 'base64url')); + decipher.setAuthTag(Buffer.from(sealed.tag, 'base64url')); + const plain = Buffer.concat([ + decipher.update(Buffer.from(sealed.data, 'base64url')), + decipher.final(), + ]); + return plain.toString('utf8'); + } catch (err) { + console.error( + `[secrets] hodnotu nelze rozsifrovat (verze klice ${sealed.v}): ` + + (err instanceof Error ? err.message : String(err)), + ); + return null; + } +} + +function isSealed(value: unknown): value is SealedValue { + if (value === null || typeof value !== 'object') return false; + const record = value as Record; + return ( + typeof record.iv === 'string' && + typeof record.tag === 'string' && + typeof record.data === 'string' && + typeof record.v === 'number' + ); +} + +/** Zasifruje celou sadu hodnot. */ +export function sealAll(values: Record): Record { + const result: Record = {}; + for (const [id, value] of Object.entries(values)) { + if (value === '') continue; + result[id] = seal(value); + } + return result; +} + +/** Rozsifruje celou sadu. Co nejde rozsifrovat, se vynecha a zaloguje. */ +export function openAll(sealed: unknown): Record { + if (sealed === null || typeof sealed !== 'object') return {}; + + const result: Record = {}; + for (const [id, value] of Object.entries(sealed as Record)) { + const plain = open(value); + if (plain !== null) result[id] = plain; + else console.warn(`[secrets] pole ${id} se nepodarilo rozsifrovat, chova se jako nevyplnene`); + } + return result; +} diff --git a/src/index.ts b/src/index.ts index 43a4c31..a4fb448 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,6 +16,9 @@ import { contactRouter } from './routes/contact.js'; import { dashboardRouter } from './routes/dashboard.js'; import { simulateRouter } from './routes/simulate.js'; import { webhookRouter } from './routes/webhook.js'; +import { initConnectorStore, storageStatus } from './data/connectorStore.js'; +import { runMigrations } from './db/migrate.js'; +import { closeDatabase, databaseHealth, isDatabaseEnabled } from './db/pool.js'; import { ensureLoaded, scriptsDir } from './scripts/registry.js'; const here = path.dirname(fileURLToPath(import.meta.url)); @@ -62,10 +65,30 @@ function isOriginAllowed(origin: string): boolean { */ const api = Router({ strict: true }); +/** + * Liveness. Zamerne **nezavisi na databazi**: kratky vypadek DB by jinak vedl + * k restartovani containeru, coz nic nespravi (AGENTS.md). + */ api.get('/health', (_req, res) => { res.json({ status: 'ok', uptimeSec: Math.round(process.uptime()) }); }); +/** + * Readiness. Tady uz databaze zalezi, a proto je to zvlast. + * Vysledek se par sekund cachuje, aby monitoring nedelal dotaz pri kazdem pingu. + */ +api.get('/health/ready', async (_req, res) => { + const database = await databaseHealth(); + const storage = storageStatus(); + const ready = !database.enabled || database.ok; + + res.status(ready ? 200 : 503).json({ + status: ready ? 'ok' : 'degraded', + database, + storage, + }); +}); + /** * Swagger UI. Cesta bez lomitka presmerujeme na variantu s lomitkem, * jinak by se relativni odkazy na CSS a JS skladaly o uroven vys @@ -176,14 +199,47 @@ app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => { */ await ensureLoaded(true); +/** + * Databaze a volba uloziste. + * + * Chybejici nebo nedostupna databaze **nesmi shodit start**. Aplikace pak jede + * v pameti procesu a rekne to nahlas - v logu i v portalu. Container, ktery + * nenastartuje, je pro AppFactory nefunkcni sluzba. + */ +let databaseReady = true; +if (isDatabaseEnabled()) { + try { + await runMigrations(); + } catch (err) { + databaseReady = false; + console.error( + '[db] migrace selhaly, aplikace pojede v pameti procesu:', + err instanceof Error ? err.message : err, + ); + } +} +await initConnectorStore({ databaseReady }); + // Poslouchat na vsech rozhranich containeru, ne jen na localhost (AGENTS.md). const server = app.listen(config.port, '0.0.0.0', () => { console.info(`[start] csbot-prototype bezi na portu ${config.port}`); console.info(`[start] ROOT_PATH: ${config.rootPath || '(neni nastaven)'}`); console.info(`[start] health: ${config.rootPath}/health, docs: ${config.rootPath}/docs`); console.info(`[start] skripty konektoru: ${scriptsDir()}`); + const storage = storageStatus(); + console.info(`[start] uloziste konektoru: ${storage.mode}${storage.reason ? ` (${storage.reason})` : ''}`); }); +// Pool se pri ukonceni zavre, at se spojeni neopousti otevrena. +for (const signal of ['SIGTERM', 'SIGINT'] as const) { + process.on(signal, () => { + console.info(`[stop] ${signal}, ukoncuji`); + server.close(() => { + void closeDatabase().finally(() => process.exit(0)); + }); + }); +} + server.on('error', (err: NodeJS.ErrnoException) => { if (err.code === 'EADDRINUSE') { console.error(`[start] Port ${config.port} je obsazeny.`); diff --git a/src/openapi.ts b/src/openapi.ts index d0e2c50..6b6893a 100644 --- a/src/openapi.ts +++ b/src/openapi.ts @@ -987,6 +987,46 @@ export function buildOpenApiDocument() { responses: { '200': { description: 'Proud udalosti text/event-stream' } }, }, }, + '/health/ready': { + get: { + tags: ['Provoz'], + summary: 'Readiness vcetne databaze', + description: + 'Vraci 503, kdyz je databaze nastavena a nedostupna. `/health` na databazi ' + + 'zamerne nezavisi - kratky vypadek DB by jinak vedl k restartovani containeru.', + responses: { + '200': { description: 'Aplikace je pripravena' }, + '503': { description: 'Databaze je nastavena, ale nedostupna' }, + }, + }, + }, + '/api/dashboard/storage': { + get: { + tags: ['Dashboard'], + summary: 'Kam se uklada', + description: + 'mode postgres nebo memory. `ephemeral: true` znamena, ze restart procesu ' + + 'data smaze. Portal to musi umet rict nahlas.', + security: [{ bearerAuth: [] }], + responses: { + '200': { + description: 'Rezim uloziste', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + mode: { type: 'string', enum: ['postgres', 'memory'] }, + reason: { type: 'string', nullable: true }, + ephemeral: { type: 'boolean' }, + }, + }, + }, + }, + }, + }, + }, + }, '/api/dashboard/services': { get: { tags: ['Sluzby'], diff --git a/src/routes/connectors.ts b/src/routes/connectors.ts index 7e3b6cf..8c586fd 100644 --- a/src/routes/connectors.ts +++ b/src/routes/connectors.ts @@ -91,14 +91,16 @@ function serviceOrDeny( * pozna rozdil mezi "napojeno" a "muzete si napojit". Neni to vlastnost sluzby, * ale te firmy, proto se to pocita tady a ne v katalogu. */ -connectorsRouter.get('/services', (req, res) => { +connectorsRouter.get('/services', async (req, res) => { const access = accessFor(req.user!); const requested = typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined; const tenantId = requested ?? access.defaultTenantId; const visible = visibleServices(req.user!, tenantId); const withScripts = new Map(serviceCatalog().map((service) => [service.id, service])); - const counts = tenantId ? connectorCountsByService([tenantId]) : new Map(); + const counts = tenantId + ? await connectorCountsByService([tenantId]) + : new Map(); const items = visible.map((service) => { const merged = withScripts.get(service.id) ?? service; @@ -118,12 +120,12 @@ connectorsRouter.get('/services', (req, res) => { // ----------------------------------------------------------------- konektory -connectorsRouter.get('/', (req, res) => { +connectorsRouter.get('/', async (req, res) => { const tenantId = tenantOrDeny(req, res); if (!tenantId) return; const serviceId = typeof req.query.serviceId === 'string' ? req.query.serviceId : undefined; - const items = listConnectors([tenantId], serviceId ? { serviceId } : {}) + const items = (await listConnectors([tenantId], serviceId ? { serviceId } : {})) // Konektor na sluzbu, kterou uzivatel nevidi, mu taky ukazovat nebudeme. .filter((connector) => { const service = findService(connector.serviceId); @@ -134,11 +136,11 @@ connectorsRouter.get('/', (req, res) => { return res.json({ items, tenantId }); }); -connectorsRouter.get('/:id', (req, res) => { +connectorsRouter.get('/:id', async (req, res) => { const tenantId = tenantOrDeny(req, res); if (!tenantId) return; - const connector = getConnector(req.params.id, [tenantId]); + const connector = await getConnector(req.params.id, [tenantId]); if (!connector) { return res.status(404).json({ error: 'not_found', message: 'Konektor neexistuje.' }); } @@ -154,7 +156,7 @@ const createSchema = z.object({ values: z.record(z.string()).optional(), }); -connectorsRouter.post('/', (req, res) => { +connectorsRouter.post('/', async (req, res) => { const tenantId = tenantOrDeny(req, res); if (!tenantId) return; @@ -181,7 +183,7 @@ connectorsRouter.post('/', (req, res) => { return res.status(400).json({ error: 'validation_error', message: issues[0].message, issues }); } - const connector = createConnector({ + const connector = await createConnector({ tenantId, serviceId: service.id, name: parsed.data.name, @@ -201,11 +203,11 @@ const updateSchema = z.object({ isDefault: z.literal(true).optional(), }); -connectorsRouter.patch('/:id', (req, res) => { +connectorsRouter.patch('/:id', async (req, res) => { const tenantId = tenantOrDeny(req, res); if (!tenantId) return; - const existing = getConnector(req.params.id, [tenantId]); + const existing = await getConnector(req.params.id, [tenantId]); if (!existing) { return res.status(404).json({ error: 'not_found', message: 'Konektor neexistuje.' }); } @@ -228,18 +230,18 @@ connectorsRouter.patch('/:id', (req, res) => { } } - const updated = updateConnector(req.params.id, parsed.data, [tenantId]); + const updated = await updateConnector(req.params.id, parsed.data, [tenantId]); if (!updated) { return res.status(404).json({ error: 'not_found', message: 'Konektor neexistuje.' }); } return res.json(toPublicConnector(updated)); }); -connectorsRouter.delete('/:id', (req, res) => { +connectorsRouter.delete('/:id', async (req, res) => { const tenantId = tenantOrDeny(req, res); if (!tenantId) return; - if (!deleteConnector(req.params.id, [tenantId])) { + if (!(await deleteConnector(req.params.id, [tenantId]))) { return res.status(404).json({ error: 'not_found', message: 'Konektor neexistuje.' }); } return res.status(204).end(); @@ -258,7 +260,7 @@ connectorsRouter.post('/:id/test', async (req, res) => { const tenantId = tenantOrDeny(req, res); if (!tenantId) return; - const connector = getConnector(req.params.id, [tenantId]); + const connector = await getConnector(req.params.id, [tenantId]); if (!connector) { return res.status(404).json({ error: 'not_found', message: 'Konektor neexistuje.' }); } @@ -267,7 +269,7 @@ connectorsRouter.post('/:id/test', async (req, res) => { const target = resolveTarget(service.id, connector); if (!target.ready) { - setConnectorStatus(connector.id, 'error', target.missing.join(', '), [tenantId]); + await setConnectorStatus(connector.id, 'error', target.missing.join(', '), [tenantId]); return res.json({ ok: false, checked: 'nic', @@ -293,7 +295,7 @@ connectorsRouter.post('/:id/test', async (req, res) => { }); const response = await http.get(path); - setConnectorStatus(connector.id, 'ok', null, [tenantId]); + await setConnectorStatus(connector.id, 'ok', null, [tenantId]); return res.json({ ok: true, @@ -314,7 +316,7 @@ connectorsRouter.post('/:id/test', async (req, res) => { : undefined : redact(truncate(String(err instanceof Error ? err.stack ?? err.message : err), config.errorDetailBytes)); - setConnectorStatus(connector.id, 'error', message, [tenantId]); + await setConnectorStatus(connector.id, 'error', message, [tenantId]); console.warn( `[connectors] test ${connector.id} selhal: ${message}` + (detail ? ` ${detail}` : ''), diff --git a/src/routes/dashboard.ts b/src/routes/dashboard.ts index 880ebff..90a8bbe 100644 --- a/src/routes/dashboard.ts +++ b/src/routes/dashboard.ts @@ -20,6 +20,7 @@ import { operatorAllowedForType, operatorsByType } from '../data/conditions.js'; import { defaultConnectorFor, getConnector, + storageStatus, } from '../data/connectorStore.js'; import { findOperation, @@ -91,6 +92,16 @@ dashboardRouter.get('/summary', (req, res) => { return res.json(getSummary(scope.tenantIds)); }); +/** + * Kam se uklada a jestli to prezije restart. + * + * Portal to musi umet rict nahlas. Bez toho se clovek divi, kam se podely + * jeho konektory, a hleda chybu v aplikaci - presne to se stalo. + */ +dashboardRouter.get('/storage', (_req, res) => { + res.json(storageStatus()); +}); + dashboardRouter.get('/incidents', (_req, res) => { res.json({ items: listIncidents() }); }); @@ -486,10 +497,10 @@ function normalizeTriggerFields(flow: z.infer): z.infer, tenantIds: string[], -): { problems: string[]; issues: string[] } { +): Promise<{ problems: string[]; issues: string[] }> { const problems: string[] = []; /** Nedodelky: strom se ulozi, jen automatizace nepujde zapnout. */ const issues: string[] = []; @@ -516,7 +527,7 @@ function validateFlowReferences( // jen na parametry, ktere pred ni uz vznikly. const scopes = collectScopes(flow); - const walk = (steps: FlowStep[]) => { + const walk = async (steps: FlowStep[]): Promise => { for (const step of steps) { if (step.kind === 'condition') { const field = scopes.all.get(step.fieldId); @@ -527,8 +538,8 @@ function validateFlowReferences( `Operátor "${step.operator}" nelze použít na parametr „${field.name}" typu ${field.type}.`, ); } - walk(step.yes); - walk(step.no); + await walk(step.yes); + await walk(step.no); continue; } const action = findOperation(step.serviceId, step.operationId, 'action'); @@ -574,7 +585,7 @@ function validateFlowReferences( // Vybrany konektor musi patrit te same firme a te same sluzbe. // Cizi konektor je rozbity strom, ne nedodelek. if (step.connectorId) { - const connector = getConnector(step.connectorId, tenantIds); + const connector = await getConnector(step.connectorId, tenantIds); if (!connector) { problems.push(`Krok odkazuje na konektor, který neexistuje (${step.connectorId}).`); } else if (connector.serviceId !== step.serviceId) { @@ -586,7 +597,7 @@ function validateFlowReferences( const service = findService(step.serviceId); // Chybejici napojeni je nedodelek, ne chyba - rozdelana prace se ulozi. if (service && !service.general && tenantIds.length === 1) { - const fallback = defaultConnectorFor(tenantIds[0], step.serviceId); + const fallback = await defaultConnectorFor(tenantIds[0], step.serviceId); if (!fallback) { issues.push( `Služba ${service.name} nemá v této firmě konektor. Vytvořte ho v Konektorech.`, @@ -596,7 +607,7 @@ function validateFlowReferences( } } }; - walk(flow.steps); + await walk(flow.steps); return { problems, issues }; } @@ -638,7 +649,7 @@ dashboardRouter.post('/automations', (req, res) => { return res.status(201).json(automation); }); -dashboardRouter.put('/automations/:id', (req, res) => { +dashboardRouter.put('/automations/:id', async (req, res) => { const parsed = updateSchema.safeParse(req.body); if (!parsed.success) { return res.status(400).json({ @@ -653,7 +664,7 @@ dashboardRouter.put('/automations/:id', (req, res) => { let connectorIssues: string[] = []; if (flow) { - const { problems, issues } = validateFlowReferences(flow, writableTenants(req)); + const { problems, issues } = await validateFlowReferences(flow, writableTenants(req)); if (problems.length > 0) { console.warn(`[automations] ${req.params.id}: neplatny strom - ${problems.join(' ')}`); return res.status(400).json({ diff --git a/src/routes/scripts.ts b/src/routes/scripts.ts index 6470fe3..5619dee 100644 --- a/src/routes/scripts.ts +++ b/src/routes/scripts.ts @@ -50,14 +50,13 @@ scriptsRouter.get('/', async (req, res) => { access.defaultTenantId; // Ke kazde sluzbe vychozi konektor firmy, aby portal poznal, jestli je cim volat. - const connectors = Object.fromEntries( - items - .map((item) => { - const connector = tenantId ? defaultConnectorFor(tenantId, item.serviceId) : undefined; - return connector ? [item.serviceId, toPublicConnector(connector)] : null; - }) - .filter((entry): entry is [string, ReturnType] => entry !== null), + const pairs = await Promise.all( + items.map(async (item) => { + const connector = tenantId ? await defaultConnectorFor(tenantId, item.serviceId) : undefined; + return connector ? ([item.serviceId, toPublicConnector(connector)] as const) : null; + }), ); + const connectors = Object.fromEntries(pairs.filter((entry) => entry !== null)); res.json({ items, @@ -93,7 +92,7 @@ scriptsRouter.get('/:id', async (req, res) => { const tenantId = (typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined) ?? access.defaultTenantId; - const connector = tenantId ? defaultConnectorFor(tenantId, serviceId) : undefined; + const connector = tenantId ? await defaultConnectorFor(tenantId, serviceId) : undefined; return res.json({ id, @@ -182,8 +181,8 @@ scriptsRouter.post('/:id/test', requirePlatformAdmin, async (req, res) => { // Konektor musi patrit te same firme. Cizi se chova jako neexistujici. const connector = parsed.data.connectorId - ? getConnector(parsed.data.connectorId, [tenantId]) - : defaultConnectorFor(tenantId, serviceId); + ? await getConnector(parsed.data.connectorId, [tenantId]) + : await defaultConnectorFor(tenantId, serviceId); if (parsed.data.connectorId && !connector) { return res.status(404).json({ error: 'not_found', message: 'Konektor neexistuje.' }); diff --git a/web/src/pages/dashboard/Connectors.tsx b/web/src/pages/dashboard/Connectors.tsx index 9b5467c..b45dfb3 100644 --- a/web/src/pages/dashboard/Connectors.tsx +++ b/web/src/pages/dashboard/Connectors.tsx @@ -1,4 +1,5 @@ import { + AlertTriangle, CheckCircle2, Eye, EyeOff, @@ -26,6 +27,7 @@ import type { Service, ServiceCredentialField, ServiceOverview, + StorageStatus, } from '@/types/dashboard'; /** @@ -46,6 +48,7 @@ export default function Connectors() { const [services, setServices] = useState(null); const [connectors, setConnectors] = useState(null); + const [storage, setStorage] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(true); @@ -60,10 +63,12 @@ export default function Connectors() { Promise.all([ apiFetch('/api/dashboard/connectors/services'), apiFetch<{ items: Connector[] }>('/api/dashboard/connectors'), + apiFetch('/api/dashboard/storage'), ]) - .then(([overview, list]) => { + .then(([overview, list, storageStatus]) => { setServices(overview); setConnectors(list.items); + setStorage(storageStatus); }) .catch((err: unknown) => { setError(err instanceof Error ? err.message : 'Konektory se nepodařilo načíst.'); @@ -111,6 +116,20 @@ export default function Connectors() { )} + {/* + Kam se uklada. Bez teto hlasky se clovek divi, kam se podely jeho + konektory po restartu, a hleda chybu v aplikaci. + */} + {storage?.ephemeral && ( +

+ + + Přístupové údaje se ukládají jen do paměti serveru a restart je smaže. + {storage.reason} + +

+ )} + {(connectors?.length ?? 0) === 0 ? (

diff --git a/web/src/types/dashboard.ts b/web/src/types/dashboard.ts index 35ddaf2..0d08b62 100644 --- a/web/src/types/dashboard.ts +++ b/web/src/types/dashboard.ts @@ -292,6 +292,15 @@ export interface Connector { ready: boolean; } +/** Kam se uklada a jestli to prezije restart. */ +export interface StorageStatus { + mode: 'postgres' | 'memory'; + /** Proc se jede v pameti. null u databaze. */ + reason: string | null; + /** true = restart procesu data smaze. */ + ephemeral: boolean; +} + export interface ConnectorTestResult { ok: boolean; /** Co se vlastne overilo: pristupove udaje, nebo jen dostupnost sluzby. */