Přeskočit na obsah

Marketplace — Developer Reference

Marketplace žije celý uvnitř sencai.space (Strapi backend) — samostatný marketplace microservice neexistuje. Stojí na dvou content-typech: marketplace-app (listing + review workflow) a marketplace-revenue-share (fakturační evidence, produkovaná billing-adapter). Katalog/detail/submission UI žije v sencai.space-frontend (pages/gravity/marketplace/, composables/useMarketplace.ts).

name string, required
slug uid (z name)
description text
image_ref string, required — reference na container image
config_template json — parametrizovaný deploy manifest (viz "Kontrakt předvyplnění wizardu" níže)
icon media (jeden obrázek)
publisher_organisation relace → organisation (manyToOne)
status enum: draft | pending_review | approved | rejected | suspended (default: draft)
fee_percent decimal, default 20 — platformní revenue-share poplatek
rejection_reason text — nastavuje jen akce reject, vždy admin rozhodnutí
scan_error text — nastavuje jen technické selhání skenu, nikdy admin rozhodnutí
submitted_at datetime
approved_at datetime
approved_by relace → user

x-sencai-classification: tenant-private (aplikace sama neukládá žádný secret, ale config_template/fee_percent jsou obchodní data partnera). Žádný holý factories.createCoreController — každá akce v src/api/marketplace-app/controllers/marketplace-app.ts explicitně řeší org membership přes src/utils/org-scope.ts ještě před dotykem na záznam, stejná IDOR-hardening konvence jako custom-registry/vulnerability-report.

submit() approve()
draft ─────────────► pending_review ─────────────► approved ──► suspend() ──► suspended
▲ │ reject()
└──── withdraw() ────────┘ └─────────────► rejected
  • draft → pending_review (POST /api/marketplace-apps/:id/submit) — vyžaduje roli admin+ v publisher_organisation (nebo platform admina). Validuje, že jsou vyplněná všechna čtyři pole name/description/image_ref/config_template a že image_ref odpovídá přijímanému formátu image reference, nastaví submitted_at a spustí triggerSecurityScan() fire-and-forget (setImmediate, nikdy neblokuje HTTP odpověď).
  • pending_review → draft (POST .../:id/withdraw) — stejný požadavek na roli. Vymaže submitted_at. Z jiného stavu není dostupné.
  • pending_review → approved (POST .../:id/approve) — jen platform admin (isSencaiAdmin(ctx), ne pouhé org membership — viz loadAppForAdmin() níže). Tvrdě zablokováno (400, s polem blockingFindings), dokud Trivy scan gate hlásí jakýkoliv otevřený nález critical/high — i pro admina, který by to chtěl protlačit silou.
  • pending_review → rejected (POST .../:id/reject) — jen platform admin. Vyžaduje neprázdné rejection_reason v těle requestu.
  • approved → suspended (POST .../:id/suspend) — jen platform admin. Odstraní aplikaci z veřejného katalogu; existující nasazení z ní vytvořená nejsou dotčena — blokuje jen nové Deploy CTA.

Všech pět přechodů vrátí 400 z jakéhokoliv jiného stavu, než jaký očekávají — žádný z nich není idempotentní re-run (druhé submit() v době, kdy je aplikace už pending_review, je 400, ne tichý no-op).

Dva odlišné helpery pro řešení přístupu, záměrně nesdílené:

  • loadAppForMember(strapi, ctx, minRole) — použitý u update/delete/submit/withdraw. Povolí přístup volajícímu, pokud drží alespoň minRole v publisher_organisation dané aplikace, nebo je platform admin. Cizí/non-member/neexistující aplikace → 404, nikdy 403 — nesmí uniknout existence cizího draftu.
  • loadAppForAdmin(strapi, ctx) — použitý u approve/reject/suspend. Povolí přístup jen isSencaiAdmin(ctx) — vlastní admin publisher org zde dostane 403, ne 404. Není co skrývat před volajícím, který prostě nemá oprávnění cokoliv reviewovat vůbec — jde o záměrně opačnou konvenci oproti loadAppForMember. approval-request.approve()/.reject() (samostatný, generický content-type) kontroluje jen !user?.id, ne roli — marketplace review záměrně tento volnější vzor nekopíruje.

