Revize projektu: prava, vykon, runtime, portal a ARES

Prava a bezpecnost: spravce firmy uz nemuze nastavit priznak spravce
platformy ani clenstvi v cizi firme; pozvanky, konektory a automatizace
kontroluji sve pravo; cizi firma v query je 404; zivy stream posila
udalosti jen firmam, kterych se tykaji; akce nad ticketem maji kontrolu
prava za firmu ticketu a strop viditelnosti; tokeny se nelogujou; limit
pokusu na prihlaseni, kontakt a pozvanky; bezpecnostni hlavicky;
zachyceni chyb v async handlerech; timing-safe porovnani tokenu.

Vykon: audit neskenuje celou kolekci pri kazdem zapisu a konecne maze
firemni zaznamy; ticket se uklada jednou misto trikrat; zapisy do
Postgresu jsou serializovane podle ID; prava se pocitaji jednou na
request; widgety nacitaji tickety jednou; strankovani seznamu; worker
je pool misto kol; na webu udalost ze streamu neodmontuje stranku,
dotazy maji spolecny debounce a cache, ciselniky drzi typovany sklad.

Runtime: opakuji se jen chyby oznacene retryable; smycka nenarazi na
strop 50 kroku (novy strop 1000 akci); podminka nad datem funguje;
vystup MCP nastroje neprepisuje spoustec; sandbox skriptu firmy nejde
opustit; MCP session id se drzi mezi volanimi; incident z kroku patri
firme; jedno rozhodnuti o rezimu uloziste; snapshot neprepise soubor
po chybe cteni.

Refaktory: sdilene typy API v src/shared (web nic nekopiruje, osm
rozjetych tvaru sjednoceno); spolecny modul net/guard pro volani ven;
formularova vrstva ui/form; rozdeleni Connectors a FlowCanvas; jeden
helper pro firmu z query, validaci a CRUD udalosti; pomucky ctx.util
pro skripty konektoru; i18n verejneho webu vcetne anglictiny.

Nova funkce: zalozeni firmy z registru ARES v Nastaveni (IC nebo nazev,
dotazeni IC, DIC, sidla a pravni formy, vyber soucasnych statutarnich
zastupcu a prokury, ucty spravce firmy s nahradnim e-mailem
IC-poradi@placeholder.cz).

