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:
co-authored by
Claude Fable 5.1
parent
0c405ea55a
commit
104ae36783
@@ -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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user