Rozdeleni na sluzby a konektory, pristupove udaje do konektoru

Slovo "konektor" v kodu znamenalo katalog toho, co umime. Ted znamena napojeni
jedne firmy, tedy to, co tim mysli i uzivatel. Popis modelu je
v documentation/12-sluzby-a-konektory.md.

Tri vrstvy:
- Sluzba: ze iDoklad existuje, co umi a co potrebuje k napojeni. Nase.
- Skript: kod, ktery jednu operaci sluzby opravdu vykona. Nas.
- Konektor: ucet firmy vcetne jejich pristupovych udaju. Firemni.

Pristupove udaje se prestaly cist z environment variables. Cela instance by
mela jedny udaje spolecne a dve firmy by fakturovaly z jednoho uctu. Napojeni
je vlastnost firmy, ne prostredi. Z prostredi zustava jen SERVICES_BASE_URL.

Pridano:
- src/data/services.ts: sluzba nese general, appId, visibility, credentials
  a verifyPath. Kategorie "obecne" sdruzuje veci, ktere ma kazdy a nepotrebuji
  konektor: webhook, planovac, tickety, transformace dat, HTTP pozadavek,
  pauza, zapis do logu
- viditelnost sluzby: vsichni, jen uvedene firmy a lide, nebo jen spravce
  platformy. Neviditelna sluzba se z API nevraci vubec, ne se stavem 403 -
  firma nema poznat, ze takova sluzba existuje
- src/data/connectorStore.ts: konektory za firmu vcetne hodnot udaju. Hodnoty
  se z API nikdy nevraci, jen filled a missing. Prazdne pole hodnotu nemeni,
  takze ulozeni formularu bez tajnych hodnot nic nepresepe
- FlowStep.connectorId: krok rika, pod kterym napojenim volat. null = vychozi
  konektor firmy, diky tomu je vzorovy strom prenositelny mezi firmami
- overeni konektoru pres verifyPath, tedy cteci volani vyzadujici autorizaci.
  U sluzby bez nej se overi jen dostupnost a odpoved to rekne nahlas, jinak by
  zeleny vysledek uzivateli lhal
- stranky /dashboard/sluzby a /dashboard/konektory vcetne formularu udaju
- endpointy /api/dashboard/services a CRUD /api/dashboard/connectors ve Swaggeru
- predvyplnene prihlaseni spravcem platformy a prepinac demo uctu na login
  strance, kvuli testovani prototypu

Zmeneno:
- stav "napojeno" se prestal cist z katalogu a zacal pocitat z konektoru firmy.
  Sluzba ma jen available nebo planned
- validace stromu overuje i konektor. Cizi konektor je chyba, chybejici
  napojeni nedodelek - rozdelana prace se nezahazuje