GET /api/marketplace-apps je auth: false bez require-auth policy:

  • Anonymní volající → jen status: 'approved', bez ohledu na query parametry.
  • Autentizovaný non-admin → { status: 'approved' } UNION { publisher_organisation ve vlastních org id volajícího, libovolný status }.
  • Platform admin → úplně vše.

GET /api/marketplace-apps/:id — aplikace ve stavu approved je veřejná; jakýkoliv jiný stav vyžaduje viewer+ membership v publisher_organisation nebo platform admina, jinak 404.

create() vždy vynutí status: 'draft' server-side bez ohledu na tělo requestu. update() přijímá jen explicitní allowlist zapisovatelných polí (name/description/image_ref/config_template/icon) — status, publisher_organisation, fee_percent a žádné workflow pole (submitted_at/approved_at/approved_by/rejection_reason/scan_error) přes PUT nikdy nelze nastavit; procházejí výhradně akcemi výše. Platform admin smí navíc nastavit fee_percent při create/update.

src/api/marketplace-app/services/scan-runner.ts (runTrivyScan()) je minimální, in-process wrapper — nesdílí kód s vlastní Trivy integrací cloud-connector (vuln-scanner.service.ts), což je denní cron nad už-running cloud instancemi v samostatném microservice/procesu. Marketplace gate místo toho běží synchronně uvnitř téhož Strapi procesu, který vlastní vulnerability-report, takže se výsledky persistují přímo přes strapi.documents() bez HTTP round-tripu.

Terminál
trivy image --format json --quiet --no-progress "<image_ref>"
  • imageRef se nikdy neinterpoluje do shellu surově — znaky, které by mohly vylomit uvozený argument, se ořežou ještě před sestavením příkazu (defense-in-depth; image_ref už je formátem validovaný přes validateImageRef() v čase submit()).
  • Nálezy jsou omezeny na MAX_FINDINGS_PER_SCAN = 200 per sken — chrání DB před patologickým image, aniž by v reálných scénářích kdy skryl critical/high nález.
  • Technické selhání (chybějící scanner binárka, nedostupný image, timeout, malformovaný JSON) vrátí { ok: false, error } místo throw a persistuje se do marketplace-app.scan_errornikdy do rejection_reason, které zůstává samostatné, vždy explicitní admin rozhodnutí.
  • Grype se záměrně nezapojuje jako fallback scanner — pokud je Trivy nedostupná, sken selže technicky, místo aby tiše vyfabrikoval prázdný “čistý” výsledek pod jiným scanner labelem.

Při úspěšném skenu triggerSecurityScan() (service metoda na marketplace-app) persistuje každý nález jako řádek vulnerability-report scoped přes (nullable) relaci marketplace_app — nikdy cloud_instance_id — s organisation nastavenou na publisher_organisation dané aplikace, vyčistí případný starý scan_error z předchozího neúspěšného pokusu a emituje market.app.scan.completed (risk_level: 'high', pokud je aspoň jeden nález critical/high, jinak 'low') nebo market.app.scan.failed (risk_level: 'medium') přes audit publisher.

async getScanGateStatus(appId: number): Promise<{
blocked: boolean;
blockingFindings: Array<{ documentId: string; severity: string; cve_id: string | null }>;
}>

Dívá se jen na AKTUÁLNÍ řádky vulnerability-report se status: 'open' scoped na danou aplikaci, se severity v ['critical', 'high']. approve() tuto funkci volá a tvrdě blokuje (400), pokud je blocked: true — gate se automaticky uvolní, jakmile jsou všechny blokující nálezy vyřešené (accepted/fixed/false_positive přes vulnerability-report.acceptRisk()/.markFixed()/ruční změnu statusu); žádný samostatný “unblock” krok není potřeba.

