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:
co-authored by
Claude Opus 5
parent
99561c4682
commit
e4779caf82
@@ -14,6 +14,7 @@ import { refreshCustomWidgets, customWidgetStore, seedCustomWidgets } from './cu
|
||||
import { auditStore } from './audit.js';
|
||||
import { notificationStore, refreshNotifications } from './notifications.js';
|
||||
import { inviteStore, refreshInvites } from './invites.js';
|
||||
import { tenantScriptStore, refreshTenantScripts } from './tenantScripts.js';
|
||||
import { onTicket } from './ticketHooks.js';
|
||||
import { onTicketEvent } from '../runtime/triggers.js';
|
||||
import { initQueue } from '../runtime/queue.js';
|
||||
@@ -63,6 +64,7 @@ const entities: Array<{
|
||||
{ store: auditStore as EntityStore<TenantEntity> },
|
||||
{ store: notificationStore as EntityStore<TenantEntity> },
|
||||
{ store: inviteStore as EntityStore<TenantEntity> },
|
||||
{ store: tenantScriptStore as EntityStore<TenantEntity> },
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -96,6 +98,7 @@ export async function refreshCaches(): Promise<void> {
|
||||
refreshCustomWidgets(),
|
||||
refreshNotifications(),
|
||||
refreshInvites(),
|
||||
refreshTenantScripts(),
|
||||
]);
|
||||
|
||||
// Prava k akcim vznikaji z definic akci, takze se registruji az po jejich nacteni.
|
||||
|
||||
+48
-4
@@ -123,7 +123,7 @@ export interface OperationField {
|
||||
* Odkud se doplni nabidka za behu. Katalog vznika pri importu modulu,
|
||||
* ale resitele a skupiny se nacitaji z uloziste az pozdeji.
|
||||
*/
|
||||
optionsFrom?: 'people' | 'groups';
|
||||
optionsFrom?: 'people' | 'groups' | 'scripts';
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
@@ -1739,6 +1739,34 @@ export const services: Service[] = [
|
||||
description: 'Vyřadí záznamy, které už systémem prošly.',
|
||||
fields: ['Klíč pro srovnání'],
|
||||
},
|
||||
{
|
||||
id: 'custom',
|
||||
name: 'Vlastní skript',
|
||||
description:
|
||||
'Převod dat napsaný v JavaScriptu. Hodí se, když je pravidel tolik, ' +
|
||||
'že je kód čitelnější než jejich seznam.',
|
||||
fields: ['Skript', 'Zdrojová data'],
|
||||
inputs: [
|
||||
{
|
||||
id: 'scriptId',
|
||||
label: 'Skript',
|
||||
kind: 'choice',
|
||||
required: true,
|
||||
optionsFrom: 'scripts',
|
||||
hint: 'Skripty firmy se píšou v záložce Skripty.',
|
||||
},
|
||||
{
|
||||
id: 'source',
|
||||
label: 'Zdrojová data',
|
||||
kind: 'object',
|
||||
required: true,
|
||||
hint: 'Objekt, který skript dostane jako input. Třeba {{_body}}.',
|
||||
},
|
||||
],
|
||||
outputFields: [
|
||||
{ id: 'transform.result', name: 'result', type: 'object', required: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -1890,15 +1918,31 @@ export function actionsFor(serviceId: string): ServiceOperation[] {
|
||||
*/
|
||||
export function withRuntimeOptions(
|
||||
items: Service[],
|
||||
options: { people: Array<{ id: string; name: string }>; groups: Array<{ id: string; name: string }> },
|
||||
options: {
|
||||
people: Array<{ id: string; name: string }>;
|
||||
groups: Array<{ id: string; name: string }>;
|
||||
scripts?: Array<{ id: string; name: string }>;
|
||||
},
|
||||
): Service[] {
|
||||
/** Prazdna volba nahore. U kazdeho zdroje znamena neco jineho. */
|
||||
const empty: Record<NonNullable<OperationField['optionsFrom']>, string> = {
|
||||
people: 'Nechat ve frontě',
|
||||
groups: 'Bez skupiny',
|
||||
scripts: '- vyberte skript -',
|
||||
};
|
||||
|
||||
const fill = (field: OperationField): OperationField => {
|
||||
if (!field.optionsFrom) return field;
|
||||
const source = field.optionsFrom === 'people' ? options.people : options.groups;
|
||||
const source =
|
||||
field.optionsFrom === 'people'
|
||||
? options.people
|
||||
: field.optionsFrom === 'groups'
|
||||
? options.groups
|
||||
: (options.scripts ?? []);
|
||||
return {
|
||||
...field,
|
||||
options: [
|
||||
{ value: '', label: field.optionsFrom === 'people' ? 'Nechat ve frontě' : 'Bez skupiny' },
|
||||
{ value: '', label: empty[field.optionsFrom] },
|
||||
...source.map((item) => ({ value: item.id, label: item.name })),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Vlastni skripty firmy: prevod dat z A do B napsany v JS.
|
||||
*
|
||||
* Proc vedle klikacich pravidel:
|
||||
* - u slozitejsiho prevodu je **kod citelnejsi nez dvacet pravidel**. Kdo umi
|
||||
* JS, napise podminku a smycku rychleji, nez je naklika, a hlavne to pak
|
||||
* prectе i za pul roku,
|
||||
* - kazdy zakaznik ma jiny tvar dat. Jeden skript na firmu je presne ta
|
||||
* hranice, na ktere se to da udrzovat,
|
||||
* - **v logu je videt vstup i vystup**, takze kdyz neco nesedi, neni potreba
|
||||
* hadat, co do prevodu vlezlo.
|
||||
*
|
||||
* Klikaci pravidla tim neztraceji smysl: u peti poli je vyber ze seznamu
|
||||
* rychlejsi a nemuze se v nem udelat preklep v zavorce.
|
||||
*
|
||||
* Skript **nesmi nic volat ven**. Je to ciste prevod hodnot: dostane objekt,
|
||||
* vrati objekt. Volani cizich sluzeb dela krok konektoru, ktery ma pristupove
|
||||
* udaje, opakovani i zapis do logu - viz `runtime/sandbox.ts`.
|
||||
*/
|
||||
|
||||
import { defineStore, nowIso, type TenantEntity } from './store/index.js';
|
||||
import { withCache } from './store/cached.js';
|
||||
|
||||
export interface TenantScript extends TenantEntity {
|
||||
tenantId: string;
|
||||
/** Jmeno, ktere se ukazuje v nabidce kroku. */
|
||||
name: string;
|
||||
/** K cemu to je. Ctou to lide, kteri skript nepsali. */
|
||||
description: string;
|
||||
/**
|
||||
* Telo funkce. Dostane `input` a `util`, vraci objekt.
|
||||
*
|
||||
* Uklada se jako text, ne jako zkompilovana funkce - kod musi jit precist
|
||||
* a zmenit z portalu, jinak by to nebylo o nic lepsi nez soubor na disku.
|
||||
*/
|
||||
code: string;
|
||||
/** false = krok se skriptem selze s tim, ze je vypnuty. */
|
||||
enabled: boolean;
|
||||
/** Kdy naposledy probehl a jak dopadl. Napovi, jestli se vubec pouziva. */
|
||||
lastRunAt: string | null;
|
||||
lastRunOk: boolean | null;
|
||||
}
|
||||
|
||||
export const tenantScriptStore = defineStore<TenantScript>('tenantScript');
|
||||
const cache = withCache(tenantScriptStore);
|
||||
|
||||
export async function refreshTenantScripts(): Promise<void> {
|
||||
await cache.refresh();
|
||||
}
|
||||
|
||||
/** Kolik znaku smi skript mit. Delsi uz neni prevod, ale aplikace. */
|
||||
export const MAX_CODE_LENGTH = 20_000;
|
||||
|
||||
export function listTenantScripts(tenantIds: string[]): TenantScript[] {
|
||||
return cache
|
||||
.all()
|
||||
.filter((script) => tenantIds.includes(script.tenantId))
|
||||
.sort((a, b) => a.name.localeCompare(b.name, 'cs'));
|
||||
}
|
||||
|
||||
/** Skript firmy. Cizi skript se chova jako neexistujici, ne jako zakazany. */
|
||||
export function findTenantScript(id: string, tenantIds: string[]): TenantScript | undefined {
|
||||
const script = cache.all().find((item) => item.id === id);
|
||||
if (!script || !tenantIds.includes(script.tenantId)) return undefined;
|
||||
return script;
|
||||
}
|
||||
|
||||
export function newTenantScript(input: {
|
||||
tenantId: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
code: string;
|
||||
}): TenantScript {
|
||||
const timestamp = nowIso();
|
||||
return {
|
||||
id: `scr_${Math.random().toString(36).slice(2, 10)}`,
|
||||
tenantId: input.tenantId,
|
||||
name: input.name,
|
||||
description: input.description ?? '',
|
||||
code: input.code,
|
||||
enabled: true,
|
||||
lastRunAt: null,
|
||||
lastRunOk: null,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Poznamena, jak dopadl posledni beh.
|
||||
*
|
||||
* Zapisuje se **bez cekani**: kdyby zapis selhal, nesmi to shodit beh, ktery
|
||||
* uz probehl. Je to informace do prehledu, ne soucast vysledku.
|
||||
*/
|
||||
export function noteRun(id: string, tenantId: string, ok: boolean): void {
|
||||
const script = cache.all().find((item) => item.id === id);
|
||||
if (!script) return;
|
||||
|
||||
script.lastRunAt = nowIso();
|
||||
script.lastRunOk = ok;
|
||||
|
||||
void tenantScriptStore
|
||||
.update(id, { lastRunAt: script.lastRunAt, lastRunOk: ok }, { tenantIds: [tenantId], includeGlobal: false })
|
||||
.catch((err: unknown) => {
|
||||
console.warn(`[skripty] beh skriptu ${id} se nepodarilo poznamenat:`, err);
|
||||
});
|
||||
}
|
||||
|
||||
/** Vzor pro novy skript. Radeji neco, co rovnou bezi, nez prazdne okno. */
|
||||
export const SCRIPT_TEMPLATE = `// Vstup je v \`input\`, vrat objekt.
|
||||
// Priklad: objednavka ze Shoptetu na doklad pro iDoklad.
|
||||
|
||||
const order = input.data.order;
|
||||
|
||||
return {
|
||||
documentSerialNumber: order.code,
|
||||
variableSymbol: order.code,
|
||||
dateOfIssue: order.creationTime.slice(0, 10),
|
||||
currencyCode: order.price.currencyCode,
|
||||
items: order.items.map((item) => ({
|
||||
name: item.name,
|
||||
code: item.code,
|
||||
amount: Number(item.amount),
|
||||
unit: item.unit || 'ks',
|
||||
unitPrice: Number(item.unitPrice.withoutVat),
|
||||
vatRate: Number(item.unitPrice.vatRate),
|
||||
})),
|
||||
};
|
||||
`;
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,8 @@
|
||||
*/
|
||||
|
||||
import { createIncident } from '../data/incidentStore.js';
|
||||
import { findTenantScript, noteRun } from '../data/tenantScripts.js';
|
||||
import { runSandbox } from './sandbox.js';
|
||||
import { findGroup, findPerson, findPersonByExternalId, listPeople } from '../data/people.js';
|
||||
import {
|
||||
assignTicket,
|
||||
@@ -513,6 +515,56 @@ const handlers: Record<string, Handler> = {
|
||||
* za tri dny) takhle delat nejde - to patri do fronty jako beh naplanovany
|
||||
* na pozdeji, viz `nextAttemptAt`. Strop je proto minuta.
|
||||
*/
|
||||
/**
|
||||
* Vlastni skript firmy: prevod dat napsany v JS.
|
||||
*
|
||||
* Do logu jde **vstup i vystup**. Prave to je duvod, proc krok existuje:
|
||||
* kdyz vysledek nesedi, nema se hadat, co do prevodu vlezlo.
|
||||
*/
|
||||
'transform/custom': (inputs, context) => {
|
||||
const id = inputs.scriptId?.trim();
|
||||
if (!id) return missing('vybraný skript');
|
||||
|
||||
const script = findTenantScript(id, [context.tenantId]);
|
||||
if (!script) {
|
||||
return {
|
||||
ok: false,
|
||||
summary: 'skript neexistuje',
|
||||
detail: `Skript ${id} ve firmě není. Nejspíš ho někdo smazal.`,
|
||||
outputs: {},
|
||||
};
|
||||
}
|
||||
if (!script.enabled) {
|
||||
return {
|
||||
ok: false,
|
||||
summary: `skript ${script.name} je vypnutý`,
|
||||
detail: 'Zapněte ho v záložce Skripty, nebo krok ze stromu odeberte.',
|
||||
outputs: {},
|
||||
};
|
||||
}
|
||||
|
||||
const source = inputs.source ? safeJson(inputs.source) : {};
|
||||
const result = runSandbox(script.code, source);
|
||||
noteRun(script.id, script.tenantId, result.ok);
|
||||
|
||||
if (!result.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
summary: `${script.name}: ${result.error?.split('\n')[0] ?? 'chyba'}`,
|
||||
// Cely vstup i chyba. Bez vstupu se chyba v prevodu hleda naslepo.
|
||||
detail: JSON.stringify({ vstup: source, chyba: result.error }, null, 2),
|
||||
outputs: {},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
summary: `${script.name}: hotovo za ${result.durationMs} ms`,
|
||||
detail: JSON.stringify({ vstup: source, vystup: result.value }, null, 2),
|
||||
outputs: { result: result.value },
|
||||
};
|
||||
},
|
||||
|
||||
'flow/pause': pause,
|
||||
/*
|
||||
* Tytéž kroky pod klici z katalogu.
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Spusteni vlastniho skriptu firmy.
|
||||
*
|
||||
* Skript je **ciste prevod hodnot**: dostane objekt, vrati objekt. Nic nevola
|
||||
* ven a nic si nepamatuje mezi behy. Diky tomu se da pustit rychle, opakovat
|
||||
* bez nasledku a v logu z nej staci vstup a vystup.
|
||||
*
|
||||
* Co skript nema k dispozici a proc:
|
||||
* - `require`, `import`, `process`, `fetch`: volani ven patri do kroku
|
||||
* konektoru, ktery ma pristupove udaje, opakovani i zapis do logu,
|
||||
* - `setTimeout` a Promise: beh musi skoncit v jednom kroku. Cekani ve fronte
|
||||
* se dela krokem Pauza, ne uspanim workera.
|
||||
*
|
||||
* **Cim to neni.** `node:vm` neni bezpecnostni hranice proti nekomu, kdo se
|
||||
* chce dostat ven - je to izolace proti nehode a proti zacyklení. Skript pise
|
||||
* spravce te same firmy, tedy nekdo, kdo uz stejne vidi jeji data. Kdyby mel
|
||||
* skripty psat nekdo zvenku, musi to jit do samostatneho procesu s vlastnimi
|
||||
* pravy, ne do teto funkce.
|
||||
*/
|
||||
|
||||
import { createContext, Script } from 'node:vm';
|
||||
|
||||
/** Kolik ms smi skript bezet. Delsi vypocet blokuje workera ostatnim firmam. */
|
||||
const TIMEOUT_MS = 2_000;
|
||||
|
||||
/** Strop na velikost vysledku. Vetsi objekt uz nikdo dal nezpracuje. */
|
||||
const MAX_RESULT_BYTES = 256 * 1024;
|
||||
|
||||
export interface SandboxResult {
|
||||
ok: boolean;
|
||||
/** Vysledek skriptu. Vzdy objekt, i kdyz skript vrati neco jineho. */
|
||||
value: unknown;
|
||||
/** Cele hlaseni chyby vcetne toho, na cem to spadlo. */
|
||||
error: string | null;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Male pomucky, ktere by si jinak kazdy skript psal znovu.
|
||||
*
|
||||
* Zamerne jich je malo. Kazda dalsi je vec, kterou musi ctenar skriptu znat -
|
||||
* a `input.data.order.items.map(...)` umi kazdy, kdo umi JS.
|
||||
*/
|
||||
const util = {
|
||||
/** Hodnota na ceste, `undefined` kdyz tam nic neni. `get(o, 'a.b.0.c')`. */
|
||||
get(source: unknown, path: string): unknown {
|
||||
let current: unknown = source;
|
||||
for (const part of path.split('.')) {
|
||||
if (current === null || current === undefined) return undefined;
|
||||
current = (current as Record<string, unknown>)[part];
|
||||
}
|
||||
return current;
|
||||
},
|
||||
/** Cislo z cehokoliv. Ceska desetinna carka je bezna, nema smysl na ni padat. */
|
||||
num(value: unknown): number | null {
|
||||
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
|
||||
const parsed = Number(String(value ?? '').trim().replace(',', '.'));
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
},
|
||||
/** Datum jako `2026-08-20`. Prazdne, kdyz to datum neni. */
|
||||
day(value: unknown): string {
|
||||
const parsed = Date.parse(String(value ?? ''));
|
||||
return Number.isNaN(parsed) ? '' : new Date(parsed).toISOString().slice(0, 10);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Spusti telo skriptu nad vstupem.
|
||||
*
|
||||
* **Nikdy nevyhodi vyjimku.** Chyba je vysledek, stejna dohoda jako
|
||||
* u `runScript` a `runFlow` - volajici pak nemusi resit try/catch a chyba se
|
||||
* vzdycky dostane do logu.
|
||||
*/
|
||||
export function runSandbox(code: string, input: unknown): SandboxResult {
|
||||
const startedAt = Date.now();
|
||||
|
||||
/*
|
||||
* Kod se zabali do funkce, ktera se **rovnou zavola**. Dve veci najednou:
|
||||
* - v kodu jde psat `return`, coz je to, co clovek ceka,
|
||||
* - beh je uvnitr `runInContext`, takze na nej plati casovy limit.
|
||||
*
|
||||
* Zavolat funkci az potom zvenku by limit obeslo: `runInContext` by jen
|
||||
* vyrobil funkci a `while (true) {}` uvnitr ni by zablokovalo workera
|
||||
* navzdy. Presne to se pri prvni zkousce stalo.
|
||||
*/
|
||||
const wrapped = `(function (input, util) {\n'use strict';\n${code}\n})(__input, __util);`;
|
||||
|
||||
try {
|
||||
const script = new Script(wrapped, { filename: 'skript-firmy.js' });
|
||||
|
||||
/*
|
||||
* Kontext nese jen vstup a pomucky. Skript nevidi ani `console`, ani
|
||||
* globalni objekty hostitele, takze nema kudy sahnout ven.
|
||||
*/
|
||||
const context = createContext(
|
||||
{
|
||||
__input: structuredClone(input),
|
||||
__util: util,
|
||||
/*
|
||||
* `console` v novem kontextu existuje samo od sebe a psalo by do logu
|
||||
* serveru. Prevod dat nema co vypisovat - kdyz je potreba videt, co se
|
||||
* stalo, je v logu ticketu vstup i vystup.
|
||||
*/
|
||||
console: undefined,
|
||||
},
|
||||
{ codeGeneration: { strings: false, wasm: false } },
|
||||
);
|
||||
|
||||
const value = script.runInContext(context, { timeout: TIMEOUT_MS }) as unknown;
|
||||
|
||||
if (value === undefined || value === null) {
|
||||
return {
|
||||
ok: false,
|
||||
value: null,
|
||||
error: 'Skript nic nevrátil. Poslední řádek má být return s objektem.',
|
||||
durationMs: Date.now() - startedAt,
|
||||
};
|
||||
}
|
||||
|
||||
const size = JSON.stringify(value)?.length ?? 0;
|
||||
if (size > MAX_RESULT_BYTES) {
|
||||
return {
|
||||
ok: false,
|
||||
value: null,
|
||||
error: `Výsledek má ${Math.round(size / 1024)} kB, vejde se ${MAX_RESULT_BYTES / 1024} kB.`,
|
||||
durationMs: Date.now() - startedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
* Vysledek vznikl uvnitr kontextu. Prevedeme ho na prosta data, aby se dal
|
||||
* ulozit i porovnat - objekt z ciziho kontextu neprojde kontrolou
|
||||
* `instanceof` a dal by se choval nepredvidatelne.
|
||||
*/
|
||||
return {
|
||||
ok: true,
|
||||
value: JSON.parse(JSON.stringify(value)) as unknown,
|
||||
error: null,
|
||||
durationMs: Date.now() - startedAt,
|
||||
};
|
||||
} catch (err) {
|
||||
/*
|
||||
* Hlaska musi obsahovat i misto. Bez cisla radku se v padesatiradkovem
|
||||
* prevodu hleda chyba hur nez ve stromu kroku.
|
||||
*/
|
||||
/*
|
||||
* Chyba z limitu nese prototyp z kontextu skriptu, takze `instanceof Error`
|
||||
* na ni neplati. Pozna se podle textu - overeno zkouskou s `while (true)`.
|
||||
*/
|
||||
const timedOut = /timed out/i.test(String(err));
|
||||
const message = timedOut
|
||||
? `Skript běžel déle než ${TIMEOUT_MS} ms a byl zastaven. Nejspíš je v něm ` +
|
||||
'nekonečná smyčka, nebo zpracovává příliš velký seznam.'
|
||||
: err instanceof Error
|
||||
? `${err.name}: ${err.message}${err.stack ? `\n${err.stack.split('\n').slice(0, 4).join('\n')}` : ''}`
|
||||
: String(err);
|
||||
|
||||
return { ok: false, value: null, error: message, durationMs: Date.now() - startedAt };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user