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
+31
View File
@@ -8,9 +8,15 @@
*/
import { randomBytes } from 'node:crypto';
import path from 'node:path';
const isProduction = process.env.NODE_ENV === 'production';
function positiveNumber(value: string | undefined, fallback: number): number {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
/**
* Tajny klic pro podpis tokenu.
*
@@ -68,6 +74,31 @@ export const config = {
* relativni tvar - nikdy se nehardcoduje produkcni domena.
*/
publicOrigin: (process.env.PUBLIC_ORIGIN ?? '').trim().replace(/\/+$/, ''),
// ------------------------------------------------------- skripty konektoru
/**
* Adresar se skripty konektoru. Relativne k adresari, ze ktereho aplikace
* bezi, aby to fungovalo v containeru (`/app/scripts`) i lokalne.
*/
scriptsDir: path.resolve(process.env.SCRIPTS_DIR ?? path.join(process.cwd(), 'scripts')),
/**
* Zaklad adres napojenych sluzeb, napr. "https://services.csbot.cz/apps".
* Konkretni konektor lze presmerovat pres `<KONEKTOR>_BASE_URL`.
* Nikdy se nehardcoduje do logiky, viz AGENTS.md.
*/
servicesBaseUrl: (process.env.SERVICES_BASE_URL ?? 'https://services.csbot.cz/apps')
.trim()
.replace(/\/+$/, ''),
/** Strop na jeden beh skriptu, kdyz si ho manifest neurci sam. */
scriptTimeoutMs: positiveNumber(process.env.SCRIPT_TIMEOUT_MS, 15_000),
/** Vetsi odpoved cizi sluzby se zahodi, misto aby snedla pamet procesu. */
scriptMaxResponseBytes: positiveNumber(process.env.SCRIPT_MAX_RESPONSE_BYTES, 1_000_000),
/**
* Povoli skriptum volat na localhost a do privatnich rozsahu IP.
* Jen pro lokalni vyvoj, v nasazeni musi zustat vypnute.
*/
allowPrivateTargets: process.env.ALLOW_PRIVATE_TARGETS === 'true',
};
/** Zaklad verejne adresy aplikace vcetne prefixu proxy. */
+60 -1
View File
@@ -89,6 +89,14 @@ export interface ConnectorOperation {
* Diky nim jde stavet podminky nad daty, ktera si nikdo nevymyslel.
*/
providedFields?: ProvidedField[];
/**
* `script` = operaci obsluhuje skript ze slozky skriptu, tedy se opravdu
* vykona. Kdyz chybi, je to zatim jen zapis v katalogu.
* Doplnuje `src/scripts/catalog.ts`, rucne se to nepise.
*/
implementation?: 'script';
/** Ktery skript operaci obsluhuje. Vyplnene spolu s `implementation`. */
scriptId?: string;
}
export interface Connector {
@@ -1097,6 +1105,57 @@ export function findConnector(connectorId: string): Connector | undefined {
return connectors.find((c) => c.id === connectorId);
}
// ------------------------------------------------------- akce ze skriptu
/**
* Akce domerene ze skriptu konektoru. Plni to `src/scripts/registry.ts`
* pri kazdem nacteni skriptu.
*
* Je to prekryv, ne zapis do `connectors`. Dva duvody: staticky katalog
* zustane citelny a z operace jde poznat, odkud je (`implementation`).
*
* Prekryv je zamerne tady, ne ve zvlastnim modulu. Vsechno ostatni v aplikaci
* uz se pta pres `findOperation`, takze tim se skripty naraz objevi ve validaci
* stromu, ve vypoctu scope i v sablonach - bez toho, aby se to psalo trikrat.
*/
const scriptActions = new Map<string, ConnectorOperation[]>();
/** Nahradi cely prekryv. Volani je idempotentni, poradi nezalezi. */
export function setScriptActions(byConnector: Map<string, ConnectorOperation[]>): void {
scriptActions.clear();
for (const [connectorId, operations] of byConnector) {
scriptActions.set(connectorId, operations);
}
}
/**
* Akce konektoru vcetne tech ze skriptu.
* Kdyz skript nese ID operace, ktera uz v katalogu je, **skript vyhrava**.
* Staticky zapis je popis toho, co umime, skript je to, co se opravdu stane.
*/
export function actionsFor(connectorId: string): ConnectorOperation[] {
const connector = findConnector(connectorId);
if (!connector) return [];
const fromScripts = scriptActions.get(connectorId);
if (!fromScripts || fromScripts.length === 0) return connector.actions;
const replaced = new Set(fromScripts.map((operation) => operation.id));
return [
...connector.actions.filter((action) => !replaced.has(action.id)),
...fromScripts,
].sort((a, b) => a.name.localeCompare(b.name, 'cs'));
}
/** Katalog pro portal. Nemodifikuje `connectors`, sklada nove objekty. */
export function connectorCatalog(): Connector[] {
return connectors.map((connector) =>
scriptActions.has(connector.id)
? { ...connector, actions: actionsFor(connector.id) }
: connector,
);
}
/**
* Overi, ze konektor existuje a ma danou operaci pozadovaneho druhu.
* Pouziva se pri ukladani stromu, aby se do nej nedostaly neexistujici kroky.
@@ -1108,7 +1167,7 @@ export function findOperation(
): ConnectorOperation | undefined {
const connector = findConnector(connectorId);
if (!connector) return undefined;
const pool = type === 'trigger' ? connector.triggers : connector.actions;
const pool = type === 'trigger' ? connector.triggers : actionsFor(connectorId);
return pool.find((op) => op.id === operationId);
}
+9
View File
@@ -16,6 +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 { ensureLoaded, scriptsDir } from './scripts/registry.js';
const here = path.dirname(fileURLToPath(import.meta.url));
/** Zbuildovana SPA. Vite ji zapisuje do dist/public, viz vite.config.ts. */
@@ -168,11 +169,19 @@ app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {
});
});
/**
* Skripty konektoru se nactou jeste pred prijimanim provozu, protoze doplnuji
* katalog a bez nich by prvni ulozeni stromu neznalo operace ze skriptu.
* `ensureLoaded` chyby polyka a loguje, takze start nemuze shodit (AGENTS.md).
*/
await ensureLoaded(true);
// 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()}`);
});
server.on('error', (err: NodeJS.ErrnoException) => {
+306
View File
@@ -26,6 +26,7 @@ export function buildOpenApiDocument() {
{ name: 'Dashboard', description: 'Data klientskeho portalu' },
{ name: 'Tickety', description: 'Pozadavky, jejich resitele a log prubehu' },
{ name: 'Automatizace', description: 'Sprava automatizaci a stromu akci' },
{ name: 'Skripty', description: 'Vykonna cast konektoru: manifest, kod a zkusebni beh' },
{ name: 'Simulace', description: 'Vyvolani provoznich udalosti pro nahled' },
{ name: 'Webhook', description: 'Verejny prijem dat do automatizace' },
{ name: 'Kontakt', description: 'Poptavkovy formular z webu' },
@@ -98,6 +99,145 @@ export function buildOpenApiDocument() {
personId: { type: 'string', nullable: true },
},
},
ScriptField: {
type: 'object',
description:
'Parametr skriptu. Stejny tvar pro vstup i vystup - kontrola je pak ' +
'jedna funkce, ne dve skoro stejne.',
required: ['id', 'label', 'type', 'required'],
properties: {
id: {
type: 'string',
example: 'invoiceId',
description: 'Pouziva se v sablonach jako {{invoiceId}}.',
},
label: { type: 'string', example: 'ID faktury v iDokladu' },
type: { type: 'string', enum: ['string', 'number', 'boolean', 'date'] },
required: { type: 'boolean' },
hint: { type: 'string' },
options: {
type: 'array',
description: 'Vyber z hodnot. Jina hodnota neprojde kontrolou.',
items: {
type: 'object',
properties: { value: { type: 'string' }, label: { type: 'string' } },
},
},
pattern: { type: 'string', description: 'Jen u typu string.' },
multiline: { type: 'boolean', description: 'Jen u typu string.' },
default: { description: 'Dosadi se, kdyz hodnota chybi a parametr neni povinny.' },
},
},
ScriptManifest: {
type: 'object',
description: 'Co skript umi. Podle nej s nim umi pracovat strom automatizace.',
properties: {
id: {
type: 'string',
example: 'idoklad.get-issued-invoice',
description: 'Tvar konektor.operace. Nazev souboru musi byt <id>.js.',
},
connectorId: { type: 'string', example: 'idoklad' },
operationId: { type: 'string', example: 'get-issued-invoice' },
name: { type: 'string', example: 'Získat vydanou fakturu' },
description: { type: 'string' },
inputs: { type: 'array', items: { $ref: '#/components/schemas/ScriptField' } },
outputs: { type: 'array', items: { $ref: '#/components/schemas/ScriptField' } },
timeoutMs: { type: 'integer', example: 15000 },
},
},
ScriptProblem: {
type: 'object',
description:
'Rozbity skript. Nesmi shodit ostatni ani tise zmizet, proto se vraci sem.',
properties: {
file: { type: 'string', example: 'idoklad.get-issued-invoice.js' },
scriptId: { type: 'string', nullable: true },
message: { type: 'string' },
issues: {
type: 'array',
items: {
type: 'object',
properties: { field: { type: 'string' }, message: { type: 'string' } },
},
},
},
},
ConnectionStatus: {
type: 'object',
description:
'Stav napojeni konektoru. Hodnoty pristupovych udaju se nevraci nikdy, ' +
'jen jmena promennych, ktere chybi.',
properties: {
connectorId: { type: 'string', example: 'idoklad' },
baseUrl: { type: 'string', example: 'https://services.csbot.cz/apps/idoklad' },
ready: { type: 'boolean' },
missing: {
type: 'array',
items: { type: 'string' },
example: ['IDOKLAD_CLIENT_SECRET'],
},
headers: { type: 'array', items: { type: 'string' }, example: ['X-ClientId'] },
},
},
ScriptRunResult: {
type: 'object',
description:
'Vysledek behu skriptu. `retryable` rika, jestli ma smysl zkusit to znovu - ' +
'timeout ano, spatny vstup ne.',
properties: {
ok: { type: 'boolean' },
scriptId: { type: 'string' },
outputs: {
type: 'object',
additionalProperties: true,
description: 'Prazdne, kdyz beh selhal.',
},
logs: {
type: 'array',
items: {
type: 'object',
properties: {
at: { type: 'string', format: 'date-time' },
message: { type: 'string' },
detail: { type: 'string' },
},
},
},
durationMs: { type: 'integer' },
httpCalls: { type: 'integer' },
error: {
type: 'object',
nullable: true,
properties: {
kind: {
type: 'string',
enum: [
'not_found',
'config',
'validation',
'output',
'retryable',
'terminal',
'timeout',
'internal',
],
},
message: { type: 'string' },
retryable: { type: 'boolean' },
status: { type: 'integer' },
detail: { type: 'string' },
issues: {
type: 'array',
items: {
type: 'object',
properties: { field: { type: 'string' }, message: { type: 'string' } },
},
},
},
},
},
},
LoginRequest: {
type: 'object',
required: ['email', 'password'],
@@ -811,10 +951,176 @@ export function buildOpenApiDocument() {
get: {
tags: ['Automatizace'],
summary: 'Katalog konektoru',
description:
'Operace, ktere obsluhuje skript, nesou `implementation: script` a `scriptId`, ' +
'a maji skutecne `inputs` a `outputFields` z manifestu toho skriptu.',
security: [{ bearerAuth: [] }],
responses: { '200': { description: 'Konektory, kategorie a operatory podminek' } },
},
},
'/api/dashboard/scripts': {
get: {
tags: ['Skripty'],
summary: 'Seznam skriptu konektoru',
description:
'Manifesty vsech nactenych skriptu, rozbite skripty v `problems` ' +
'a stav napojeni v `connections`. Pristupove udaje se nikdy nevraci, ' +
'jen jmena chybejicich environment variables.',
security: [{ bearerAuth: [] }],
responses: {
'200': {
description: 'Skripty, problemy a stav napojeni',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
items: {
type: 'array',
items: { $ref: '#/components/schemas/ScriptManifest' },
},
problems: {
type: 'array',
items: { $ref: '#/components/schemas/ScriptProblem' },
},
connections: {
type: 'array',
items: { $ref: '#/components/schemas/ConnectionStatus' },
},
directory: { type: 'string', example: '/app/scripts' },
},
},
},
},
},
},
},
},
'/api/dashboard/scripts/reload': {
post: {
tags: ['Skripty'],
summary: 'Znovu nacist skripty ze slozky',
description:
'Skripty se nacitaji samy podle casu zmeny souboru. Tenhle endpoint ' +
'to jen vynuti hned, bez cekani.',
security: [{ bearerAuth: [] }],
responses: {
'200': { description: 'Skripty po nacteni' },
'403': { description: 'Jen spravce platformy' },
},
},
},
'/api/dashboard/scripts/{id}': {
get: {
tags: ['Skripty'],
summary: 'Manifest a kod skriptu',
security: [{ bearerAuth: [] }],
parameters: [
{
name: 'id',
in: 'path',
required: true,
schema: { type: 'string' },
example: 'idoklad.get-issued-invoice',
},
],
responses: {
'200': {
description: 'Kod se vraci vzdy. `manifest` je null, kdyz je skript rozbity.',
},
'400': { description: 'Neplatne ID skriptu' },
'404': { description: 'Skript neexistuje' },
},
},
put: {
tags: ['Skripty'],
summary: 'Ulozit kod skriptu',
description:
'Nejdriv se kod nacte a overi, az pak prepise soubor. Rozbita uprava ' +
'se neulozi a puvodni skript dal funguje.',
security: [{ bearerAuth: [] }],
parameters: [
{
name: 'id',
in: 'path',
required: true,
schema: { type: 'string' },
example: 'idoklad.get-issued-invoice',
},
],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['code'],
properties: {
code: {
type: 'string',
description: 'Cely obsah souboru vcetne exportu manifest a run.',
},
},
},
},
},
},
responses: {
'200': { description: 'Ulozeno, vraci se overeny manifest' },
'400': {
description: 'Kod nebo manifest neprosel, v `issues` je co opravit',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
'403': { description: 'Jen spravce platformy' },
},
},
},
'/api/dashboard/scripts/{id}/test': {
post: {
tags: ['Skripty'],
summary: 'Zkusebni spusteni skriptu',
description:
'POZOR: vola opravdovou sluzbu. Vystavena faktura opravdu vznikne. ' +
'Chyba skriptu neni chyba API, vraci se 200 s popisem v `error`.',
security: [{ bearerAuth: [] }],
parameters: [
{
name: 'id',
in: 'path',
required: true,
schema: { type: 'string' },
example: 'idoklad.get-issued-invoice',
},
],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
properties: {
inputs: {
type: 'object',
additionalProperties: true,
example: { invoiceId: 12345 },
},
},
},
},
},
},
responses: {
'200': {
description: 'Vysledek behu',
content: {
'application/json': { schema: { $ref: '#/components/schemas/ScriptRunResult' } },
},
},
'400': { description: 'Neplatne ID nebo vstupy' },
'403': { description: 'Jen spravce platformy' },
},
},
},
'/api/dashboard/automations': {
get: {
tags: ['Automatizace'],
+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);
});
+139
View File
@@ -0,0 +1,139 @@
/**
* Napojeni konektoru: kam se vola a cim se to autorizuje.
*
* Zamerne oddelene od skriptu. Skript rika "GET /issued-invoices/12",
* napojeni rika, na jake adrese to je a jaké hlavicky se pridaji. Skript se
* tim k pristupovym udajum vubec nedostane.
*
* Tady je zatim jedno napojeni na konektor, sestavene z environment variables.
* Cilovy stav je napojeni za firmu v databazi, viz documentation/09, bod 9.
* Az to bude, prepise se vnitrek `resolveConnection` a nic dalsiho.
*/
import { config } from '../config.js';
export interface ConnectorAuthSpec {
/** Hlavicka -> jmeno environment variable, ze ktere se plni. */
headers: Record<string, string>;
/** Ktere hlavicky musi byt vyplnene, aby se dalo volat. */
required: string[];
/** Necitliva nastaveni pristupna skriptu jako `ctx.config`. */
config?: Record<string, string>;
}
/**
* Autorizace jednotlivych konektoru.
*
* iDoklad podle https://services.csbot.cz/apps/idoklad/docs: sluzba prijima
* `X-ClientId` a `X-ClientSecret`, `X-ApplicationId` jen partnerske aplikace.
* OAuth tok resi ta sluzba, my posilame jen tyto hlavicky.
*/
const authSpecs: Record<string, ConnectorAuthSpec> = {
idoklad: {
headers: {
'X-ClientId': 'IDOKLAD_CLIENT_ID',
'X-ClientSecret': 'IDOKLAD_CLIENT_SECRET',
'X-ApplicationId': 'IDOKLAD_APPLICATION_ID',
},
required: ['X-ClientId', 'X-ClientSecret'],
config: { language: 'IDOKLAD_LANGUAGE' },
},
};
export interface ResolvedConnection {
connectorId: string;
name: string;
baseUrl: string;
/** Vcetne tajemstvi. Nikdy neposilat na klienta ani do logu. */
headers: Record<string, string>;
/** Necitliva cast, skript ji vidi jako `ctx.config`. */
config: Record<string, string>;
/** false = chybi pristupove udaje, volat nema smysl. */
ready: boolean;
/** Jmena environment variables, ktere chybi. */
missing: string[];
}
/** `search-console` -> `SEARCH_CONSOLE`, aby slo skladat jmena promennych. */
function envPrefix(connectorId: string): string {
return connectorId.replace(/-/g, '_').toUpperCase();
}
function readEnv(name: string): string | undefined {
const value = process.env[name];
return value !== undefined && value.trim() !== '' ? value.trim() : undefined;
}
/**
* Vychozi adresa sluzby. Verejna domena se nikdy nehardcoduje do logiky,
* bere se z `SERVICES_BASE_URL` (viz AGENTS.md).
* Jednotlive konektory lze presmerovat pres `<KONEKTOR>_BASE_URL`.
*/
function resolveBaseUrl(connectorId: string): string {
return (
readEnv(`${envPrefix(connectorId)}_BASE_URL`) ?? `${config.servicesBaseUrl}/${connectorId}`
);
}
export function resolveConnection(connectorId: string): ResolvedConnection {
const spec = authSpecs[connectorId];
const headers: Record<string, string> = {};
const scriptConfig: Record<string, string> = {};
const missing: string[] = [];
for (const [header, envName] of Object.entries(spec?.headers ?? {})) {
const value = readEnv(envName);
if (value !== undefined) headers[header] = value;
else if (spec?.required.includes(header)) missing.push(envName);
}
for (const [key, envName] of Object.entries(spec?.config ?? {})) {
const value = readEnv(envName);
if (value !== undefined) scriptConfig[key] = value;
}
return {
connectorId,
name: `${connectorId} (z environment variables)`,
baseUrl: resolveBaseUrl(connectorId),
headers,
config: scriptConfig,
ready: missing.length === 0,
missing,
};
}
/** Hodnoty, ktere se musi zredigovat, nez cokoliv skonci v logu. */
export function connectionSecrets(connection: ResolvedConnection): string[] {
return Object.values(connection.headers);
}
export interface ConnectionStatus {
connectorId: string;
baseUrl: string;
ready: boolean;
/** Jen jmena chybejicich promennych, nikdy hodnoty. */
missing: string[];
/** Ktere hlavicky jsou vyplnene. Hodnoty se nevraci. */
headers: string[];
}
/**
* Stav napojeni pro portal. Vraci se **jen jmena**, nikdy hodnoty -
* secrets se z beznych endpointu nevraci (AGENTS.md).
*/
export function connectionStatus(connectorId: string): ConnectionStatus {
const connection = resolveConnection(connectorId);
return {
connectorId,
baseUrl: connection.baseUrl,
ready: connection.ready,
missing: connection.missing,
headers: Object.keys(connection.headers),
};
}
/** Ktere konektory maji popsanou autorizaci. */
export function connectorsWithAuth(): string[] {
return Object.keys(authSpecs);
}
+201
View File
@@ -0,0 +1,201 @@
/**
* HTTP klient, ktery dostane skript jako `ctx.http`.
*
* Delá ctyri veci, ktere by jinak resil kazdy skript znovu a spatne:
* - sklada adresu z napojeni, takze skript zna jen cestu,
* - pridava autorizacni hlavicky, takze skript nezna tajemstvi,
* - rozlisuje opakovatelnou chybu od koncove,
* - loguje volani bez hlavicek a bez tel, jen metodu, cestu a kod.
*/
import { config } from '../config.js';
import type { ResolvedConnection } from './connections.js';
import { ScriptError, type ScriptHttp, type ScriptHttpOptions, type ScriptHttpResponse } from './types.js';
import { describe } from './util.js';
/** Kody, u kterych ma smysl opakovat. Zbytek je koncova chyba. */
const retryableStatuses = new Set([408, 425, 429, 500, 502, 503, 504]);
/** Chyby spojeni od Node. Vsechny jsou docasne. */
const retryableCodes = new Set([
'ECONNRESET',
'ECONNREFUSED',
'ETIMEDOUT',
'EAI_AGAIN',
'EPIPE',
'ENOTFOUND',
'UND_ERR_SOCKET',
'UND_ERR_CONNECT_TIMEOUT',
]);
const privateHostPattern =
/^(localhost|127\.|0\.0\.0\.0$|10\.|192\.168\.|169\.254\.|::1$|\[::1\]$|172\.(1[6-9]|2\d|3[01])\.)/i;
function joinUrl(baseUrl: string, path: string): string {
const base = baseUrl.replace(/\/+$/, '');
const suffix = path.startsWith('/') ? path : `/${path}`;
return `${base}${suffix}`;
}
/**
* Adresu skladame my z napojeni, ale az budou napojeni nastavovat klienti,
* je tohle to jedine, co brani volani na vnitrni sit. Proto tady, ne pozdeji.
*/
function assertAllowedUrl(url: URL): void {
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
throw new ScriptError('config', `Adresa ${url.protocol} není povolená, jen http a https.`);
}
if (!config.allowPrivateTargets && privateHostPattern.test(url.hostname)) {
throw new ScriptError(
'config',
`Adresa ${url.hostname} míří do vnitřní sítě. Pro místní vývoj nastavte ALLOW_PRIVATE_TARGETS=true.`,
);
}
}
function buildUrl(connection: ResolvedConnection, path: string, options?: ScriptHttpOptions): URL {
let url: URL;
try {
url = new URL(joinUrl(connection.baseUrl, path));
} catch {
throw new ScriptError('config', `Neplatná adresa: ${joinUrl(connection.baseUrl, path)}`);
}
for (const [key, value] of Object.entries(options?.query ?? {})) {
if (value === undefined || value === null || value === '') continue;
url.searchParams.set(key, String(value));
}
assertAllowedUrl(url);
return url;
}
function statusError(status: number, url: URL, detail: string): ScriptError {
const where = `${url.pathname} vrátilo HTTP ${status}`;
if (retryableStatuses.has(status)) {
return new ScriptError('retryable', `Služba je momentálně nedostupná: ${where}.`, {
status,
detail,
});
}
if (status === 401 || status === 403) {
return new ScriptError('config', `Přístup zamítnut: ${where}. Zkontrolujte přístupové údaje.`, {
status,
detail,
});
}
if (status === 404) {
return new ScriptError('terminal', `Záznam nenalezen: ${where}.`, { status, detail });
}
return new ScriptError('terminal', `Volání selhalo: ${where}.`, { status, detail });
}
function transportError(err: unknown, url: URL): ScriptError {
if (err instanceof ScriptError) return err;
const code =
err !== null && typeof err === 'object' && 'code' in err ? String((err as { code: unknown }).code) : '';
const name = err instanceof Error ? err.name : '';
const message = err instanceof Error ? err.message : String(err);
if (name === 'AbortError' || name === 'TimeoutError') {
return new ScriptError('timeout', `Volání ${url.pathname} nedoběhlo v limitu.`, { cause: err });
}
if (retryableCodes.has(code)) {
return new ScriptError('retryable', `Nepodařilo se spojit se službou (${code}).`, { cause: err });
}
return new ScriptError('retryable', `Volání ${url.pathname} selhalo: ${message}`, { cause: err });
}
export interface CreateHttpOptions {
connection: ResolvedConnection;
signal: AbortSignal;
idempotencyKey: string;
/** Zredigovana verze textu, aby se tajemstvi nedostalo do logu. */
redact: (value: string) => string;
log: (message: string, detail?: unknown) => void;
onCall: () => void;
}
export function createHttp(options: CreateHttpOptions): ScriptHttp {
const { connection, signal, idempotencyKey, redact, log, onCall } = options;
async function request<T>(
method: string,
path: string,
body: unknown,
httpOptions?: ScriptHttpOptions,
): Promise<ScriptHttpResponse<T>> {
const url = buildUrl(connection, path, httpOptions);
const hasBody = body !== undefined && method !== 'GET' && method !== 'DELETE';
const startedAt = Date.now();
onCall();
let response: Response;
try {
response = await fetch(url, {
method,
signal,
headers: {
Accept: 'application/json',
// Druhy pokus tehoz kroku nesmi vystavit druhou fakturu.
'Idempotency-Key': idempotencyKey,
...connection.headers,
...(hasBody ? { 'Content-Type': 'application/json' } : {}),
...httpOptions?.headers,
},
body: hasBody ? JSON.stringify(body) : undefined,
});
} catch (err) {
throw transportError(err, url);
}
const declaredSize = Number(response.headers.get('content-length') ?? 0);
if (declaredSize > config.scriptMaxResponseBytes) {
throw new ScriptError(
'terminal',
`Odpověď je větší než povolený limit ${config.scriptMaxResponseBytes} bajtů.`,
{ status: response.status },
);
}
const raw = await response.text();
if (raw.length > config.scriptMaxResponseBytes) {
throw new ScriptError(
'terminal',
`Odpověď je větší než povolený limit ${config.scriptMaxResponseBytes} bajtů.`,
{ status: response.status },
);
}
const isJson = response.headers.get('content-type')?.includes('json') ?? false;
let parsed: unknown = raw;
if (isJson && raw.length > 0) {
try {
parsed = JSON.parse(raw);
} catch {
throw new ScriptError('terminal', `Odpověď ${url.pathname} není platný JSON.`, {
status: response.status,
detail: redact(describe(raw, 300)),
});
}
}
log(`${method} ${url.pathname} -> ${response.status} (${Date.now() - startedAt} ms)`);
const allowed = httpOptions?.allowStatus ?? [];
if (!response.ok && !allowed.includes(response.status)) {
throw statusError(response.status, url, redact(describe(parsed, 400)));
}
return { status: response.status, body: parsed as T };
}
return {
get: (path, httpOptions) => request('GET', path, undefined, httpOptions),
post: (path, body, httpOptions) => request('POST', path, body, httpOptions),
patch: (path, body, httpOptions) => request('PATCH', path, body, httpOptions),
put: (path, body, httpOptions) => request('PUT', path, body, httpOptions),
del: (path, httpOptions) => request('DELETE', path, undefined, httpOptions),
};
}
+102
View File
@@ -0,0 +1,102 @@
/**
* Prevody kolem manifestu skriptu.
*
* Skript je zdroj pravdy o svych parametrech. Katalog konektoru z nich jen
* odvozuje to, co potrebuje builder. Kdyby se pole psala na dvou mistech,
* jedno by se casem rozeslo a strom by nabizel parametr, ktery skript nezna.
*/
import type { ConnectorOperation, ProvidedField } from '../data/connectors.js';
import type { OperationField } from '../data/connectors.js';
import { scriptManifestSchema, type FieldIssue, type ScriptField, type ScriptManifest } from './types.js';
/** `idoklad.get-issued-invoice` -> `idoklad` */
export function connectorIdOf(scriptId: string): string {
return scriptId.slice(0, scriptId.indexOf('.'));
}
/** `idoklad.get-issued-invoice` -> `get-issued-invoice` */
export function operationIdOf(scriptId: string): string {
return scriptId.slice(scriptId.indexOf('.') + 1);
}
export type ParseResult =
| { ok: true; manifest: ScriptManifest }
| { ok: false; issues: FieldIssue[] };
/**
* Overi manifest a zaroven to, ze odpovida nazvu souboru.
* Nesoulad nazvu je chyba, ne varovani - jinak by se skript ulozil pod jednim
* jmenem a nacetl pod druhym.
*/
export function parseManifest(raw: unknown, expectedId: string): ParseResult {
const parsed = scriptManifestSchema.safeParse(raw);
if (!parsed.success) {
return {
ok: false,
issues: parsed.error.issues.map((issue) => ({
field: issue.path.join('.') || 'manifest',
message: issue.message,
})),
};
}
if (parsed.data.id !== expectedId) {
return {
ok: false,
issues: [
{
field: 'id',
message: `Manifest má id "${parsed.data.id}", ale soubor se jmenuje "${expectedId}.js". Musí být stejné.`,
},
],
};
}
return { ok: true, manifest: parsed.data };
}
/**
* Nastavitelne pole akce pro builder.
* `kind` se odvodi z manifestu, aby se nemuselo psat dvakrat.
*/
function toOperationField(field: ScriptField): OperationField {
return {
id: field.id,
label: field.label,
kind: field.options ? 'choice' : field.multiline ? 'longtext' : 'text',
required: field.required,
...(field.options ? { options: field.options } : {}),
...(field.hint ? { hint: field.hint } : {}),
};
}
/**
* Vystup kroku pro strom.
*
* `id` nese prefix konektoru, protoze se na nej odkazuji podminky v ulozenych
* stromech a musi byt jednoznacne. `name` je to, co se pise do sablony -
* stejne rozdeleni jako u ostatnich konektoru, viz documentation/06-tickety.md.
*/
function toProvidedField(scriptId: string, field: ScriptField): ProvidedField {
return {
id: `${connectorIdOf(scriptId)}.${field.id}`,
name: field.id,
type: field.type,
required: field.required,
};
}
/** Operace katalogu odvozena ze skriptu. Tohle vidi builder. */
export function toConnectorOperation(manifest: ScriptManifest): ConnectorOperation {
return {
id: operationIdOf(manifest.id),
name: manifest.name,
description: manifest.description,
inputs: manifest.inputs.map(toOperationField),
outputFields: manifest.outputs.map((field) => toProvidedField(manifest.id, field)),
implementation: 'script',
scriptId: manifest.id,
};
}
+318
View File
@@ -0,0 +1,318 @@
/**
* Nacitani skriptu ze souboru.
*
* Myslenka: skript jde vytvorit nebo upravit v rozhrani i rucne v souboru
* a **nic se kvuli tomu neotaci**. Registr proto sleduje cas zmeny souboru
* a pri zmene ho nacte znovu. Nazev souboru je `<id>.js`, takze mezi souborem
* a operaci v katalogu neni zadna mapa, ktera by mohla lhat.
*
* Soubory jsou zamerne obycejny JavaScript, ne TypeScript. TypeScript by se
* musel prelozit, a to je presne to otaceni, ktere tady nema byt.
*
* Rozbity skript nesmi shodit ostatni. Zapise se do `problems()` a portal ho
* ukaze - zadna ticha selhani.
*/
import fs from 'node:fs/promises';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { config } from '../config.js';
import { connectors, setScriptActions, type ConnectorOperation } from '../data/connectors.js';
import { connectorIdOf, parseManifest, toConnectorOperation } from './manifest.js';
import type { FieldIssue, ScriptContext, ScriptManifest, ScriptValues } from './types.js';
export type ScriptRunFn = (
inputs: ScriptValues,
ctx: ScriptContext,
) => Promise<unknown> | unknown;
export interface LoadedScript {
manifest: ScriptManifest;
run: ScriptRunFn;
file: string;
mtimeMs: number;
}
export interface ScriptProblem {
/** Nazev souboru, ne cela cesta - cesta na serveru nikomu nic nerekne. */
file: string;
scriptId: string | null;
message: string;
issues?: FieldIssue[];
}
/** ID smi byt jen tohle. Chrani zapis i cteni proti vyskoku z adresare. */
const idPattern = /^[a-z][a-z0-9-]*\.[a-z][a-z0-9-]*$/;
const scripts = new Map<string, LoadedScript>();
const problems = new Map<string, ScriptProblem>();
/** Nez se znovu prohleda adresar. Bez toho by se stat volal pri kazdem dotazu. */
const RESCAN_MS = 1000;
let lastScanAt = 0;
let scanning: Promise<void> | null = null;
export function scriptsDir(): string {
return config.scriptsDir;
}
export function isValidScriptId(id: string): boolean {
return idPattern.test(id);
}
function fileFor(id: string): string {
if (!isValidScriptId(id)) throw new Error(`Neplatné ID skriptu: ${id}`);
return path.join(scriptsDir(), `${id}.js`);
}
function idFor(fileName: string): string {
return fileName.replace(/\.js$/, '');
}
function problemMessage(err: unknown): string {
if (err instanceof Error) return err.message;
return String(err);
}
/**
* Nacte jeden soubor. Query `?v=` je nutna - bez ni si Node drzi prvni verzi
* modulu v cache a uprava souboru by se nikdy neprojevila.
*/
async function importScript(
file: string,
mtimeMs: number,
expectedId: string,
): Promise<LoadedScript | ScriptProblem> {
const fileName = path.basename(file);
const url = `${pathToFileURL(file).href}?v=${mtimeMs}`;
let module: { manifest?: unknown; run?: unknown };
try {
module = (await import(url)) as { manifest?: unknown; run?: unknown };
} catch (err) {
return { file: fileName, scriptId: expectedId, message: `Soubor se nepodařilo načíst: ${problemMessage(err)}` };
}
if (typeof module.run !== 'function') {
return {
file: fileName,
scriptId: expectedId,
message: 'Soubor musí exportovat funkci run(inputs, ctx).',
};
}
const parsed = parseManifest(module.manifest, expectedId);
if (!parsed.ok) {
return {
file: fileName,
scriptId: expectedId,
message: 'Manifest není platný.',
issues: parsed.issues,
};
}
return {
manifest: parsed.manifest,
run: module.run as ScriptRunFn,
file: fileName,
mtimeMs,
};
}
function isProblem(value: LoadedScript | ScriptProblem): value is ScriptProblem {
return 'message' in value;
}
async function scan(): Promise<void> {
const dir = scriptsDir();
let entries: string[];
try {
entries = await fs.readdir(dir);
} catch (err) {
// Chybejici adresar neni chyba aplikace, jen nejsou zadne skripty.
console.warn(`[scripts] adresar ${dir} nelze precist: ${problemMessage(err)}`);
scripts.clear();
problems.clear();
return;
}
// Soubory od podtrzitka jsou pomocne, nejsou to skripty.
const files = entries.filter((name) => name.endsWith('.js') && !name.startsWith('_'));
const seen = new Set<string>();
for (const fileName of files) {
const id = idFor(fileName);
seen.add(id);
const file = path.join(dir, fileName);
if (!isValidScriptId(id)) {
problems.set(id, {
file: fileName,
scriptId: null,
message: 'Název souboru musí mít tvar konektor.operace.js, jen malá písmena a pomlčky.',
});
continue;
}
let mtimeMs: number;
try {
mtimeMs = (await fs.stat(file)).mtimeMs;
} catch (err) {
problems.set(id, { file: fileName, scriptId: id, message: problemMessage(err) });
continue;
}
const cached = scripts.get(id);
if (cached && cached.mtimeMs === mtimeMs) {
problems.delete(id);
continue;
}
const loaded = await importScript(file, mtimeMs, id);
if (isProblem(loaded)) {
// Rozbita uprava nesmi zahodit posledni funkcni verzi v pameti.
problems.set(id, loaded);
console.error(`[scripts] ${fileName}: ${loaded.message}`);
continue;
}
scripts.set(id, loaded);
problems.delete(id);
console.info(`[scripts] nacten ${id} (${loaded.manifest.name})`);
}
for (const id of [...scripts.keys()]) {
if (!seen.has(id)) {
scripts.delete(id);
console.info(`[scripts] ${id} zmizel z adresare`);
}
}
for (const id of [...problems.keys()]) {
if (!seen.has(id)) problems.delete(id);
}
publishToCatalog();
}
/**
* Prenese nactene skripty do katalogu konektoru.
*
* Tim se naraz objevi ve validaci stromu, ve vypoctu toho, co je v kterem kroku
* videt, i v sablonach - vsechno se uz pta pres `findOperation`.
*/
function publishToCatalog(): void {
const byConnector = new Map<string, ConnectorOperation[]>();
for (const script of scripts.values()) {
const connectorId = connectorIdOf(script.manifest.id);
if (!connectors.some((connector) => connector.id === connectorId)) {
problems.set(script.manifest.id, {
file: script.file,
scriptId: script.manifest.id,
message: `Konektor ${connectorId} v katalogu neexistuje. Skript se nedá použít ve stromu.`,
});
continue;
}
const list = byConnector.get(connectorId) ?? [];
list.push(toConnectorOperation(script.manifest));
byConnector.set(connectorId, list);
}
setScriptActions(byConnector);
}
/** Prohleda adresar, nejvyse jednou za RESCAN_MS. Soubezne volani se sdili. */
export async function ensureLoaded(force = false): Promise<void> {
if (!force && Date.now() - lastScanAt < RESCAN_MS) return;
if (scanning) return scanning;
scanning = scan()
.catch((err: unknown) => {
console.error('[scripts] nacitani selhalo:', err);
})
.finally(() => {
lastScanAt = Date.now();
scanning = null;
});
return scanning;
}
export async function listScripts(): Promise<LoadedScript[]> {
await ensureLoaded();
return [...scripts.values()].sort((a, b) => a.manifest.id.localeCompare(b.manifest.id));
}
export async function listManifests(): Promise<ScriptManifest[]> {
return (await listScripts()).map((script) => script.manifest);
}
export async function getScript(id: string): Promise<LoadedScript | undefined> {
await ensureLoaded();
return scripts.get(id);
}
export async function scriptProblems(): Promise<ScriptProblem[]> {
await ensureLoaded();
return [...problems.values()].sort((a, b) => a.file.localeCompare(b.file));
}
export async function readSource(id: string): Promise<string | null> {
if (!isValidScriptId(id)) return null;
try {
return await fs.readFile(fileFor(id), 'utf8');
} catch {
return null;
}
}
export type SaveResult =
| { ok: true; manifest: ScriptManifest }
| { ok: false; message: string; issues?: FieldIssue[] };
/**
* Ulozi kod skriptu.
*
* Poradi je zamerne: nejdriv se zapise do docasneho souboru, ten se nacte
* a overi, a az pak prepise puvodni. Rozbita uprava tim nikdy neshodi
* skript, ktery fungoval.
*/
export async function saveSource(id: string, code: string): Promise<SaveResult> {
if (!isValidScriptId(id)) {
return { ok: false, message: 'Neplatné ID skriptu. Povolený tvar je konektor.operace.' };
}
if (code.trim().length === 0) {
return { ok: false, message: 'Kód skriptu nesmí být prázdný.' };
}
const target = fileFor(id);
// Cas v nazvu, aby si Node nenacetl predchozi pokus z cache.
const temp = path.join(scriptsDir(), `_tmp.${id}.${Date.now()}.js`);
try {
await fs.mkdir(scriptsDir(), { recursive: true });
await fs.writeFile(temp, code, 'utf8');
const mtimeMs = (await fs.stat(temp)).mtimeMs;
const loaded = await importScript(temp, mtimeMs, id);
if (isProblem(loaded)) {
return { ok: false, message: loaded.message, issues: loaded.issues };
}
await fs.rename(temp, target);
// Nova mtime, at si registr vezme skutecny soubor a ne docasny.
const finalMtime = (await fs.stat(target)).mtimeMs;
scripts.set(id, { ...loaded, file: `${id}.js`, mtimeMs: finalMtime });
problems.delete(id);
publishToCatalog();
console.info(`[scripts] ulozen ${id}`);
return { ok: true, manifest: loaded.manifest };
} catch (err) {
return { ok: false, message: `Uložení selhalo: ${problemMessage(err)}` };
} finally {
await fs.rm(temp, { force: true }).catch(() => undefined);
}
}
+229
View File
@@ -0,0 +1,229 @@
/**
* Spusteni jednoho skriptu.
*
* Runner nikdy nevyhodi vyjimku. Vzdy vrati vysledek, ve kterem je bud vystup,
* nebo popsana chyba vcetne toho, jestli ma smysl zkusit to znovu. Az bude
* existovat runtime automatizaci, bude tohle jeho jediny vstupni bod na kroku,
* takze fronta nemusi resit nic z toho, co je tady.
*
* Poradi je vzdy stejne: overit vstup, spustit, overit vystup. Neoverený
* vystup by znamenal, ze strom veri parametru, ktery neexistuje.
*/
import { createHash } from 'node:crypto';
import { config } from '../config.js';
import { connectionSecrets, resolveConnection } from './connections.js';
import { createHttp } from './http.js';
import { connectorIdOf } from './manifest.js';
import { getScript } from './registry.js';
import {
isRetryableKind,
ScriptError,
type ScriptContext,
type ScriptErrorKind,
type ScriptLogEntry,
type ScriptRunResult,
} from './types.js';
import { createRedactor, describe, scriptUtil } from './util.js';
import { validateValues } from './values.js';
export interface RunScriptOptions {
/**
* Stabilni pres vsechny pokusy tehoz kroku. Kdyz chybi, dopocita se
* ze skriptu a vstupu - dva stejne pokusy tak dostanou stejny klic.
*/
idempotencyKey?: string;
timeoutMs?: number;
}
function defaultIdempotencyKey(scriptId: string, inputs: unknown): string {
const hash = createHash('sha256')
.update(scriptId)
.update(JSON.stringify(inputs) ?? '')
.digest('base64url');
return `${scriptId}:${hash.slice(0, 24)}`;
}
function toRunError(
err: unknown,
redact: (value: string) => string,
): { kind: ScriptErrorKind; message: string; status?: number; detail?: string } {
if (err instanceof ScriptError) {
return {
kind: err.kind,
message: redact(err.message),
...(err.status !== undefined ? { status: err.status } : {}),
...(err.detail !== undefined ? { detail: redact(err.detail) } : {}),
};
}
const message = err instanceof Error ? err.message : String(err);
// Neocekavana vyjimka ve skriptu. Opakovat ji nema smysl, kod se sam nespravi.
return { kind: 'internal', message: redact(`Skript selhal: ${message}`) };
}
export async function runScript(
scriptId: string,
rawInputs: unknown,
options: RunScriptOptions = {},
): Promise<ScriptRunResult> {
const startedAt = Date.now();
const logs: ScriptLogEntry[] = [];
let httpCalls = 0;
const finish = (
partial: Pick<ScriptRunResult, 'ok' | 'outputs' | 'error'>,
): ScriptRunResult => ({
scriptId,
logs,
durationMs: Date.now() - startedAt,
httpCalls,
...partial,
});
const script = await getScript(scriptId);
if (!script) {
return finish({
ok: false,
outputs: {},
error: {
kind: 'not_found',
message: `Skript ${scriptId} neexistuje nebo se nepodařilo načíst.`,
retryable: false,
},
});
}
const { manifest } = script;
const connection = resolveConnection(connectorIdOf(scriptId));
const redact = createRedactor(connectionSecrets(connection));
if (!connection.ready) {
return finish({
ok: false,
outputs: {},
error: {
kind: 'config',
message: `Napojení na ${connection.connectorId} není nastavené. Chybí: ${connection.missing.join(', ')}.`,
retryable: false,
},
});
}
const validatedInputs = validateValues(manifest.inputs, rawInputs, {
logLabel: `${scriptId} vstup`,
});
if (!validatedInputs.ok) {
return finish({
ok: false,
outputs: {},
error: {
kind: 'validation',
message: 'Vstupní parametry nejsou v pořádku.',
retryable: false,
issues: validatedInputs.issues,
},
});
}
const idempotencyKey =
options.idempotencyKey ?? defaultIdempotencyKey(scriptId, validatedInputs.values);
const timeoutMs = options.timeoutMs ?? manifest.timeoutMs ?? config.scriptTimeoutMs;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const log = (message: string, detail?: unknown) => {
// Log muze cist klient, proto vzdy pres redakci a vzdy zkraceny.
logs.push({
at: new Date().toISOString(),
message: redact(describe(message, 300)),
...(detail !== undefined ? { detail: redact(describe(detail)) } : {}),
});
};
const ctx: ScriptContext = {
http: createHttp({
connection,
signal: controller.signal,
idempotencyKey,
redact,
log,
onCall: () => {
httpCalls += 1;
},
}),
util: scriptUtil,
log,
config: Object.freeze({ ...connection.config }),
idempotencyKey,
fail(message, detail) {
throw new ScriptError('terminal', message, { detail: describe(detail) });
},
retry(message, detail) {
throw new ScriptError('retryable', message, { detail: describe(detail) });
},
};
let returned: unknown;
try {
returned = await script.run(validatedInputs.values, ctx);
} catch (err) {
const error = toRunError(err, redact);
console.warn(`[scripts] ${scriptId} selhal (${error.kind}): ${error.message}`);
return finish({
ok: false,
outputs: {},
error: { ...error, retryable: isRetryableKind(error.kind) },
});
} finally {
clearTimeout(timer);
}
if (controller.signal.aborted) {
return finish({
ok: false,
outputs: {},
error: {
kind: 'timeout',
message: `Skript nedoběhl v limitu ${timeoutMs} ms.`,
retryable: true,
},
});
}
const validatedOutputs = validateValues(manifest.outputs, returned, {
logLabel: `${scriptId} výstup`,
});
if (!validatedOutputs.ok) {
// Chyba skriptu, ne uzivatele. Strom by jinak veril parametru, ktery nedosel.
console.error(
`[scripts] ${scriptId} nevratil deklarovane vystupy: ${validatedOutputs.issues
.map((issue) => issue.message)
.join(' ')}`,
);
return finish({
ok: false,
outputs: {},
error: {
kind: 'output',
message: 'Skript nevrátil parametry, které má v manifestu.',
retryable: false,
issues: validatedOutputs.issues,
},
});
}
return finish({ ok: true, outputs: validatedOutputs.values, error: null });
}
/** Vysledek jako jeden radek do logu ticketu. Az bude runtime, pouzije tohle. */
export function summarizeRun(result: ScriptRunResult): string {
if (result.ok) {
const pairs = Object.entries(result.outputs)
.map(([key, value]) => `${key}=${value === null ? '-' : String(value)}`)
.join(', ');
return `${result.scriptId} ok za ${result.durationMs} ms${pairs ? ` (${pairs})` : ''}`;
}
return `${result.scriptId} selhal: ${result.error?.message ?? 'neznámá chyba'}`;
}
+271
View File
@@ -0,0 +1,271 @@
/**
* Co je skript konektoru.
*
* Skript je jeden soubor, ktery nese dve veci: **manifest** (jak se jmenuje,
* co potrebuje na vstupu, co vraci na vystupu) a **kod**, ktery to udela.
* Diky manifestu s nim umi pracovat strom automatizace, aniz by o jeho kodu
* cokoliv vedel.
*
* Zdroj pravdy o tvaru manifestu je zod schema tady v tomhle souboru. Typy
* se z nej odvozuji, aby nebyl na dvou mistech a jednou se nerozesel.
*
* Souvisejici navrh: documentation/09-navrh-rozsireni.md, bod 9.
*/
import { z } from 'zod';
// ------------------------------------------------------------------- hodnoty
/** Skript pracuje jen s temito hodnotami. Zadne objekty ani pole. */
export type ScriptValue = string | number | boolean | null;
export type ScriptValues = Record<string, ScriptValue>;
// -------------------------------------------------------------------- schema
const fieldTypeSchema = z.enum(['string', 'number', 'boolean', 'date']);
/**
* Jeden parametr, vstupni nebo vystupni. Zamerne je to jeden typ pro obe
* strany - validace je pak taky jedna funkce, ne dve skoro stejne.
*
* `id` se pouziva v sablonach jako `{{id}}`, proto smi obsahovat jen to,
* co jde napsat bez preklepu.
*/
export const scriptFieldSchema = z
.object({
id: z
.string()
.regex(
/^[A-Za-z][A-Za-z0-9_]*$/,
'ID parametru musí začínat písmenem a obsahovat jen písmena, číslice a podtržítko.',
),
label: z.string().min(1, 'Popis parametru nesmí být prázdný.'),
type: fieldTypeSchema,
required: z.boolean(),
/** Napoveda pod polem v builderu. */
hint: z.string().optional(),
/** Vyber z hodnot. Jina hodnota neprojde validaci. */
options: z
.array(z.object({ value: z.string(), label: z.string() }))
.min(1)
.optional(),
/** Jen u typu string: dalsi kontrola regularnim vyrazem. */
pattern: z.string().optional(),
/** Jen u typu string: pole na vic radku. Builder ho vykresli jako longtext. */
multiline: z.boolean().optional(),
/** Dosadi se, kdyz hodnota chybi a parametr neni povinny. */
default: z.union([z.string(), z.number(), z.boolean(), z.null()]).optional(),
})
.strict();
export type ScriptField = z.infer<typeof scriptFieldSchema>;
/**
* Manifest skriptu.
*
* `id` ma tvar `<konektor>.<operace>`, napriklad `idoklad.get-issued-invoice`.
* Z nej se dopocita, do ktereho konektoru operace patri, takze se to nepise
* dvakrat. Nazev souboru musi byt `<id>.js`.
*
* `.strict()` je zamer: preklep v nazvu klice (`outputFileds`) se ma ohlasit,
* ne tise ignorovat.
*/
export const scriptManifestSchema = z
.object({
id: z
.string()
.regex(
/^[a-z][a-z0-9-]*\.[a-z][a-z0-9-]*$/,
'ID skriptu musí mít tvar konektor.operace, například idoklad.get-issued-invoice.',
),
name: z.string().min(1, 'Název skriptu nesmí být prázdný.'),
description: z.string().min(1, 'Popis skriptu nesmí být prázdný.'),
inputs: z.array(scriptFieldSchema).default([]),
outputs: z.array(scriptFieldSchema).default([]),
/** Strop na jeden beh. Kdyz chybi, pouzije se hodnota z konfigurace. */
timeoutMs: z.number().int().min(1000).max(120_000).optional(),
})
.strict()
.superRefine((manifest, ctx) => {
for (const key of ['inputs', 'outputs'] as const) {
const seen = new Set<string>();
for (const field of manifest[key]) {
if (seen.has(field.id)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [key],
message: `Parametr ${field.id} je uveden dvakrát.`,
});
}
seen.add(field.id);
}
}
});
export type ScriptManifest = z.infer<typeof scriptManifestSchema>;
// --------------------------------------------------------------------- chyby
/**
* Druh selhani. Rozdeleni na `retryable` a `terminal` je to podstatne:
* timeout nebo 503 ma smysl zkusit znovu, chyba ve vstupu nebo 403 ne.
* Opakovat koncovou chybu jen vypali kvotu u cizi sluzby.
*/
export type ScriptErrorKind =
| 'not_found'
| 'config'
| 'validation'
| 'output'
| 'retryable'
| 'terminal'
| 'timeout'
| 'internal';
const retryableKinds: ScriptErrorKind[] = ['retryable', 'timeout'];
export function isRetryableKind(kind: ScriptErrorKind): boolean {
return retryableKinds.includes(kind);
}
export class ScriptError extends Error {
readonly kind: ScriptErrorKind;
/** HTTP kod cizi sluzby, kdyz chyba prisla z volani. */
readonly status?: number;
/** Kratky detail k zobrazeni. Uz zredigovany, bez tajemstvi. */
readonly detail?: string;
constructor(
kind: ScriptErrorKind,
message: string,
options: { status?: number; detail?: string; cause?: unknown } = {},
) {
super(message, options.cause !== undefined ? { cause: options.cause } : undefined);
this.name = 'ScriptError';
this.kind = kind;
this.status = options.status;
this.detail = options.detail;
}
}
// ------------------------------------------------------------------- kontext
export interface ScriptHttpResponse<T = unknown> {
status: number;
body: T;
}
export interface ScriptHttpOptions {
query?: Record<string, string | number | boolean | undefined | null>;
headers?: Record<string, string>;
/** Kody, ktere se nemaji brat jako chyba. Vychozi je 2xx. */
allowStatus?: number[];
}
/**
* HTTP klient predany skriptu. Adresu a autorizaci doplnuje runtime podle
* napojeni, takze **skript se k pristupovym udajum nedostane**.
*/
export interface ScriptHttp {
get<T = unknown>(path: string, options?: ScriptHttpOptions): Promise<ScriptHttpResponse<T>>;
post<T = unknown>(
path: string,
body?: unknown,
options?: ScriptHttpOptions,
): Promise<ScriptHttpResponse<T>>;
patch<T = unknown>(
path: string,
body?: unknown,
options?: ScriptHttpOptions,
): Promise<ScriptHttpResponse<T>>;
put<T = unknown>(
path: string,
body?: unknown,
options?: ScriptHttpOptions,
): Promise<ScriptHttpResponse<T>>;
del<T = unknown>(path: string, options?: ScriptHttpOptions): Promise<ScriptHttpResponse<T>>;
}
/**
* Pomocne funkce. Jsou na kontextu, ne v importu, ze dvou duvodu: skript
* nemusi resit relativni cesty a stejny podpis bude fungovat i pozdeji
* v sandboxu, kde zadny import neni.
*/
export interface ScriptUtil {
/** Rozbali obalku odpovedi, tedy `{ Data: ... }` i `{ data: ... }`. */
unwrap<T = unknown>(body: unknown): T;
/** Prvni existujici pole bez ohledu na velka a mala pismena. */
pick(source: unknown, ...names: string[]): unknown;
/** Prvni prvek pole, nebo null. */
first<T = unknown>(value: unknown): T | null;
text(value: unknown, fallback?: string | null): string | null;
num(value: unknown, fallback?: number | null): number | null;
bool(value: unknown): boolean;
/** Datum jako ISO retezec, nebo null. */
date(value: unknown): string | null;
/** Zaokrouhli na dane desetinne misto. Uctuje se v halerich. */
round(value: number, decimals?: number): number;
/**
* Vrati hodnotu, nebo skonci chybou s citelnou zpravou.
* Pro povinne vystupy: kdyz je cizi odpoved jina, nez skript ceka, ma se to
* poznat hned a s nazvem pole, ne az na chybejicim parametru ve strome.
*/
need<T>(value: T | null | undefined, label: string): T;
}
export interface ScriptContext {
http: ScriptHttp;
util: ScriptUtil;
/** Zapise radek do logu behu. Nikdy sem nedavat pristupove udaje. */
log(message: string, detail?: unknown): void;
/** Necitliva cast nastaveni napojeni. */
config: Readonly<Record<string, string>>;
/**
* Stabilni pres vsechny pokusy tehoz kroku. Predava se cizim sluzbam jako
* `Idempotency-Key`, aby druhy pokus nevystavil druhou fakturu.
*/
idempotencyKey: string;
/** Koncova chyba, neopakuje se. Typicky nesmyslny vstup nebo 404 od sluzby. */
fail(message: string, detail?: unknown): never;
/** Opakovatelna chyba. Typicky vypadek nebo docasna nedostupnost. */
retry(message: string, detail?: unknown): never;
}
/** Co soubor skriptu exportuje. */
export interface ScriptModule {
manifest: unknown;
run: (inputs: ScriptValues, ctx: ScriptContext) => Promise<unknown> | unknown;
}
// -------------------------------------------------------------------- vysledek
export interface ScriptLogEntry {
at: string;
message: string;
detail?: string;
}
export interface ScriptRunError {
kind: ScriptErrorKind;
message: string;
retryable: boolean;
status?: number;
detail?: string;
/** Vyplnene jen u chyb ve vstupu nebo vystupu. */
issues?: FieldIssue[];
}
export interface FieldIssue {
field: string;
message: string;
}
export interface ScriptRunResult {
ok: boolean;
scriptId: string;
/** Prazdne, kdyz beh selhal. */
outputs: ScriptValues;
logs: ScriptLogEntry[];
durationMs: number;
httpCalls: number;
error: ScriptRunError | null;
}
+127
View File
@@ -0,0 +1,127 @@
/**
* Pomocne funkce predane skriptu jako `ctx.util`, plus redakce tajemstvi.
*
* Duvod, proc to neni v kazdem skriptu znovu: cizi API vraci pokazde jinak.
* iDoklad pouziva velka pocatecni pismena a nekde obaluje odpoved do `Data`,
* jine sluzby ne. Bez `unwrap` a `pick` by kazdy skript resil totez a jeden
* z nich by to resil spatne.
*/
import { ScriptError, type ScriptUtil } from './types.js';
/** Rozbali obalku odpovedi. `{ Data: x }` i `{ data: x }` vrati `x`. */
function unwrap<T = unknown>(body: unknown): T {
if (body === null || typeof body !== 'object') return body as T;
const record = body as Record<string, unknown>;
if ('Data' in record) return record.Data as T;
if ('data' in record) return record.data as T;
return body as T;
}
/**
* Prvni existujici pole bez ohledu na velikost pismen.
* `pick(invoice, 'documentNumber')` najde `DocumentNumber` i `documentNumber`.
*/
function pick(source: unknown, ...names: string[]): unknown {
if (source === null || typeof source !== 'object') return undefined;
const record = source as Record<string, unknown>;
for (const name of names) {
if (record[name] !== undefined) return record[name];
}
const lowered = new Map<string, unknown>();
for (const [key, value] of Object.entries(record)) lowered.set(key.toLowerCase(), value);
for (const name of names) {
const value = lowered.get(name.toLowerCase());
if (value !== undefined) return value;
}
return undefined;
}
function first<T = unknown>(value: unknown): T | null {
if (Array.isArray(value)) return (value[0] as T) ?? null;
const unwrapped = unwrap(value);
if (Array.isArray(unwrapped)) return (unwrapped[0] as T) ?? null;
return null;
}
function text(value: unknown, fallback: string | null = null): string | null {
if (value === undefined || value === null) return fallback;
if (typeof value === 'object') return fallback;
const result = String(value).trim();
return result === '' ? fallback : result;
}
function num(value: unknown, fallback: number | null = null): number | null {
if (value === undefined || value === null || value === '') return fallback;
const parsed = typeof value === 'number' ? value : Number(String(value).replace(',', '.'));
return Number.isFinite(parsed) ? parsed : fallback;
}
function bool(value: unknown): boolean {
if (typeof value === 'boolean') return value;
const lowered = String(value ?? '').trim().toLowerCase();
return lowered === 'true' || lowered === '1' || lowered === 'yes' || lowered === 'ano';
}
function date(value: unknown): string | null {
if (value === undefined || value === null || value === '') return null;
const parsed = Date.parse(value instanceof Date ? value.toISOString() : String(value));
return Number.isNaN(parsed) ? null : new Date(parsed).toISOString();
}
function round(value: number, decimals = 2): number {
const factor = 10 ** decimals;
return Math.round(value * factor) / factor;
}
function need<T>(value: T | null | undefined, label: string): T {
if (value === undefined || value === null || value === '') {
throw new ScriptError('output', `Odpověď služby neobsahuje ${label}.`);
}
return value;
}
export const scriptUtil: ScriptUtil = { unwrap, pick, first, text, num, bool, date, round, need };
// ------------------------------------------------------------------- redakce
/**
* Nahradi tajne hodnoty hvezdickami.
*
* Neni to kosmetika. Log ticketu ukazuje, co sluzba vratila, a cizi API rado
* vraci prijaty token v chybove zprave. Bez redakce by tajemstvi skoncilo
* v logu, ktery se navic zobrazuje klientovi.
*/
export function createRedactor(secrets: Array<string | undefined>): (value: string) => string {
// Kratke hodnoty se neredigují - nahradit "1" hvezdickami by rozbilo cely text.
const values = secrets
.filter((value): value is string => typeof value === 'string' && value.length >= 6)
.sort((a, b) => b.length - a.length);
if (values.length === 0) return (value) => value;
return (value: string) => {
let result = value;
for (const secret of values) result = result.split(secret).join('***');
return result;
};
}
/** Zkrati text na danou delku, aby jeden log nezabral megabajt. */
export function truncate(value: string, max = 600): string {
if (value.length <= max) return value;
return `${value.slice(0, max)} (zkráceno, celkem ${value.length} znaků)`;
}
/** Bezpecne prevede cokoliv na kratky text do logu. */
export function describe(value: unknown, max = 600): string {
if (value === undefined) return '';
if (typeof value === 'string') return truncate(value, max);
try {
return truncate(JSON.stringify(value) ?? String(value), max);
} catch {
return truncate(String(value), max);
}
}
+138
View File
@@ -0,0 +1,138 @@
/**
* Kontrola parametru skriptu.
*
* Jedna funkce pro vstup i vystup. Kdyby to byly dve, jedna by se casem
* opravila a druha ne, a strom by pak veril vystupu, ktery nikdo neoveril.
*
* Pravidla:
* - povinny parametr bez hodnoty je chyba, ne prazdny retezec,
* - nepovinny parametr bez hodnoty dostane `default`, jinak `null`,
* - hodnota se prevede na deklarovany typ, kdyz to jde bez hadani,
* - parametr, ktery v manifestu neni, se zahodi a zaloguje.
*/
import type { FieldIssue, ScriptField, ScriptValue, ScriptValues } from './types.js';
export type ValidationResult =
| { ok: true; values: ScriptValues }
| { ok: false; issues: FieldIssue[] };
/** Retezce, ktere lidi i sluzby pouzivaji pro ano a ne. */
const truthy = new Set(['true', '1', 'yes', 'y', 'ano', 'on']);
const falsy = new Set(['false', '0', 'no', 'n', 'ne', 'off']);
function isMissing(value: unknown): boolean {
return value === undefined || value === null || (typeof value === 'string' && value.trim() === '');
}
/**
* Prevede jednu hodnotu na deklarovany typ.
* Vraci bud hodnotu, nebo text chyby - nikdy nehada.
*/
function coerce(field: ScriptField, raw: unknown): { value: ScriptValue } | { error: string } {
switch (field.type) {
case 'string': {
if (typeof raw === 'object') return { error: 'Očekává se text, přišel objekt.' };
const text = field.multiline ? String(raw) : String(raw).trim();
if (field.pattern) {
let regex: RegExp;
try {
regex = new RegExp(field.pattern);
} catch {
return { error: `Manifest má neplatný pattern: ${field.pattern}` };
}
if (!regex.test(text)) return { error: `Hodnota neodpovídá tvaru ${field.pattern}.` };
}
return { value: text };
}
case 'number': {
if (typeof raw === 'boolean') return { error: 'Očekává se číslo, přišlo ano/ne.' };
// Ceska desetinna carka je bezna, nema smysl na ni padat.
const text = typeof raw === 'string' ? raw.trim().replace(',', '.') : raw;
const num = typeof text === 'number' ? text : Number(text);
if (!Number.isFinite(num)) return { error: `"${String(raw)}" není číslo.` };
return { value: num };
}
case 'boolean': {
if (typeof raw === 'boolean') return { value: raw };
const text = String(raw).trim().toLowerCase();
if (truthy.has(text)) return { value: true };
if (falsy.has(text)) return { value: false };
return { error: `"${String(raw)}" není ano ani ne.` };
}
case 'date': {
const text = raw instanceof Date ? raw.toISOString() : String(raw).trim();
const parsed = Date.parse(text);
if (Number.isNaN(parsed)) return { error: `"${text}" není platné datum.` };
return { value: new Date(parsed).toISOString() };
}
default: {
// Vetev je nedosazitelna, dokud FieldType nema dalsi hodnotu.
return { error: 'Neznámý typ parametru.' };
}
}
}
export interface ValidateOptions {
/** Kam se zapisuje varovani o parametrech navic. */
logLabel: string;
}
/**
* Overi a prevede sadu hodnot proti deklaraci parametru.
* Vraci vsechny chyby najednou, ne jen prvni - uzivatel ma opravit vse.
*/
export function validateValues(
fields: ScriptField[],
raw: unknown,
options: ValidateOptions,
): ValidationResult {
const source: Record<string, unknown> =
raw !== null && typeof raw === 'object' ? (raw as Record<string, unknown>) : {};
const issues: FieldIssue[] = [];
const values: ScriptValues = {};
for (const field of fields) {
const incoming = source[field.id];
if (isMissing(incoming)) {
if (field.required) {
issues.push({ field: field.id, message: `${field.label} je povinné.` });
continue;
}
values[field.id] = field.default ?? null;
continue;
}
const result = coerce(field, incoming);
if ('error' in result) {
issues.push({ field: field.id, message: `${field.label}: ${result.error}` });
continue;
}
if (field.options && !field.options.some((option) => option.value === String(result.value))) {
const allowed = field.options.map((option) => option.value).join(', ');
issues.push({
field: field.id,
message: `${field.label}: povolené hodnoty jsou ${allowed}.`,
});
continue;
}
values[field.id] = result.value;
}
// Parametry navic neodmitame, jen o nich chceme vedet. Stejne jako u webhooku.
const declared = new Set(fields.map((field) => field.id));
const extra = Object.keys(source).filter((key) => !declared.has(key));
if (extra.length > 0) {
console.warn(`[scripts] ${options.logLabel}: parametry mimo manifest: ${extra.join(', ')}`);
}
return issues.length > 0 ? { ok: false, issues } : { ok: true, values };
}