Samotná vulnerability-report byla rozšířena o nullable relaci marketplace_app vedle svého už existujícího stringového pole cloud_instance_id. Záznam se scopuje výhradně na jedno z obou, nikdy na obě, nikdy na žádné — vynuceno aplikační XOR kontrolou (validateScope()), protože Strapi v5 nemá cross-field DB CHECK constraint.

validateImageRef() existuje na obou stranách — v marketplace-app service (server-side zdroj pravdy) i v composables/useMarketplace.ts (client-side fail-fast předkontrola, stejný regex udržovaný ručně v synchronizaci):

/^[\w][\w.-]*(:\d+)?(\/[\w][\w.-]*)*:[\w][\w.-]{0,127}$/

Přijímá holé name:tag (implicitní Docker Hub library/), namespace/name:tag i registry[:port]/namespace/name:tag — tag je vždy povinný; reference bez :tag je odmítnuta. V repu v době psaní neexistoval žádný validátor image reference k reuse (docker-hub service tento tvar nevaliduje).

Kontrakt předvyplnění wizardu (config_template)

Section titled “Kontrakt předvyplnění wizardu (config_template)”

config_template je free-form JSON sloupec bez schema-level tvaru — composables/useMarketplace.ts’s buildWizardPreset() je první (a zatím jediný) konzument, takže klíče, které rozpoznává, JSOU de-facto kontrakt pro publishery:

interface MarketplaceConfigTemplate {
provider?: string;
region?: string;
instance_type?: string;
root_disk_gb?: number;
additional_disks?: Array<{ sizeGb: number; type?: string; label?: string }>;
tags?: string[];
env?: Record<string, string>;
cloud_init?: string;
}

Položky env se převedou na řádky export KEY="value" předřazené cloud_init (backslashe se escapují před uvozovkami — escapování uvozovek jako první by u hodnoty končící lichým počtem backslashů dovolilo předčasně vylomit se z uvozeného řetězce); klíče env, které nemají tvar validního identifikátoru, se tiše přeskočí místo surové interpolace. Každý klíč je volitelný — řídký nebo prázdný config_template se elegantně degraduje na vlastní výchozí hodnoty wizardu instance. pages/gravity/instances/new.vue’s applyMarketplacePreset() čte ?marketplaceAppId=... (nastavené Deploy CTA na detailní stránce marketplace, vedle ?image=...&provider=hetzner), stáhne aplikaci a předvyplní existující ADR-001 wizard pro založení instance — pro marketplace deploye neexistuje žádná samostatná provisioning cesta.

Pouze kalkulace/evidence — v důsledku ničeho v tomto content-typu se skutečně nepřevádí žádné peníze. Reálná výplata partnerům přes Stripe Connect (F4.MARKET.06b) je samostatný, zatím neimplementovaný task, gatovaný na business rozhodnutí o modelu poplatku a výplatním mechanismu (Connect vs. ruční fakturace).

marketplace_app relace → marketplace-app
publisher_organisation relace → organisation (denormalizovaná z marketplace_app při vytvoření)
customer_organisation relace → organisation (kdo vygeneroval usage)
billing_period_start/end datetime, required
gross_amount decimal, required
fee_percent decimal, required — snapshot marketplace_app.fee_percent V ČASE VYTVOŘENÍ
sencai_fee_amount decimal, required — dopočítáno server-side
partner_share_amount decimal, required — dopočítáno server-side
payout_status enum: pending | manual_invoice_required | paid (default: pending)
source_event_idempotency_key string, unique

Kalkulace je izolovaná v services/revenue-share-calculator.ts (calculateRevenueShare()), čistá funkce bez závislosti na strapi:

sencai_fee_amount = round2(gross_amount * fee_percent / 100)
partner_share_amount = round2(gross_amount - sencai_fee_amount)