- prejmenovani napric kodem: Connector na Service, FlowStep.connectorId na
  serviceId, GET /connectors na GET /services, connectorIcons na serviceIcons,
  stranka Konektory (katalog) na Sluzby. Prevodni tabulka je v dokumentu 12

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 14:11:50 +02:00
co-authored by Claude Opus 5
parent 6f6b287d7e
commit 8ad91a6c28
37 changed files with 3006 additions and 776 deletions
+68 -105
View File
@@ -1,139 +1,102 @@
/**
* Napojeni konektoru: kam se vola a cim se to autorizuje.
* 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.
* Skript rika `GET /issued-invoices/12`. Zbytek dodava tenhle soubor ze dvou
* mist: **sluzba** rika, ktere hlavicky jsou potreba, **konektor** firmy nese
* jejich hodnoty.
*
* 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.
* Diky tomu se skript k pristupovym udajum vubec nedostane a dve firmy volaji
* tutez sluzbu kazda pod svym uctem.
*
* Z environment variables sem uz nechodi zadne pristupove udaje, jen zaklad
* adresy (`SERVICES_BASE_URL`). Udaje patri konektoru.
*/
import { config } from '../config.js';
import type { Connector } from '../data/connectorStore.js';
import { findService, type Service } from '../data/services.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;
export interface ResolvedTarget {
serviceId: string;
connectorId: string | null;
/** Prazdne u obecnych sluzeb - `ctx.http` pak vyzaduje absolutni adresu. */
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. */
serviceConfig: Record<string, string>;
/** false = chybi povinne udaje, volat nema smysl. */
ready: boolean;
/** Jmena environment variables, ktere chybi. */
/** Popisy chybejicich poli pro uzivatele. */
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`.
* bere se ze `SERVICES_BASE_URL` (AGENTS.md).
*/
function resolveBaseUrl(connectorId: string): string {
return (
readEnv(`${envPrefix(connectorId)}_BASE_URL`) ?? `${config.servicesBaseUrl}/${connectorId}`
);
export function serviceBaseUrl(service: Service): string {
if (service.appId === null) return '';
return `${config.servicesBaseUrl}/${service.appId}`;
}
export function resolveConnection(connectorId: string): ResolvedConnection {
const spec = authSpecs[connectorId];
const headers: Record<string, string> = {};
const scriptConfig: Record<string, string> = {};
const missing: string[] = [];
/**
* Slozi adresu a hlavicky pro beh skriptu.
*
* `connector` je null u obecnych sluzeb, ktere napojeni nepotrebuji. U ostatnich
* je jeho absence duvod, proc se skript nespusti - a rekne se to nahlas, ne ze
* se zavola bez autorizace a spadne to az na 401.
*/
export function resolveTarget(serviceId: string, connector: Connector | null): ResolvedTarget {
const service = findService(serviceId);
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);
if (!service) {
return {
serviceId,
connectorId: connector?.id ?? null,
baseUrl: '',
headers: {},
serviceConfig: {},
ready: false,
missing: [`Služba ${serviceId} v katalogu neexistuje.`],
};
}
for (const [key, envName] of Object.entries(spec?.config ?? {})) {
const value = readEnv(envName);
if (value !== undefined) scriptConfig[key] = value;
const headers: Record<string, string> = {};
const serviceConfig: Record<string, string> = {};
const missing: string[] = [];
for (const field of service.credentials) {
const value = (connector?.values[field.id] ?? '').trim();
if (value === '') {
if (field.required) missing.push(field.label);
continue;
}
if (field.target === 'header') headers[field.name] = value;
else serviceConfig[field.name] = value;
}
if (!service.general && connector === null) {
missing.push(`Služba ${service.name} potřebuje konektor.`);
}
if (connector !== null && !connector.enabled) {
missing.push(`Konektor ${connector.name} je vypnutý.`);
}
return {
connectorId,
name: `${connectorId} (z environment variables)`,
baseUrl: resolveBaseUrl(connectorId),
serviceId,
connectorId: connector?.id ?? null,
baseUrl: connector?.baseUrl ?? serviceBaseUrl(service),
headers,
config: scriptConfig,
serviceConfig,
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);
export function targetSecrets(target: ResolvedTarget): string[] {
return Object.values(target.headers);
}
+18 -8
View File
@@ -9,7 +9,7 @@
*/
import { config } from '../config.js';
import type { ResolvedConnection } from './connections.js';
import type { ResolvedTarget } from './connections.js';
import { ScriptError, type ScriptHttp, type ScriptHttpOptions, type ScriptHttpResponse } from './types.js';
import { describe } from './util.js';
@@ -53,12 +53,22 @@ function assertAllowedUrl(url: URL): void {
}
}
function buildUrl(connection: ResolvedConnection, path: string, options?: ScriptHttpOptions): URL {
function buildUrl(target: ResolvedTarget, path: string, options?: ScriptHttpOptions): URL {
// Obecna sluzba nema pevnou adresu, skript pak musi poslat absolutni.
const absolute = target.baseUrl === '';
if (absolute && !/^https?:\/\//i.test(path)) {
throw new ScriptError(
'config',
`Služba ${target.serviceId} nemá základní adresu, skript musí volat absolutní URL.`,
);
}
const raw = absolute ? path : joinUrl(target.baseUrl, path);
let url: URL;
try {
url = new URL(joinUrl(connection.baseUrl, path));
url = new URL(raw);
} catch {
throw new ScriptError('config', `Neplatná adresa: ${joinUrl(connection.baseUrl, path)}`);
throw new ScriptError('config', `Neplatná adresa: ${raw}`);
}
for (const [key, value] of Object.entries(options?.query ?? {})) {
@@ -108,7 +118,7 @@ function transportError(err: unknown, url: URL): ScriptError {
}
export interface CreateHttpOptions {
connection: ResolvedConnection;
target: ResolvedTarget;
signal: AbortSignal;
idempotencyKey: string;
/** Zredigovana verze textu, aby se tajemstvi nedostalo do logu. */
@@ -118,7 +128,7 @@ export interface CreateHttpOptions {
}
export function createHttp(options: CreateHttpOptions): ScriptHttp {
const { connection, signal, idempotencyKey, redact, log, onCall } = options;
const { target, signal, idempotencyKey, redact, log, onCall } = options;
async function request<T>(
method: string,
@@ -126,7 +136,7 @@ export function createHttp(options: CreateHttpOptions): ScriptHttp {
body: unknown,
httpOptions?: ScriptHttpOptions,
): Promise<ScriptHttpResponse<T>> {
const url = buildUrl(connection, path, httpOptions);
const url = buildUrl(target, path, httpOptions);
const hasBody = body !== undefined && method !== 'GET' && method !== 'DELETE';
const startedAt = Date.now();
onCall();
@@ -140,7 +150,7 @@ export function createHttp(options: CreateHttpOptions): ScriptHttp {
Accept: 'application/json',
// Druhy pokus tehoz kroku nesmi vystavit druhou fakturu.
'Idempotency-Key': idempotencyKey,
...connection.headers,
...target.headers,
...(hasBody ? { 'Content-Type': 'application/json' } : {}),
...httpOptions?.headers,
},
+4 -5
View File
@@ -6,12 +6,11 @@
* 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 type { OperationField, ProvidedField, ServiceOperation } from '../data/services.js';
import { scriptManifestSchema, type FieldIssue, type ScriptField, type ScriptManifest } from './types.js';
/** `idoklad.get-issued-invoice` -> `idoklad` */
export function connectorIdOf(scriptId: string): string {
export function serviceIdOf(scriptId: string): string {
return scriptId.slice(0, scriptId.indexOf('.'));
}
@@ -81,7 +80,7 @@ function toOperationField(field: ScriptField): OperationField {
*/
function toProvidedField(scriptId: string, field: ScriptField): ProvidedField {
return {
id: `${connectorIdOf(scriptId)}.${field.id}`,
id: `${serviceIdOf(scriptId)}.${field.id}`,
name: field.id,
type: field.type,
required: field.required,
@@ -89,7 +88,7 @@ function toProvidedField(scriptId: string, field: ScriptField): ProvidedField {
}
/** Operace katalogu odvozena ze skriptu. Tohle vidi builder. */
export function toConnectorOperation(manifest: ScriptManifest): ConnectorOperation {
export function toServiceOperation(manifest: ScriptManifest): ServiceOperation {
return {
id: operationIdOf(manifest.id),
name: manifest.name,
+13 -13
View File
@@ -17,8 +17,8 @@ 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 { services, setScriptActions, type ServiceOperation } from '../data/services.js';
import { parseManifest, serviceIdOf, toServiceOperation } from './manifest.js';
import type { FieldIssue, ScriptContext, ScriptManifest, ScriptValues } from './types.js';
export type ScriptRunFn = (
@@ -150,7 +150,7 @@ async function scan(): Promise<void> {
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.',
message: 'Název souboru musí mít tvar sluzba.operace.js, jen malá písmena a pomlčky.',
});
continue;
}
@@ -196,30 +196,30 @@ async function scan(): Promise<void> {
}
/**
* Prenese nactene skripty do katalogu konektoru.
* Prenese nactene skripty do katalogu sluzeb.
*
* 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[]>();
const byService = new Map<string, ServiceOperation[]>();
for (const script of scripts.values()) {
const connectorId = connectorIdOf(script.manifest.id);
if (!connectors.some((connector) => connector.id === connectorId)) {
const serviceId = serviceIdOf(script.manifest.id);
if (!services.some((service) => service.id === serviceId)) {
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.`,
message: `Služba ${serviceId} 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);
const list = byService.get(serviceId) ?? [];
list.push(toServiceOperation(script.manifest));
byService.set(serviceId, list);
}
setScriptActions(byConnector);
setScriptActions(byService);
}
/** Prohleda adresar, nejvyse jednou za RESCAN_MS. Soubezne volani se sdili. */
@@ -280,7 +280,7 @@ export type SaveResult =
*/
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.' };
return { ok: false, message: 'Neplatné ID skriptu. Povolený tvar je sluzba.operace.' };
}
if (code.trim().length === 0) {
return { ok: false, message: 'Kód skriptu nesmí být prázdný.' };
+15 -8
View File
@@ -12,9 +12,10 @@
import { createHash } from 'node:crypto';
import { config } from '../config.js';
import { connectionSecrets, resolveConnection } from './connections.js';
import type { Connector } from '../data/connectorStore.js';
import { resolveTarget, targetSecrets } from './connections.js';
import { createHttp } from './http.js';
import { connectorIdOf } from './manifest.js';
import { serviceIdOf } from './manifest.js';
import { getScript } from './registry.js';
import {
isRetryableKind,
@@ -28,6 +29,12 @@ import { createRedactor, describe, scriptUtil } from './util.js';
import { validateValues } from './values.js';
export interface RunScriptOptions {
/**
* Konektor firmy, pod jehoz udaji se to zavola. `null` je v poradku jen
* u obecnych sluzeb - u ostatnich se skript nespusti a rekne se to nahlas.
* Prislusnost konektoru k firme si overuje volajici.
*/
connector?: Connector | null;
/**
* Stabilni pres vsechny pokusy tehoz kroku. Kdyz chybi, dopocita se
* ze skriptu a vstupu - dva stejne pokusy tak dostanou stejny klic.
@@ -95,16 +102,16 @@ export async function runScript(
}
const { manifest } = script;
const connection = resolveConnection(connectorIdOf(scriptId));
const redact = createRedactor(connectionSecrets(connection));
const target = resolveTarget(serviceIdOf(scriptId), options.connector ?? null);
const redact = createRedactor(targetSecrets(target));
if (!connection.ready) {
if (!target.ready) {
return finish({
ok: false,
outputs: {},
error: {
kind: 'config',
message: `Napojení na ${connection.connectorId} není nastavené. Chybí: ${connection.missing.join(', ')}.`,
message: `Napojení není hotové: ${target.missing.join(', ')}.`,
retryable: false,
},
});
@@ -144,7 +151,7 @@ export async function runScript(
const ctx: ScriptContext = {
http: createHttp({
connection,
target,
signal: controller.signal,
idempotencyKey,
redact,
@@ -155,7 +162,7 @@ export async function runScript(
}),
util: scriptUtil,
log,
config: Object.freeze({ ...connection.config }),
config: Object.freeze({ ...target.serviceConfig }),
idempotencyKey,
fail(message, detail) {
throw new ScriptError('terminal', message, { detail: describe(detail) });
+1 -1
View File
@@ -76,7 +76,7 @@ export const scriptManifestSchema = 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.',
'ID skriptu musí mít tvar sluzba.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ý.'),