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
+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));
});
});
}