Dokumentace: zaznam v 99-zmeny.md a aktualizace 15 dalsich dokumentu.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
JiriUhlir
2026-09-09 10:26:07 +02:00
co-authored by Claude Fable 5.1
parent 0c405ea55a
commit 104ae36783
215 changed files with 13226 additions and 8320 deletions
+152
View File
@@ -0,0 +1,152 @@
/**
* Spolecna pravidla pro volani ven.
*
* Adresu vyplnuje firma (napojeni, MCP server, SMTP), takze tohle je jedina
* zabrana proti navazani spojeni dovnitr nasi site. Pravidlo ma byt jedno:
* driv bylo trikrat (HTTP klient, MCP klient, SMTP) a casem by se rozesla.
*
* Totez plati pro popis chyby spojeni a pro cteni tela s limitem - kazdy
* klient to resil po svem a jeden z nich cetl cele telo do pameti, nez zjistil,
* ze je moc velke.
*/
import { config } from '../config.js';
const privateHostPattern =
/^(localhost|127\.|0\.0\.0\.0$|10\.|192\.168\.|169\.254\.|::1$|\[::1\]$|172\.(1[6-9]|2\d|3[01])\.)/i;
/** Miri tohle jmeno do vnitrni site? */
export function isPrivateHost(hostname: string): boolean {
return privateHostPattern.test(hostname);
}
/** Duvod, proc se na tento server nesmi. `null` = smi. */
export function hostProblem(hostname: string): string | null {
if (!config.allowPrivateTargets && isPrivateHost(hostname)) {
return (
`Adresa ${hostname} míří do vnitřní sítě. ` +
'Pro místní vývoj nastavte ALLOW_PRIVATE_TARGETS=true.'
);
}
return null;
}
/** Duvod, proc se na tuto adresu nesmi: jen http(s) a ne do vnitrni site. */
export function urlProblem(url: URL): string | null {
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
return `Adresa ${url.protocol} není povolená, jen http a https.`;
}
return hostProblem(url.hostname);
}
/**
* Overi adresu a pri problemu vyhodi chybu, kterou si volajici sam vyrobi.
* Kazdy klient ma vlastni typ chyby (ScriptError, McpFailure), proto se
* nevyhazuje primo.
*/
export function assertAllowedUrl(url: URL, fail: (message: string) => Error): void {
const problem = urlProblem(url);
if (problem !== null) throw fail(problem);
}
export interface FetchErrorInfo {
/** true = vyprsel limit (AbortError), ne chyba site. */
timedOut: boolean;
/** Kod od Node (`ECONNREFUSED`), prazdny kdyz zadny neni. */
code: string;
/** Hlaska pro uzivatele. */
message: string;
/** Puvodni chyba vcetne priciny, do `detail`. */
detail: string;
}
/**
* Popis chyby, kterou vyhodil `fetch`.
*
* `where` je server nebo adresa do hlasky. `cause` nese u fetch to podstatne,
* proto se pripojuje - bez nej je v hlasce jen "fetch failed".
*/
export function describeFetchError(err: unknown, where: string): FetchErrorInfo {
const code =
err !== null && typeof err === 'object' && 'code' in err ? String((err as { code: unknown }).code) : '';
const name = err instanceof Error ? err.name : '';
const reason = err instanceof Error ? err.message : String(err);
const cause = err instanceof Error && err.cause instanceof Error ? `\nPříčina: ${err.cause.message}` : '';
const timedOut = name === 'AbortError' || name === 'TimeoutError';
return {
timedOut,
code,
message: timedOut
? `Server ${where} neodpověděl v limitu.`
: `Nepodařilo se spojit s ${where}: ${reason}`,
detail: `${name}: ${reason}${cause}`,
};
}
/** Hlaska pro telo nad limitem. Jedna, aby byla vsude stejna. */
export function tooLargeMessage(limit: number): string {
return `Odpověď je větší než povolený limit ${limit} bajtů.`;
}
export interface LimitedBody {
text: string;
/** true = telo presahlo limit. Cteni se zastavilo, `text` je neuplny. */
tooLarge: boolean;
}
/**
* Precte telo odpovedi, ale nejvys `limit` bajtu.
*
* Cte se po kouscich a pri prekroceni se spojeni zavre. Precist cele telo
* a az pak merit by znamenalo, ze limit chrani jen log, ne pamet procesu.
*/
export async function readBodyLimited(response: Response, limit: number): Promise<LimitedBody> {
const declared = Number(response.headers.get('content-length') ?? 0);
if (declared > limit) {
await response.body?.cancel().catch(() => undefined);
return { text: '', tooLarge: true };
}
const body = response.body;
if (!body) return { text: '', tooLarge: false };
const reader = body.getReader();
const decoder = new TextDecoder();
let text = '';
let size = 0;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
size += value.byteLength;
if (size > limit) {
await reader.cancel().catch(() => undefined);
return { text, tooLarge: true };
}
text += decoder.decode(value, { stream: true });
}
text += decoder.decode();
return { text, tooLarge: false };
} finally {
reader.releaseLock();
}
}
export type LimitedJson =
| { tooLarge: true; text: string; json: undefined }
/** `json` je `undefined`, kdyz telo neni platny JSON. Prazdne telo je `null`. */
| { tooLarge: false; text: string; json: unknown };
/** Telo jako JSON s limitem. Nevyhazuje, volajici si vybere vlastni chybu. */
export async function readJsonLimited(response: Response, limit: number): Promise<LimitedJson> {
const body = await readBodyLimited(response, limit);
if (body.tooLarge) return { tooLarge: true, text: body.text, json: undefined };
if (body.text.trim() === '') return { tooLarge: false, text: body.text, json: null };
try {
return { tooLarge: false, text: body.text, json: JSON.parse(body.text) as unknown };
} catch {
return { tooLarge: false, text: body.text, json: undefined };
}
}