Skripty konektoru: vykonna cast s manifestem a kontrolou parametru

Konektory dostaly vykonnou cast. Jeden skript je jeden soubor, ktery nese
manifest (vstupni a vystupni parametry) i kod. Diky manifestu s nim umi
pracovat strom automatizace, aniz by o kodu cokoliv vedel.

Soubory jsou zamerne obycejny JavaScript, ne TypeScript. TypeScript by se
musel prelozit a to je presne to otaceni, ktere tady nema byt. Registr
sleduje cas zmeny souboru, takze uprava v portalu, rucni uprava souboru
i novy soubor ve slozce funguji stejne a bez restartu.

Pridano:
- scripts/ se skripty konektoru, nazev souboru je zaroven ID operace
- kontrola vstupu i vystupu proti manifestu, jedna funkce pro obe strany.
  Chybejici povinny vystup je chyba skriptu, ne uzivatele - jinak by strom
  veril parametru, ktery nikdy nedosel
- ctx predavany skriptu: http nad adresou napojeni, util, log, config,
  idempotencyKey, fail a retry. Skript nedostane pristupove udaje
- rozliseni opakovatelne a koncove chyby. Runner nikdy nevyhodi vyjimku,
  vzdy vraci vysledek vcetne retryable
- redakce tajnych hodnot pred zapisem do logu. Cizi API rado vraci prijaty
  token v chybove zprave a log ticketu vidi klient
- napojeni z environment variables vcetne iDokladu
- sest ukazkovych skriptu pro iDoklad proti skutecnemu API sluzby
  services.csbot.cz/apps/idoklad, kazdy na jiny vzor
- stranka /dashboard/skripty: seznam, manifest, editor, zkusebni spusteni.
  Formular testu se sklada z manifestu, nepise se pro kazdy skript
- endpointy /api/dashboard/scripts vcetne Swaggeru

Zmeneno:
- katalog konektoru uz neni jen staticky seznam. Akce ze skriptu se domeruji
  prekryvem v src/data/connectors.ts, takze se naraz objevi ve validaci
  stromu, ve vypoctu scope i v sablonach. Pri stejnem ID vyhrava skript
- ConnectorOperation ma implementation a scriptId
- ApiError na klientovi nese cele telo odpovedi a umi z nej vytahnout issues
- Dockerfile kopiruje scripts/ do vysledneho image

Ukladani nemuze rozbit fungujici skript: kod se nejdriv zapise do docasneho
souboru, ten se nacte a overi, a az pak prepise puvodni.

K tomu tri dokumenty navrhu dalsich kroku: 09 datove modely a prava,
10 runtime a rozpocet na 150 klientu, 11 popis skriptu konektoru.

