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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
3279dd7dac
commit
78e7f99d60
+19
-17
@@ -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<string, number>();
|
||||
const counts = tenantId
|
||||
? await connectorCountsByService([tenantId])
|
||||
: new Map<string, number>();
|
||||
|
||||
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}` : ''),
|
||||
|
||||
+21
-10
@@ -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<typeof flowSchema>): z.infer<typeo
|
||||
* `tenantIds` je potreba kvuli konektorum: cizi konektor se musi chovat jako
|
||||
* neexistujici, jinak by strom mohl volat cizim jmenem.
|
||||
*/
|
||||
function validateFlowReferences(
|
||||
async function validateFlowReferences(
|
||||
flow: z.infer<typeof flowSchema>,
|
||||
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<void> => {
|
||||
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({
|
||||
|
||||
+9
-10
@@ -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<typeof toPublicConnector>] => 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.' });
|
||||
|
||||
Reference in New Issue
Block a user