Vlastni skripty firmy: prevod dat v JS, v logu vstup i vystup

Klikaci pravidla jsou u peti poli rychlejsi, ale u modelu objednavky je jich
dvacet a v tom se necte. Vedle nich proto skript firmy: prevod z A do B
napsany v JS, jeden na zakaznika.

- Skripty se ukladaji do uloziste, ne na disk. Disk je uvnitr kontejneru
  a redeploy ho vymaze.
- Krok Transformace dat - Vlastni skript. Vysledek jde dal jako krok.result.
- V logu ticketu je u kroku vstup i vystup. Prave to byl duvod, proc skript
  nad pravidly vyhral.
- Zkouska bez ulozeni: v portalu se vlepi skutecne telo a hned je videt, co
  z toho leze.
- Skripty jsou v zalozce Akce, vedle definic akci. Obojí je popis toho, co
  aplikace ve firme umi, a spravuje to tentyz clovek.

Skript je ciste prevod hodnot: dostane input, vrati objekt. Nema require,
import, process, fetch ani console, bezi nejvys 2 s a vysledek se vejde do
256 kB. node:vm neni bezpecnostni hranice proti nekomu, kdo se chce dostat
ven - je to izolace proti nehode a proti zacykleni.

Pri zkousce se ukazalo, ze casovy limit nepokryval samotny beh: runInContext
jen vyrobil funkci a zavolat ji zvenku znamenalo, ze while (true) uvnitr
zablokovalo proces navzdy. Kod se ted vola uvnitr runInContext.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
JiriUhlir
2026-08-20 08:08:09 +02:00
co-authored by Claude Opus 5
parent 99561c4682
commit e4779caf82
12 changed files with 1063 additions and 6 deletions
+8
View File
@@ -17,6 +17,7 @@ import {
type FlowStep,
} from '../data/automationStore.js';
import { operatorAllowedForType, operatorsByType } from '../data/conditions.js';
import { listTenantScripts } from '../data/tenantScripts.js';
import {
defaultConnectorFor,
getConnector,
@@ -70,6 +71,7 @@ import { settingsRouter } from './settings.js';
import { ticketActionsRouter } from './ticketActions.js';
import { widgetDataRouter } from './widgetData.js';
import { scriptsRouter } from './scripts.js';
import { tenantScriptRouter } from './tenantScripts.js';
import { streamRouter } from './stream.js';
export const dashboardRouter = Router();
@@ -729,6 +731,8 @@ dashboardRouter.use('/stream', streamRouter);
// Skripty konektoru. Taky pred obecnymi cestami.
dashboardRouter.use('/scripts', scriptsRouter);
// Skripty firmy. Prevod dat v JS, na rozdil od skriptu sluzeb nevolaji ven.
dashboardRouter.use('/tenant-scripts', tenantScriptRouter);
// Sluzby a konektory. `/connectors/services` je uvnitr toho routeru.
dashboardRouter.use('/connectors', connectorsRouter);
@@ -771,6 +775,10 @@ dashboardRouter.get('/services', (req, res) => {
{
people: listPeople(tenantIds).map((person) => ({ id: person.id, name: person.name })),
groups: listGroups(tenantIds).map((group) => ({ id: group.id, name: group.name })),
scripts: listTenantScripts(tenantIds).map((script) => ({
id: script.id,
name: script.name,
})),
},
),
// Frontend potrebuje vedet, jake operatory nabidnout ke kteremu typu,
+210
View File
@@ -0,0 +1,210 @@
/**
* Skripty firmy: prevod dat z A do B napsany v JS.
*
* Zamerne to **neni** `routes/scripts.ts`. Ten spravuje skripty sluzeb, ktere
* volaji cizi API, patri celé platforme a smi je menit jen jeji spravce.
* Tohle je prevod hodnot uvnitr jedne firmy: nic nevola ven, vidi jen data,
* ktera mu krok preda, a spravuje si ho firma sama.
*
* Pravo je `action.manage`, tedy totez co u definic akci. Obojí je popis toho,
* co aplikace ve firme umi - ne bezna prace s tickety.
*/
import { Router } from 'express';
import { z } from 'zod';
import { recordAudit } from '../data/audit.js';
import {
findTenantScript,
listTenantScripts,
newTenantScript,
refreshTenantScripts,
tenantScriptStore,
MAX_CODE_LENGTH,
SCRIPT_TEMPLATE,
} from '../data/tenantScripts.js';
import { hasPermission } from '../data/permissions.js';
import { requireAuth } from '../middleware/auth.js';
import { runSandbox } from '../runtime/sandbox.js';
import { currentTenant } from './crud.js';
export const tenantScriptRouter = Router();
tenantScriptRouter.use(requireAuth);
/** Kdo smi menit. Cteni staci prihlaseni - builder potrebuje nabidku skriptu. */
const PERMISSION = 'action.manage';
/** Vrati false a odpovi, kdyz volajici na zmenu nema pravo. */
function mayWrite(req: Parameters<typeof requireAuth>[0], res: Parameters<typeof requireAuth>[1]): boolean {
if (hasPermission(req.user!, PERMISSION)) return true;
console.warn(`[skripty] ${req.user!.email}: chybi pravo ${PERMISSION}`);
res.status(403).json({ error: 'forbidden', message: 'K této změně nemáte oprávnění.' });
return false;
}
const bodySchema = z.object({
name: z.string().trim().min(2, 'Skript musí mít název.').max(80),
description: z.string().trim().max(300).optional(),
code: z
.string()
.min(1, 'Skript nemůže být prázdný.')
.max(MAX_CODE_LENGTH, `Skript se vejde do ${MAX_CODE_LENGTH} znaků.`),
enabled: z.boolean().optional(),
});
tenantScriptRouter.get('/', (req, res) => {
const tenantId = currentTenant(req, res);
if (!tenantId) return;
return res.json({
items: listTenantScripts([tenantId]),
/** Vzor pro novy skript. Radeji neco, co rovnou bezi, nez prazdne okno. */
template: SCRIPT_TEMPLATE,
maxLength: MAX_CODE_LENGTH,
});
});
tenantScriptRouter.post('/', (req, res) => {
const tenantId = currentTenant(req, res);
if (!tenantId) return;
if (!mayWrite(req, res)) return;
const parsed = bodySchema.safeParse(req.body ?? {});
if (!parsed.success) {
return res.status(400).json({
error: 'validation_error',
message: parsed.error.issues[0]?.message ?? 'Neplatné údaje.',
});
}
const script = newTenantScript({ tenantId, ...parsed.data });
return void tenantScriptStore
.create(script)
.then(async () => {
await refreshTenantScripts();
recordAudit({
userId: req.user!.id,
userEmail: req.user!.email,
tenantId,
action: 'script.create',
target: script.id,
detail: { nazev: script.name },
});
return res.status(201).json(script);
})
.catch((err: unknown) => {
console.error('[skripty] zalozeni selhalo:', err);
return res
.status(500)
.json({ error: 'internal_error', message: 'Skript se nepodařilo uložit.' });
});
});
tenantScriptRouter.put('/:id', (req, res) => {
const tenantId = currentTenant(req, res);
if (!tenantId) return;
if (!mayWrite(req, res)) return;
if (!findTenantScript(req.params.id, [tenantId])) {
return res.status(404).json({ error: 'not_found', message: 'Skript neexistuje.' });
}
const parsed = bodySchema.safeParse(req.body ?? {});
if (!parsed.success) {
return res.status(400).json({
error: 'validation_error',
message: parsed.error.issues[0]?.message ?? 'Neplatné údaje.',
});
}
return void tenantScriptStore
.update(req.params.id, parsed.data, { tenantIds: [tenantId], includeGlobal: false })
.then(async (updated) => {
if (!updated) {
return res.status(404).json({ error: 'not_found', message: 'Skript neexistuje.' });
}
await refreshTenantScripts();
recordAudit({
userId: req.user!.id,
userEmail: req.user!.email,
tenantId,
action: 'script.update',
target: req.params.id,
detail: { nazev: parsed.data.name },
});
return res.json(updated);
})
.catch((err: unknown) => {
console.error('[skripty] uprava selhala:', err);
return res
.status(500)
.json({ error: 'internal_error', message: 'Skript se nepodařilo uložit.' });
});
});
tenantScriptRouter.delete('/:id', (req, res) => {
const tenantId = currentTenant(req, res);
if (!tenantId) return;
if (!mayWrite(req, res)) return;
return void tenantScriptStore
.remove(req.params.id, { tenantIds: [tenantId], includeGlobal: false })
.then(async (removed) => {
if (!removed) {
return res.status(404).json({ error: 'not_found', message: 'Skript neexistuje.' });
}
await refreshTenantScripts();
recordAudit({
userId: req.user!.id,
userEmail: req.user!.email,
tenantId,
action: 'script.delete',
target: req.params.id,
});
return res.status(204).end();
})
.catch((err: unknown) => {
console.error('[skripty] smazani selhalo:', err);
return res
.status(500)
.json({ error: 'internal_error', message: 'Skript se nepodařilo smazat.' });
});
});
const testSchema = z.object({
code: z.string().min(1).max(MAX_CODE_LENGTH),
/** Vstup, na kterem se to zkusi. Typicky ukazka tela od spoustece. */
input: z.unknown(),
});
/**
* Zkuseni skriptu bez ulozeni.
*
* Tohle je duvod, proc se skript pise tady a ne do souboru: napisu prevod,
* vlepim skutecne telo a hned vidim, co z toho leze. Bez toho se chyba pozna
* az z padleho behu o hodinu pozdeji.
*/
tenantScriptRouter.post('/test', (req, res) => {
const tenantId = currentTenant(req, res);
if (!tenantId) return;
if (!mayWrite(req, res)) return;
const parsed = testSchema.safeParse(req.body ?? {});
if (!parsed.success) {
return res.status(400).json({
error: 'validation_error',
message: parsed.error.issues[0]?.message ?? 'Neplatné údaje.',
});
}
const result = runSandbox(parsed.data.code, parsed.data.input ?? {});
// 200 i kdyz skript spadl. Chyba ve skriptu neni chyba API.
return res.json({
ok: result.ok,
output: result.value,
error: result.error,
durationMs: result.durationMs,
});
});