Marketplace — Developer Reference
Marketplace — Developer Reference
Section titled “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).
Schéma marketplace-app
Section titled “Schéma marketplace-app”name string, requiredslug uid (z name)description textimage_ref string, required — reference na container imageconfig_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 poplatekrejection_reason text — nastavuje jen akce reject, vždy admin rozhodnutíscan_error text — nastavuje jen technické selhání skenu, nikdy admin rozhodnutísubmitted_at datetimeapproved_at datetimeapproved_by relace → userx-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.
Review pipeline (stavový automat)
Section titled “Review pipeline (stavový automat)” submit() approve()draft ─────────────► pending_review ─────────────► approved ──► suspend() ──► suspended ▲ │ reject() └──── withdraw() ────────┘ └─────────────► rejecteddraft → pending_review(POST /api/marketplace-apps/:id/submit) — vyžaduje roliadmin+ vpublisher_organisation(nebo platform admina). Validuje, že jsou vyplněná všechna čtyři polename/description/image_ref/config_templatea žeimage_refodpovídá přijímanému formátu image reference, nastavísubmitted_ata spustítriggerSecurityScan()fire-and-forget (setImmediate, nikdy neblokuje HTTP odpověď).pending_review → draft(POST .../:id/withdraw) — stejný požadavek na roli. Vymažesubmitted_at. Z jiného stavu není dostupné.pending_review → approved(POST .../:id/approve) — jen platform admin (isSencaiAdmin(ctx), ne pouhé org membership — vizloadAppForAdmin()níže). Tvrdě zablokováno (400, s polemblockingFindings), dokud Trivy scan gate hlásí jakýkoliv otevřený nálezcritical/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_reasonv 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).
loadAppForMember() vs. loadAppForAdmin()
Section titled “loadAppForMember() vs. loadAppForAdmin()”Dva odlišné helpery pro řešení přístupu, záměrně nesdílené:
loadAppForMember(strapi, ctx, minRole)— použitý uupdate/delete/submit/withdraw. Povolí přístup volajícímu, pokud drží alespoňminRolevpublisher_organisationdané aplikace, nebo je platform admin. Cizí/non-member/neexistující aplikace →404, nikdy403— nesmí uniknout existence cizího draftu.loadAppForAdmin(strapi, ctx)— použitý uapprove/reject/suspend. Povolí přístup jenisSencaiAdmin(ctx)— vlastní admin publisher org zde dostane403, ne404. 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 oprotiloadAppForMember.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.
Viditelnost katalogu (find/findOne)
Section titled “Viditelnost katalogu (find/findOne)”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.
Mass-assignment hardening
Section titled “Mass-assignment hardening”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.
Trivy security-scan gate
Section titled “Trivy security-scan gate”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.
trivy image --format json --quiet --no-progress "<image_ref>"imageRefse 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_refuž je formátem validovaný přesvalidateImageRef()v časesubmit()).- Nálezy jsou omezeny na
MAX_FINDINGS_PER_SCAN = 200per 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 domarketplace-app.scan_error— nikdy dorejection_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.
Kontrola scan gate (getScanGateStatus())
Section titled “Kontrola scan gate (getScanGateStatus())”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.
Validace image reference
Section titled “Validace image reference”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.
Revenue share (marketplace-revenue-share)
Section titled “Revenue share (marketplace-revenue-share)”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-apppublisher_organisation relace → organisation (denormalizovaná z marketplace_app při vytvoření)customer_organisation relace → organisation (kdo vygeneroval usage)billing_period_start/end datetime, requiredgross_amount decimal, requiredfee_percent decimal, required — snapshot marketplace_app.fee_percent V ČASE VYTVOŘENÍsencai_fee_amount decimal, required — dopočítáno server-sidepartner_share_amount decimal, required — dopočítáno server-sidepayout_status enum: pending | manual_invoice_required | paid (default: pending)source_event_idempotency_key string, uniqueKalkulace 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_percentje nasnapshotovaný jednou, při vytvoření záznamu, a nikdy znovu nečtený. Pozdější admin úpravamarketplace_app.fee_percentnemůže nikdy zpětně změnit už vytvořený revenue-share záznam.POST /api/marketplace-revenue-sharesje service-only (X-Service-Secret=BILLING_ADAPTER_SERVICE_SECRET) — jediný producent jebilling-adapter, volá po úspěšném sync marketplace-sourced metering eventu do Lago. Idempotentní nasource_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áznamypublisher_organisation,404(nikdy403) pro cizí organizaci.POST /api/marketplace-revenue-shares/:id/mark-paid— jen platform admin. Jediný způsob, jak sepayout_statusmůže státpaid, dokud nepřistane F4.MARKET.06b — žádný automatizovaný flow to nikdy nenastaví sám. Validní zpending/manual_invoice_required;400ze samotnéhopaid.sencai-adminvystavuje paginovaný worklist (GET /sencai-admin/marketplace-revenue-shares, volitelný filtrpayout_status) a zrcadlenou akcimark-paidpro ruční fakturaci — dnes jde o jediné UI pro revenue-share záznamy; publisherovi orientovaný revenue dashboard vsencai.space-frontendneexistuje.
Audit události
Section titled “Audit události”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 má 'high' jen pokud sken našel critical/high nález, jinak 'low'.
Frontendový povrch
Section titled “Frontendový povrch”pages/gravity/marketplace/index.vue— třízáložkový katalog (apps/my-submissions/pending-review), jediné voláníGET /api/marketplace-appsobsluhující všechny tři (backendováfind()odpověď je pro volajícího už správná unie, taby jsou čisté client-side filtrování — vizfilterApprovedCatalog()/filterMySubmissions()/filterPendingReview()vcomposables/useMarketplace.ts).pages/gravity/marketplace/detail/[id].vue— Deploy CTA (jen approved), owner akce (submit/withdraw), admin akce (approve/reject/suspend, včetně panelublockingFindings, když scan gate blokuje schválení).pages/gravity/marketplace/submit.vue— jednostránkový create-pak-submit formulář (POST /api/marketplace-appshned 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.tsdekóduje KC JWT, aby zrcadlil backendovou kontroluisSencaiAdmin(ctx)jen pro UI gating — sám o sobě nikdy není authorization hranicí; každá admin akce je znovu vynucena server-side přesloadAppForAdmin().