Soubor jako uloziste, kdyz neni databaze

Mockup se k databazi nedostane, takze pribyl treti rezim: JSON soubor. Prezije
restart procesu i containeru, ale ne redeploy - filesystem containeru je
docasny. Je to mezistupen, ne nahrada databaze, a tak je to i napsane v portalu.

| Rezim    | Kdy                               | Restart | Redeploy |
| -------- | --------------------------------- | ------- | -------- |
| postgres | DATABASE_URL i SECRETS_KEY        | prezije | prezije  |
| file     | neni DB, ale je DATA_DIR          | prezije | ne       |
| memory   | ani jedno, nebo nejde zapsat      | ne      | ne       |

Rozhodnuti zustava na jednom miste (src/data/connectorStore.ts).

Pridano:
- src/data/snapshot.ts: atomicky zapis (.tmp a prejmenovani), slucovani zapisu
  a dokonceni rozepsaneho zapisu pri SIGTERM. Bez atomickeho zapisu by pad
  uprostred nechal polovicni JSON, ktery se pri startu nenacte. Rozbity soubor
  se prejmenuje na .broken a jede se dal - aplikace, ktera nenastartuje, je pro
  AppFactory nefunkcni sluzba
- src/data/connectors/local.ts: jeden kod pro pamet i soubor, lisi se jen tim,
  kam se zapisuje. Nahrazuje memory.ts, dve implementace by se casem rozesly
- klic k sifrovani se mimo databazi vygeneruje do DATA_DIR/secrets.key s pravy
  0600, takze sifrovani funguje bez nastaveni. Chrani to proti nahodnemu
  precteni JSONu, ne proti pristupu k disku - klic lezi vedle dat a je to tak
  napsane i v portalu. U databaze se negeneruje vubec: kdo ma zalohu tabulky,
  ma i klic ze stejneho stroje
- DATA_DIR v konfiguraci, data/ v .gitignore a .dockerignore
- hlaska v portalu rozlisuje tri nasledky: pamet, soubor a databaze