partner_share_amount se odvozuje odečtením už zaokrouhleného poplatku od hrubé částky, ne nezávislým druhým zaokrouhlením — to garantuje sencai_fee_amount + partner_share_amount === gross_amount přesně na cent, což nezávislé zaokrouhlení obou stran nezaručí. Zaokrouhlení je ploché 2-desetinné round-half-up — zdokumentované omezení, ne měnově-specifické (zatím žádné řešení pro 0-desetinný JPY / 3-desetinný BHD).

  • fee_percent je nasnapshotovaný jednou, při vytvoření záznamu, a nikdy znovu nečtený. Pozdější admin úprava marketplace_app.fee_percent nemůže nikdy zpětně změnit už vytvořený revenue-share záznam.
  • POST /api/marketplace-revenue-shares je service-only (X-Service-Secret = BILLING_ADAPTER_SERVICE_SECRET) — jediný producent je billing-adapter, volá po úspěšném sync marketplace-sourced metering eventu do Lago. Idempotentní na source_event_idempotency_key — opakovaný sync stejného eventu vrátí existující záznam (200, idempotent: true) místo vytvoření duplicity.
  • GET /api/marketplace-revenue-shares/:id — org-scoped: platform admin vidí vše, člen publisher organizace (libovolná role) vidí jen vlastní záznamy publisher_organisation, 404 (nikdy 403) pro cizí organizaci.
  • POST /api/marketplace-revenue-shares/:id/mark-paid — jen platform admin. Jediný způsob, jak se payout_status může stát paid, dokud nepřistane F4.MARKET.06b — žádný automatizovaný flow to nikdy nenastaví sám. Validní z pending/manual_invoice_required; 400 ze samotného paid.
  • sencai-admin vystavuje paginovaný worklist (GET /sencai-admin/marketplace-revenue-shares, volitelný filtr payout_status) a zrcadlenou akci mark-paid pro ruční fakturaci — dnes jde o jediné UI pro revenue-share záznamy; publisherovi orientovaný revenue dashboard v sencai.space-frontend neexistuje.

Všechny marketplace audit akce používají prostý action: string přes src/utils/audit-publisher.ts — žádná z nich (zatím) není přidaná do uzavřeného AuditAction union balíčku @sencai/audit, stejné zdůvodnění jako u několika dalších přírůstků F4 vlny (viz Audit Trail — Developer Reference):

market.app.created / .updated / .deleted / .submitted / .withdrawn / .approved / .rejected / .suspended / .scan.completed / .scan.failed / .revenue-share.calculated / .revenue-share.payout_marked_paid

approve/reject/suspend všechny publikují s risk_level: 'high'; scan.completed'high' jen pokud sken našel critical/high nález, jinak 'low'.

  • pages/gravity/marketplace/index.vue — třízáložkový katalog (apps/my-submissions/pending-review), jediné volání GET /api/marketplace-apps obsluhující všechny tři (backendová find() odpověď je pro volajícího už správná unie, taby jsou čisté client-side filtrování — viz filterApprovedCatalog()/filterMySubmissions()/filterPendingReview() v composables/useMarketplace.ts).
  • pages/gravity/marketplace/detail/[id].vue — Deploy CTA (jen approved), owner akce (submit/withdraw), admin akce (approve/reject/suspend, včetně panelu blockingFindings, když scan gate blokuje schválení).
  • pages/gravity/marketplace/submit.vue — jednostránkový create-pak-submit formulář (POST /api/marketplace-apps hned následovaný POST .../:id/submit); pokud submit krok selže po úspěšném create, aplikace pořád existuje jako draft a uživatel skončí na její detailní stránce, která má vlastní retry akci.
  • composables/useIsPlatformAdmin.ts dekóduje KC JWT, aby zrcadlil backendovou kontrolu isSencaiAdmin(ctx) jen pro UI gating — sám o sobě nikdy není authorization hranicí; každá admin akce je znovu vynucena server-side přes loadAppForAdmin().