Overeno: npm run typecheck prochazi na serveru i webu.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
JiriUhlir
2026-08-12 13:37:58 +02:00
co-authored by Claude Opus 5
parent bbc2236c0d
commit 6f6b287d7e
34 changed files with 5546 additions and 14 deletions
+11 -2
View File
@@ -18,8 +18,8 @@ import {
} from '../data/automationStore.js';
import { operatorAllowedForType, operatorsByType } from '../data/conditions.js';
import {
connectorCatalog,
connectorCategories,
connectors,
findOperation,
providedFieldsFor,
} from '../data/connectors.js';
@@ -47,6 +47,7 @@ import {
type TicketStatus,
} from '../data/ticketStore.js';
import { requireAuth } from '../middleware/auth.js';
import { scriptsRouter } from './scripts.js';
import { streamRouter } from './stream.js';
export const dashboardRouter = Router();
@@ -341,12 +342,20 @@ dashboardRouter.post('/tickets/:id/comment', (req, res) => {
// Zivy stream zmen. Musi byt pred obecnymi cestami, aby ho nic neprebilo.
dashboardRouter.use('/stream', streamRouter);
// Skripty konektoru. Taky pred obecnymi cestami.
dashboardRouter.use('/scripts', scriptsRouter);
// ---------------------------------------------------------------- konektory
/**
* Katalog uz neni jen staticky seznam. Operace, ktere obsluhuje skript, se
* domeruji z jeho manifestu, takze builder vidi skutecne vstupy a vystupy.
* Podrobnosti v `src/scripts/catalog.ts`.
*/
dashboardRouter.get('/connectors', (_req, res) => {
res.json({
categories: connectorCategories,
items: connectors,
items: connectorCatalog(),
// Frontend potrebuje vedet, jake operatory nabidnout ke kteremu typu,
// a jakou zakladni adresu ukazat u webhooku.
operatorsByType,
+145
View File
@@ -0,0 +1,145 @@
/**
* Sprava skriptu konektoru z portalu.
*
* Cteni smi kazdy prihlaseny - builder potrebuje vedet, co skript umi.
* Uprava a spusteni smi jen spravce platformy. Uprava skriptu meni chovani
* vseho, co ho pouziva, takze to neni pravo, ktere se dava vedle prava
* zakladat tickety (viz documentation/09-navrh-rozsireni.md, bod 9).
*/
import { Router } from 'express';
import { z } from 'zod';
import { requirePlatformAdmin } from '../middleware/auth.js';
import { connectionStatus, connectorsWithAuth } from '../scripts/connections.js';
import { connectorIdOf, operationIdOf } from '../scripts/manifest.js';
import {
ensureLoaded,
getScript,
isValidScriptId,
listManifests,
readSource,
saveSource,
scriptProblems,
scriptsDir,
} from '../scripts/registry.js';
import { runScript } from '../scripts/runner.js';
export const scriptsRouter = Router();
/** Manifest plus to, co si klient nema dopocitavat sam. */
async function scriptSummaries() {
const manifests = await listManifests();
return manifests.map((manifest) => ({
...manifest,
connectorId: connectorIdOf(manifest.id),
operationId: operationIdOf(manifest.id),
}));
}
scriptsRouter.get('/', async (_req, res) => {
const [items, problems] = await Promise.all([scriptSummaries(), scriptProblems()]);
res.json({
items,
problems,
connections: connectorsWithAuth().map(connectionStatus),
/** Kam se soubory ukladaji. Kdo ma na server pristup, upravi je i rucne. */
directory: scriptsDir(),
});
});
scriptsRouter.post('/reload', requirePlatformAdmin, async (_req, res) => {
await ensureLoaded(true);
const [items, problems] = await Promise.all([scriptSummaries(), scriptProblems()]);
res.json({ items, problems });
});
scriptsRouter.get('/:id', async (req, res) => {
const { id } = req.params;
if (!isValidScriptId(id)) {
return res.status(400).json({ error: 'validation_error', message: 'Neplatné ID skriptu.' });
}
const [script, source] = await Promise.all([getScript(id), readSource(id)]);
if (source === null) {
return res.status(404).json({ error: 'not_found', message: 'Skript neexistuje.' });
}
// Manifest muze chybet, kdyz je soubor rozbity. Kod se vrati vzdy, aby slo opravit.
const problems = await scriptProblems();
return res.json({
id,
connectorId: connectorIdOf(id),
operationId: operationIdOf(id),
manifest: script?.manifest ?? null,
code: source,
problem: problems.find((problem) => problem.scriptId === id) ?? null,
connection: connectionStatus(connectorIdOf(id)),
});
});
const saveSchema = z.object({
code: z.string().min(1, 'Kód skriptu nesmí být prázdný.'),
});
scriptsRouter.put('/:id', requirePlatformAdmin, async (req, res) => {
const { id } = req.params;
if (!isValidScriptId(id)) {
return res.status(400).json({
error: 'validation_error',
message: 'Neplatné ID skriptu. Povolený tvar je konektor.operace.',
});
}
const parsed = saveSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({
error: 'validation_error',
message: parsed.error.issues[0]?.message ?? 'Neplatný vstup.',
});
}
const result = await saveSource(id, parsed.data.code);
if (!result.ok) {
// Rozbita uprava se neulozi a puvodni skript dal funguje.
return res.status(400).json({
error: 'validation_error',
message: result.message,
issues: result.issues ?? [],
});
}
return res.json({ id, manifest: result.manifest });
});
const testSchema = z.object({
inputs: z.record(z.unknown()).default({}),
});
/**
* Zkusebni spusteni.
*
* Vola opravdovou sluzbu, tedy vystavena faktura opravdu vznikne. Zamerne:
* test, ktery volani predstira, nerekne nic o tom, jestli skript funguje.
* Portal na to upozorni pred stiskem.
*/
scriptsRouter.post('/:id/test', requirePlatformAdmin, async (req, res) => {
const { id } = req.params;
if (!isValidScriptId(id)) {
return res.status(400).json({ error: 'validation_error', message: 'Neplatné ID skriptu.' });
}
const parsed = testSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({
error: 'validation_error',
message: 'Vstupy musí být objekt s hodnotami parametrů.',
});
}
const result = await runScript(id, parsed.data.inputs);
console.info(`[scripts] test ${id} uzivatelem ${req.user!.email}: ${result.ok ? 'ok' : 'chyba'}`);
// Chyba skriptu neni chyba API. Vysledek se vraci vzdy s 200 vcetne popisu.
return res.json(result);
});