Overeno bez databaze: konektor s vyplnenymi udaji prezil restart, v JSONu jsou
hodnoty sifrovane a plaintext v nem neni. Pote s databazi: rezim postgres
funguje dal a klic vedle dat se nevygeneroval. Kontejner i data/ po overeni
smazany.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
JiriUhlir
2026-08-12 15:18:05 +02:00
co-authored by Claude Opus 5
parent 61cf29878f
commit 6e3d0640ff
12 changed files with 334 additions and 273 deletions
+2
View File
@@ -5,3 +5,5 @@ documentation/
*.log
.env
.env.*
# Lokalni data a klic. Do image nepatri, v containeru vznikne cista slozka.
data/
+3
View File
@@ -14,3 +14,6 @@ coverage/
.DS_Store
Thumbs.db
.idea/
# Data mimo databazi (JSON snapshot a klic). Nikdy do gitu.
data/
+2 -2
View File
@@ -36,8 +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 pro konektory | hotovo | Postgres, udaje sifrovane. Bez DATABASE_URL jede pamet |
| Databaze pro zbytek | chybi | automatizace, rozlozeni a tickety jsou v pameti |
| Uloziste konektoru | hotovo | Postgres, nebo JSON soubor. Udaje vzdy sifrovane |
| Uloziste pro zbytek | chybi | automatizace, rozlozeni a tickety jsou v pameti |
| Odesilani e-mailu z formulare | chybi | poptavka se zatim jen loguje |
## Znama omezeni
+64 -18
View File
@@ -1,30 +1,39 @@
# 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.
Naprogramovano a overeno. Zatim se 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
Ukladat jde tremi zpusoby a rezim se vybira sam podle toho, co je k dispozici.
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.
## Tri rezimy, jedno rozhrani
| Rezim | Kdy | Prezije restart |
| ---------- | --------------------------------------- | --------------- |
| `postgres` | je `DATABASE_URL` **i** `SECRETS_KEY` | ano |
| `memory` | jinak | ne |
Rozdil se resi **na jednom miste**, v `src/data/connectorStore.ts`. Nikde jinde
se nezjistuje, ktery rezim jede - kdyby se to rozlezlo po kodu, jedno misto by
se zapomnelo a chovalo by se pak jinak nez zbytek.
| Rezim | Kdy | Prezije restart | Prezije redeploy |
| ---------- | -------------------------------------- | --------------- | ---------------- |
| `postgres` | je `DATABASE_URL` i `SECRETS_KEY` | ano | ano |
| `file` | neni databaze, ale je `DATA_DIR` | ano | **ne** |
| `memory` | ani jedno, nebo nejde zapsat | ne | ne |
Rezim `file` je pro mockup. Filesystem containeru je docasny, takze soubor
prezije restart procesu i containeru, ale nove nasazeni ho smaze. Je to
mezistupen, ne nahrada databaze.
Nasazeny mockup tedy jede v rezimu `file` a v portalu je to napsane. Lokalne
s Postgresem jede `postgres`.
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.
AppFactory nefunkcni sluzba (AGENTS.md). Misto toho se do logu napise, ktery
rezim jede a proc, 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.
Stejne tak: kdyz jsou migrace nastavene, ale selzou, jede se dal bez databaze.
Psat do rozbiteho schematu je horsi nez psat do souboru.
## Promenne
@@ -34,6 +43,7 @@ Psat do rozbiteho schematu je horsi nez neukladat.
| `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 |
| `DATA_DIR` | slozka pro JSON mimo databazi, vychozi `./data`. Prazdna hodnota vypne i soubor |
`SECRETS_KEY` ma byt nahodny retezec, ne heslo:
@@ -62,7 +72,40 @@ Migrace se pousti samy pri startu. Kontrola, ze to jede z databaze:
curl -s http://localhost:3000/health/ready
```
Bez promennych `npm run dev` funguje dal, jen v pameti.
Bez promennych `npm run dev` funguje dal, jen se uklada do `./data`.
## Soubor misto databaze
`src/data/snapshot.ts` a `src/data/connectors/local.ts`.
Ukladani je **jeden kod pro pamet i soubor**, lisi se jen tim, kam se zapisuje.
Kdyby to byly dve implementace, jedna by se casem opravila a druha ne.
| Vlastnost | Jak a proc |
| ---------------- | ----------------------------------------------------------- |
| Atomicky zapis | nejdriv `.tmp`, pak prejmenovani. Pad uprostred zapisu jinak nechá polovicni JSON, ktery se pri startu nenacte |
| Slucovani zapisu | deset uprav za sebou znamena jeden zapis na disk |
| Zapis pri ukonceni | `SIGTERM` dokonci rozepsany zapis, jinak by se posledni zmena ztratila |
| Rozbity soubor | prejmenuje se na `.broken`, zaloguje a jede se s prazdnymi daty. Aplikace, ktera nenastartuje, je pro AppFactory nefunkcni sluzba |
| Sifrovani | tajne hodnoty jsou v souboru zasifrovane, plaintext nikdy |
### Klic mimo databazi
Bez `SECRETS_KEY` si aplikace v rezimu `file` vygeneruje klic do
`DATA_DIR/secrets.key` (prava 0600). Diky tomu funguje sifrovani bez jakehokoliv
nastaveni.
**Rekneme si nahlas, co to je a co ne.** Klic lezi ve stejne slozce jako data,
takze to chrani proti nahodnemu precteni JSONu, **ne** proti nekomu, kdo ma
pristup k disku serveru nebo k zaloze slozky. Do provozu patri klic ze secretu,
tedy `SECRETS_KEY`.
U databaze se klic vedle dat **negeneruje vubec**. Nemelo by to smysl: kdo ma
zalohu tabulky, ma i klic ze stejneho stroje. Proto je `allowKeyFile` parametr,
ne automatika.
Kdyz nejde ani jedno, tajne hodnoty se do souboru neukladaji a zbytek konektoru
ano. Radsi je zadat znovu nez je mit v souboru citelne.
## Migrace
@@ -169,13 +212,16 @@ Proti Postgresu 16 v kontejneru:
| `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 |
| Bez `DATABASE_URL` jede souborovy rezim a rekne to | ano |
| Konektor v souborovem rezimu prezije restart procesu | ano |
| Tajne hodnoty jsou v JSONu sifrovane, plaintext nikde | ano |
| U databaze se klic vedle dat nevygeneruje | ano |
## Co chybi
| Chybi | Poznamka |
| ---------------------------- | ----------------------------------------------------- |
| Automatizace v databazi | dalsi na rade, je to to, co si clovek nastavi |
| Automatizace v ulozisti | dalsi na rade, je to to, co si clovek nastavi. Pujde do souboru i do databaze |
| 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 |
+26
View File
@@ -2,6 +2,32 @@
Nejnovejsi nahore.
## 2026-08-12 - soubor jako uloziste bez databaze
Mockup se k databazi nedostane, takze pribyl treti rezim: JSON soubor.
Prezije restart procesu i containeru, ale ne redeploy.
### Pridano
- `src/data/snapshot.ts`: atomicky zapis (`.tmp` a prejmenovani), slucovani
zapisu a dokonceni rozepsaneho zapisu pri `SIGTERM`. Rozbity soubor se
prejmenuje na `.broken`, zaloguje a jede se s prazdnymi daty - aplikace, ktera
nenastartuje, je pro AppFactory nefunkcni sluzba.
- `src/data/connectors/local.ts`: jeden kod pro pamet i soubor, lisi se jen tim,
kam se zapisuje. Nahrazuje `memory.ts` - dve implementace by se casem rozesly.
- Klic k sifrovani se mimo databazi vygeneruje do `DATA_DIR/secrets.key`, takze
sifrovani funguje bez nastaveni. U databaze se negeneruje: kdo ma zalohu
tabulky, ma i klic ze stejneho stroje.
- `DATA_DIR` v konfiguraci, `data/` v `.gitignore` a `.dockerignore`.
- Hlaska v portalu rozlisuje tri nasledky: pamet (ztrata pri restartu), soubor
(ztrata pri redeployi) a databaze (bez ztraty, hlaska se nezobrazuje).
### Overeno
Bez databaze: konektor s vyplnenymi udaji prezil restart, v JSONu jsou hodnoty
sifrovane a plaintext v nem neni. S databazi: rezim `postgres` funguje dal
a klic vedle dat se nevygeneroval.
## 2026-08-12 - databaze pro konektory
Konektory se ukladaji do Postgresu, pristupove udaje sifrovane.
+8
View File
@@ -105,6 +105,14 @@ export const config = {
databasePoolMax: positiveNumber(process.env.DATABASE_POOL_MAX, 10),
/** Spravovane databaze vyzaduji TLS. */
databaseSsl: process.env.DATABASE_SSL === 'true',
/**
* Slozka pro data mimo databazi.
*
* Bez `DATABASE_URL` se do ni uklada JSON, ktery prezije restart procesu
* i containeru. Redeploy ho nezachova - filesystem containeru je docasny.
* Prazdna hodnota vypne i tohle a jede se v ciste pameti.
*/
dataDir: (process.env.DATA_DIR ?? path.join(process.cwd(), 'data')).trim(),
// ------------------------------------------------------- skripty konektoru
+96 -52
View File
@@ -1,22 +1,27 @@
/**
* Konektory: vyber uloziste a jedine misto, kde se rozhoduje o rezimu.
*
* Dve implementace, jedno rozhrani:
* - `postgres` kdyz je `DATABASE_URL` a `SECRETS_KEY`,
* - `memory` jinak, tedy pri mockupu a lokalnim vyvoji bez databaze.
* Tri rezimy, jedno rozhrani:
*
* Rozhodnuti je **jen tady**. Nikde jinde se nezjistuje, jestli databaze je -
* | Rezim | Kdy | Prezije |
* | ---------- | --------------------------------------- | ----------------- |
* | `postgres` | je `DATABASE_URL` i klic k sifrovani | vse |
* | `file` | neni databaze, ale je datova slozka | restart, ne redeploy |
* | `memory` | ani jedno, nebo se nepodarilo zapsat | nic |
*
* Rozhodnuti je **jen tady**. Nikde jinde se nezjistuje, ktery rezim jede -
* kdyby se to rozlezlo po kodu, jedno misto by se zapomnelo a chovalo by se
* pak jinak nez zbytek.
*
* 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.
* Databaze potrebuje klic ze `SECRETS_KEY`. Klic vedle dat by u ni nemel smysl:
* kdo ma zalohu tabulky, ma i klic ze stejneho stroje.
*/
import { canSealSecrets } from '../db/secretBox.js';
import path from 'node:path';
import { config } from '../config.js';
import { isDatabaseEnabled } from '../db/pool.js';
import { memoryConnectors, seedMemoryConnectors } from './connectors/memory.js';
import { canSealSecrets, initSecrets, secretsKeySource } from '../db/secretBox.js';
import { createLocalConnectors } from './connectors/local.js';
import { postgresConnectors } from './connectors/postgres.js';
import type {
Connector,
@@ -24,6 +29,7 @@ import type {
CreateConnectorInput,
UpdateConnectorInput,
} from './connectors/types.js';
import { fileSnapshot, memorySnapshot } from './snapshot.js';
export type {
Connector,
@@ -34,79 +40,112 @@ export type {
} from './connectors/types.js';
export { toPublicConnector, validateConnectorValues } from './connectors/types.js';
export type StorageMode = 'postgres' | 'memory';
export type StorageMode = 'postgres' | 'file' | 'memory';
export interface StorageStatus {
mode: StorageMode;
/** Proc se jede v pameti. null u databaze. */
reason: string | null;
/** Kratke vysvetleni pro portal. Vzdy vyplnene, i u databaze. */
reason: string;
/** true = restart procesu data smaze. */
ephemeral: boolean;
/** true = redeploy containeru data smaze, i kdyz restart ne. */
lostOnRedeploy: boolean;
/** Odkud je klic k sifrovani tajnych hodnot. */
secrets: 'env' | 'file' | 'none';
/** Kde data lezi. null u pameti. */
location: string | null;
}
let repository: ConnectorRepository = memoryConnectors;
let repository: ConnectorRepository = createLocalConnectors({
store: memorySnapshot(),
canEncrypt: false,
});
let flushRepository: (() => Promise<void>) | null = null;
let status: StorageStatus = {
mode: 'memory',
reason: 'Databáze není nastavená.',
reason: 'Úložiště se ještě nevybralo.',
ephemeral: true,
lostOnRedeploy: true,
secrets: 'none',
location: null,
};
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.
* Pak se jede bez ni, protoze psat do rozbiteho schematu je horsi.
*/
databaseReady?: boolean;
}
/**
* Vybere uloziste. Vola se jednou pri startu, po migracich.
* Volani je idempotentni, at jde zavolat i z testu.
*/
/** Vybere uloziste. Vola se jednou pri startu, po migracich. */
export async function initConnectorStore(options: InitOptions = {}): Promise<StorageStatus> {
const databaseReady = options.databaseReady ?? true;
const useDatabase = isDatabaseEnabled() && databaseReady;
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;
}
// Klic vedle dat je pripustny jen mimo databazi. U ni ma prijit ze secretu.
initSecrets({ allowKeyFile: !useDatabase, dir: config.dataDir });
const secrets = secretsKeySource();
if (isDatabaseEnabled() && canSealSecrets()) {
if (useDatabase && canSealSecrets()) {
repository = postgresConnectors;
status = { mode: 'postgres', reason: null, ephemeral: false };
console.info('[storage] konektory: Postgres, přístupové údaje šifrované');
flushRepository = null;
status = {
mode: 'postgres',
reason: 'Data jsou v Postgresu, přístupové údaje šifrované.',
ephemeral: false,
lostOnRedeploy: false,
secrets,
location: 'Postgres',
};
console.info(`[storage] konektory: Postgres, klic ze ${secrets === 'env' ? 'secretu' : 'souboru'}`);
return status;
}
repository = memoryConnectors;
// ---------------------------------------------------------- mimo databazi
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}`);
const reasons: string[] = [];
if (isDatabaseEnabled() && !databaseReady) {
reasons.push('Databáze je nastavená, ale schéma není použitelné, zkontrolujte log migrací.');
} else if (useDatabase && !canSealSecrets()) {
reasons.push('Databáze je nastavená, ale chybí SECRETS_KEY. Údaje by se ukládaly čitelně.');
} else if (!isDatabaseEnabled()) {
reasons.push('Databáze není nastavená (DATABASE_URL).');
}
// Ukazkovy konektor jen v pametovem rezimu. V databazi by se delal pri kazdem startu.
await seedMemoryConnectors();
const useFile = config.dataDir !== '';
const file = path.join(config.dataDir, 'connectors.json');
const local = createLocalConnectors({
store: useFile ? fileSnapshot(file) : memorySnapshot(),
canEncrypt: canSealSecrets(),
});
await local.init();
repository = local;
flushRepository = () => local.flush();
if (useFile) {
reasons.push('Data se ukládají do JSON souboru, takže přežijí restart, ale ne redeploy.');
if (!canSealSecrets()) {
reasons.push('Přístupové údaje se neukládají, protože není čím je zašifrovat.');
}
} else {
reasons.push('Data jsou jen v paměti procesu, restart je smaže.');
}
status = {
mode: useFile ? 'file' : 'memory',
reason: reasons.join(' '),
// Soubor prezije restart procesu, pamet ne.
ephemeral: !useFile,
lostOnRedeploy: true,
secrets,
location: useFile ? file : null,
};
console.warn(`[storage] konektory: ${status.mode} - ${status.reason}`);
return status;
}
@@ -114,6 +153,11 @@ export function storageStatus(): StorageStatus {
return status;
}
/** Dokonci rozepsany zapis. Vola se pri ukonceni procesu. */
export async function flushConnectorStore(): Promise<void> {
if (flushRepository) await flushRepository();
}
// ------------------------------------------------- pruchod do zvoleneho uloziste
/**
-176
View File
@@ -1,176 +0,0 @@
/**
* 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<string, number>();
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<void> {
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);
}
+72 -8
View File
@@ -12,6 +12,8 @@
*/
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { config } from '../config.js';
/** Obalka, ktera se uklada do JSONB. */
@@ -30,21 +32,83 @@ const ALGORITHM = 'aes-256-gcm';
const KEY_VERSION = 1;
/**
* Klic z konfigurace, srovnany na 32 bajtu.
* Odkud je klic.
*
* 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.
* `env` ze `SECRETS_KEY`. Klic je oddeleny od dat, takze zaloha souboru
* nebo tabulky sama nestaci. Tohle je jediny rezim vhodny do provozu.
* `file` vygenerovany klic v datove slozce vedle dat. Chrani proti nahodnemu
* precteni JSONu, **ne** proti nekomu, kdo ma pristup k disku - klic
* lezi hned vedle. Je to rezim pro mockup.
* `none` neni cim sifrovat, tajne hodnoty se neukladaji.
*/
export type SecretsKeySource = 'env' | 'file' | 'none';
let material: string | null = null;
let source: SecretsKeySource = 'none';
/**
* Nastavi klic. Vola se jednou pri startu, pred vyberem uloziste.
*
* `allowKeyFile` je zamerne parametr, ne automatika: v databazovem rezimu se
* klic vedle dat generovat nesmi, tam ma prijit ze secretu.
*/
export function initSecrets(options: { allowKeyFile: boolean; dir: string }): SecretsKeySource {
if (config.secretsKey !== '') {
material = config.secretsKey;
source = 'env';
return source;
}
if (!options.allowKeyFile) {
material = null;
source = 'none';
return source;
}
const file = path.join(options.dir, 'secrets.key');
try {
if (fs.existsSync(file)) {
material = fs.readFileSync(file, 'utf8').trim();
} else {
fs.mkdirSync(options.dir, { recursive: true });
material = randomBytes(32).toString('base64url');
// 0600: cist smi jen vlastnik procesu. Na Windows se prava ignoruji.
fs.writeFileSync(file, material, { encoding: 'utf8', mode: 0o600 });
console.info(`[secrets] vygenerovan klic v ${file}`);
}
source = 'file';
} catch (err) {
console.error(
'[secrets] klic v souboru nelze pouzit, tajne hodnoty se nebudou ukladat:',
err instanceof Error ? err.message : err,
);
material = null;
source = 'none';
}
return source;
}
export function secretsKeySource(): SecretsKeySource {
return source;
}
/**
* Klic srovnany na 32 bajtu.
*
* SHA-256 z hodnoty, aby fungoval jakkoliv dlouhy retezec. Neni to derivace
* hesla (na to by patril scrypt), ale klic ma byt nahodny retezec, 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.');
if (material === null) {
throw new Error('Klic pro sifrovani neni nastaveny, pristupove udaje nelze sifrovat.');
}
return createHash('sha256').update(config.secretsKey).digest();
return createHash('sha256').update(material).digest();
}
export function canSealSecrets(): boolean {
return config.secretsKey !== '';
return material !== null;
}
export function seal(value: string): SealedValue {
+8 -3
View File
@@ -16,7 +16,7 @@ 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 { flushConnectorStore, 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';
@@ -227,7 +227,7 @@ const server = app.listen(config.port, '0.0.0.0', () => {
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})` : ''}`);
console.info(`[start] uloziste konektoru: ${storage.mode}${storage.location ? ` (${storage.location})` : ''}`);
});
// Pool se pri ukonceni zavre, at se spojeni neopousti otevrena.
@@ -235,7 +235,12 @@ for (const signal of ['SIGTERM', 'SIGINT'] as const) {
process.on(signal, () => {
console.info(`[stop] ${signal}, ukoncuji`);
server.close(() => {
void closeDatabase().finally(() => process.exit(0));
// Rozepsany zapis do souboru se musi dokoncit, jinak se posledni zmena
// ztrati - debounce je kratky, ale nenulovy.
void flushConnectorStore()
.catch((err: unknown) => console.error('[stop] zapis dat selhal:', err))
.then(() => closeDatabase())
.finally(() => process.exit(0));
});
});
}
+43 -10
View File
@@ -2,6 +2,7 @@ import {
AlertTriangle,
CheckCircle2,
Eye,
Info,
EyeOff,
Pencil,
Plus,
@@ -118,17 +119,9 @@ 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.
konektory, a hleda chybu v aplikaci - presne to se stalo.
*/}
{storage?.ephemeral && (
<p className="flex items-start gap-2 rounded-card border border-warn-400/40 bg-warn-500/8 p-3 text-sm text-warn-400">
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
<span>
Přístupové údaje se ukládají jen do paměti serveru a restart je smaže.
<span className="mt-0.5 block text-white/50">{storage.reason}</span>
</span>
</p>
)}
{storage && storage.mode !== 'postgres' && <StorageNotice storage={storage} />}
<DataState loading={loading} error={error} onRetry={load}>
{(connectors?.length ?? 0) === 0 ? (
@@ -165,6 +158,46 @@ export default function Connectors() {
);
}
/**
* Hlaska o ulozisti.
*
* Rozlisuje tri veci, protoze pro uzivatele znamenaji uplne jiny nasledek:
* pamet (ztrata pri restartu), soubor (ztrata pri redeployi) a databaze
* (bez ztraty, hlaska se nezobrazuje).
*/
function StorageNotice({ storage }: { storage: StorageStatus }) {
const severe = storage.mode === 'memory';
return (
<div
className={cn(
'flex items-start gap-2 rounded-card border p-3 text-sm',
severe ? 'border-warn-400/40 bg-warn-500/8' : 'border-ink-600/70 bg-ink-850/50',
)}
>
{severe ? (
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-warn-400" />
) : (
<Info className="mt-0.5 size-4 shrink-0 text-white/40" />
)}
<div>
<p className={severe ? 'text-warn-400' : 'text-white/70'}>
{severe
? 'Přístupové údaje se ukládají jen do paměti serveru, restart je smaže.'
: 'Data se ukládají do souboru na serveru. Restart přežijí, nové nasazení ne.'}
</p>
<p className="mt-0.5 text-xs text-white/45">{storage.reason}</p>
{storage.secrets === 'file' && (
<p className="mt-0.5 text-xs text-white/35">
Údaje jsou zašifrované klíčem, který leží ve stejné složce. Chrání to proti
náhodnému přečtení souboru, ne proti přístupu k disku serveru.
</p>
)}
</div>
</div>
);
}
// ----------------------------------------------------------------- mala karta
function ConnectorCard({
+10 -4
View File
@@ -292,13 +292,19 @@ export interface Connector {
ready: boolean;
}
/** Kam se uklada a jestli to prezije restart. */
/** Kam se uklada a co to prezije. */
export interface StorageStatus {
mode: 'postgres' | 'memory';
/** Proc se jede v pameti. null u databaze. */
reason: string | null;
mode: 'postgres' | 'file' | 'memory';
/** Kratke vysvetleni pro uzivatele. */
reason: string;
/** true = restart procesu data smaze. */
ephemeral: boolean;
/** true = redeploy containeru data smaze, i kdyz restart ne. */
lostOnRedeploy: boolean;
/** Odkud je klic k sifrovani tajnych hodnot. */
secrets: 'env' | 'file' | 'none';
/** Kde data lezi. null u pameti. */
location: string | null;
}
export interface ConnectorTestResult {