Marketplace — Developer Reference
Marketplace — Developer Reference
Section titled “Marketplace — Developer Reference”The marketplace lives entirely inside sencai.space (the Strapi backend) — there is no separate marketplace microservice. Two content-types back it: marketplace-app (the listing + review workflow) and marketplace-revenue-share (billing evidence, produced by billing-adapter). The catalog/detail/submission UI is in sencai.space-frontend (pages/gravity/marketplace/, composables/useMarketplace.ts).
marketplace-app schema
Section titled “marketplace-app schema”name string, requiredslug uid (from name)description textimage_ref string, required — container image referenceconfig_template json — parametrized deploy manifest (see "Wizard prefill contract" below)icon media (single image)publisher_organisation relation → organisation (manyToOne)status enum: draft | pending_review | approved | rejected | suspended (default: draft)fee_percent decimal, default 20 — revenue-share platform feerejection_reason text — set only by the reject action, always an admin decisionscan_error text — set only on a technical scan failure, never an admin decisionsubmitted_at datetimeapproved_at datetimeapproved_by relation → userx-sencai-classification: tenant-private (the app doesn’t store any secret, but config_template/fee_percent are a partner’s business data). No bare factories.createCoreController — every action in src/api/marketplace-app/controllers/marketplace-app.ts explicitly resolves org membership through src/utils/org-scope.ts before touching a record, the same IDOR-hardening convention as custom-registry/vulnerability-report.
Review pipeline (state machine)
Section titled “Review pipeline (state machine)” submit() approve()draft ─────────────► pending_review ─────────────► approved ──► suspend() ──► suspended ▲ │ reject() └──── withdraw() ────────┘ └─────────────► rejecteddraft → pending_review(POST /api/marketplace-apps/:id/submit) — requiresadmin+ role inpublisher_organisation(or platform admin). Validates all four ofname/description/image_ref/config_templateare populated and thatimage_refmatches the accepted image-reference format, setssubmitted_at, and firestriggerSecurityScan()fire-and-forget (setImmediate, never blocks the HTTP response).pending_review → draft(POST .../:id/withdraw) — same role requirement. Clearssubmitted_at. Not available from any other status.pending_review → approved(POST .../:id/approve) — platform-admin-only (isSencaiAdmin(ctx), not just org membership — seeloadAppForAdmin()below). Hard-blocked (400, with ablockingFindingsarray) while the Trivy scan gate reports any opencritical/highfinding, even for an admin trying to force it through.pending_review → rejected(POST .../:id/reject) — platform-admin-only. Requires a non-emptyrejection_reasonin the body.approved → suspended(POST .../:id/suspend) — platform-admin-only. Removes the app from the public catalog; existing deployments provisioned from it are unaffected — this only blocks new Deploy CTAs.
All five status transitions return 400 from any status other than the one they expect — none of them are idempotent re-runs (a second submit() while already pending_review is a 400, not a silent no-op).
loadAppForMember() vs. loadAppForAdmin()
Section titled “loadAppForMember() vs. loadAppForAdmin()”Two distinct access-resolution helpers, deliberately not shared:
loadAppForMember(strapi, ctx, minRole)— used byupdate/delete/submit/withdraw. Grants access to the caller if they hold at leastminRolein the app’spublisher_organisation, or if they’re a platform admin. A foreign/non-member/non-existent app is404, never403— do not leak existence of another publisher’s draft.loadAppForAdmin(strapi, ctx)— used byapprove/reject/suspend. Grants access only toisSencaiAdmin(ctx)— the app’s own publisher-org admin gets403here, not404. There’s nothing to hide from a caller who simply isn’t authorized to review submissions at all; this is the opposite convention fromloadAppForMemberon purpose.approval-request.approve()/.reject()(a separate, generic content-type) only check!user?.id, not role — marketplace review deliberately does not mirror that laxer pattern.
Catalog visibility (find/findOne)
Section titled “Catalog visibility (find/findOne)”GET /api/marketplace-apps is auth: false with no require-auth policy:
- Anonymous caller →
status: 'approved'only, regardless of query params. - Authenticated non-admin →
{ status: 'approved' } UNION { publisher_organisation in caller's own org ids, any status }. - Platform admin → everything.
GET /api/marketplace-apps/:id — an approved app is public; any other status requires viewer+ membership in publisher_organisation or platform admin, else 404.
Mass-assignment hardening
Section titled “Mass-assignment hardening”create() always forces status: 'draft' server-side regardless of the request body. update() only accepts an explicit writable-field allowlist (name/description/image_ref/config_template/icon) — status, publisher_organisation, fee_percent, and every workflow field (submitted_at/approved_at/approved_by/rejection_reason/scan_error) can never be set through PUT; they transition exclusively through the actions above. A platform admin may additionally set fee_percent on create/update.
Trivy security-scan gate
Section titled “Trivy security-scan gate”src/api/marketplace-app/services/scan-runner.ts (runTrivyScan()) is a minimal, in-process wrapper — it shares no code with cloud-connector’s own Trivy integration (vuln-scanner.service.ts), which is a daily cron over already-running cloud instances in a separate microservice/process. The marketplace gate instead runs synchronously inside the same Strapi process that owns vulnerability-report, so results are persisted directly via strapi.documents() with no HTTP round-trip.
trivy image --format json --quiet --no-progress "<image_ref>"imageRefis never shell-interpolated raw — characters that could break out of the quoted argument are stripped before the command is built (defense-in-depth;image_refis already format-validated byvalidateImageRef()atsubmit()time).- Findings are capped at
MAX_FINDINGS_PER_SCAN = 200per scan — protects the DB from a pathological image without ever hiding a critical/high finding in practice. - A technical failure (scanner binary missing, image unreachable, timeout, malformed JSON) returns
{ ok: false, error }rather than throwing, and is persisted tomarketplace-app.scan_error— never torejection_reason, which stays a distinct, always-explicit admin decision. - Grype is intentionally not wired up as a fallback scanner — if Trivy is unavailable, the scan fails technically rather than silently fabricating an empty “clean” result under a different scanner label.
On a successful scan, triggerSecurityScan() (service method on marketplace-app) persists each finding as a vulnerability-report row scoped via the (nullable) marketplace_app relation — never cloud_instance_id — with organisation set to the app’s publisher_organisation, clears any stale scan_error from a previous failed attempt, and emits market.app.scan.completed (risk_level: 'high' if any critical/high finding, else 'low') or market.app.scan.failed (risk_level: 'medium') via the audit publisher.
Scan gate check (getScanGateStatus())
Section titled “Scan gate check (getScanGateStatus())”async getScanGateStatus(appId: number): Promise<{ blocked: boolean; blockingFindings: Array<{ documentId: string; severity: string; cve_id: string | null }>;}>Looks only at current status: 'open' vulnerability-report rows scoped to the app, with severity in ['critical', 'high']. approve() calls this and hard-blocks (400) if blocked: true — the gate clears automatically once every blocking finding is resolved (accepted/fixed/false_positive via vulnerability-report.acceptRisk()/.markFixed()/a manual status update); there’s no separate “unblock” step.
vulnerability-report itself was extended with a nullable marketplace_app relation alongside its pre-existing cloud_instance_id string field. A record scopes to exactly one of the two, never both, never neither — enforced by an application-level XOR check (validateScope()), since Strapi v5 has no cross-field DB CHECK constraint.
Image reference validation
Section titled “Image reference validation”validateImageRef() exists on both sides — the marketplace-app service (server-side source of truth) and composables/useMarketplace.ts (client-side fail-fast pre-check, same regex kept manually in sync):
/^[\w][\w.-]*(:\d+)?(\/[\w][\w.-]*)*:[\w][\w.-]{0,127}$/Accepts a bare name:tag (implicit Docker Hub library/), namespace/name:tag, and registry[:port]/namespace/name:tag — the tag is always mandatory; a reference with no :tag at all is rejected. There was no existing image-reference validator in the repo to reuse when this was written (docker-hub service doesn’t validate this shape).
Wizard prefill contract (config_template)
Section titled “Wizard prefill contract (config_template)”config_template is a free-form JSON column with no schema-level shape — composables/useMarketplace.ts’s buildWizardPreset() is the first (and so far only) consumer, so the keys it recognises are the de-facto publisher contract:
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;}env entries become export KEY="value" lines prepended to cloud_init (backslashes are escaped before quotes — escaping quotes first would let a value ending in an odd number of backslashes break out of the quoted string early); non-identifier-shaped env keys are silently skipped rather than interpolated raw. Every key is optional — a sparse or empty config_template degrades gracefully to the instance wizard’s own defaults. pages/gravity/instances/new.vue’s applyMarketplacePreset() reads ?marketplaceAppId=... (set by the marketplace detail page’s Deploy CTA, alongside ?image=...&provider=hetzner), fetches the app, and pre-fills the existing ADR-001 instance-creation wizard — there is no separate provisioning code path for marketplace deploys.
Revenue share (marketplace-revenue-share)
Section titled “Revenue share (marketplace-revenue-share)”Calculation/evidence only — no money actually moves as a result of anything in this content-type. Real partner payout via Stripe Connect (F4.MARKET.06b) is a separate, not yet implemented task, gated on a business decision about the fee model and payout mechanism (Connect vs. manual invoicing).
marketplace_app relation → marketplace-apppublisher_organisation relation → organisation (denormalized from marketplace_app at creation)customer_organisation relation → organisation (who generated the usage)billing_period_start/end datetime, requiredgross_amount decimal, requiredfee_percent decimal, required — snapshot of marketplace_app.fee_percent AT CREATION TIMEsencai_fee_amount decimal, required — computed server-sidepartner_share_amount decimal, required — computed server-sidepayout_status enum: pending | manual_invoice_required | paid (default: pending)source_event_idempotency_key string, uniqueCalculation is isolated in services/revenue-share-calculator.ts (calculateRevenueShare()), a pure function with no strapi dependency:
sencai_fee_amount = round2(gross_amount * fee_percent / 100)partner_share_amount = round2(gross_amount - sencai_fee_amount)partner_share_amount is derived by subtracting the already-rounded fee from the gross amount, not by an independent second rounding — this guarantees sencai_fee_amount + partner_share_amount === gross_amount exactly to the cent, which rounding both sides independently would not. Rounding is a flat 2-decimal round-half-up — a documented limitation, not currency-aware (no 0-decimal JPY/3-decimal BHD handling yet).
fee_percentis snapshotted once, at record creation, and never re-read. A later admin edit tomarketplace_app.fee_percentcan never mutate an already-created revenue-share record.POST /api/marketplace-revenue-sharesis service-only (X-Service-Secret=BILLING_ADAPTER_SERVICE_SECRET) —billing-adapteris the sole producer, calling this after a marketplace-sourced metering event syncs successfully to Lago. Idempotent onsource_event_idempotency_key— a retried sync for the same event returns the existing record (200,idempotent: true) instead of creating a duplicate.GET /api/marketplace-revenue-shares/:id— org-scoped: platform admin sees everything, a publisher org member (any role) sees only their ownpublisher_organisationrecords,404(never403) for a foreign org.POST /api/marketplace-revenue-shares/:id/mark-paid— platform-admin-only. The only waypayout_statuscan becomepaiduntil F4.MARKET.06b lands — no automated flow ever sets it. Valid frompending/manual_invoice_required;400frompaiditself.sencai-adminexposes a paginated worklist (GET /sencai-admin/marketplace-revenue-shares, optionalpayout_statusfilter) and a mirroredmark-paidaction for manual invoicing — this is the only UI surface for revenue-share records today; there is no publisher-facing revenue dashboard insencai.space-frontend.
Audit events
Section titled “Audit events”All marketplace audit actions use a plain action: string through src/utils/audit-publisher.ts — none of them are (yet) added to @sencai/audit’s closed AuditAction union, the same rationale as several other F4-wave additions (see 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 all publish at risk_level: 'high'; scan.completed is 'high' only when the scan found a critical/high finding, otherwise 'low'.
Frontend surface
Section titled “Frontend surface”pages/gravity/marketplace/index.vue— three-tab catalog (apps/my-submissions/pending-review), a singleGET /api/marketplace-appscall backing all three (the backendfind()response is already the right union per caller, tabs are pure client-side filtering — seefilterApprovedCatalog()/filterMySubmissions()/filterPendingReview()incomposables/useMarketplace.ts).pages/gravity/marketplace/detail/[id].vue— Deploy CTA (approved only), owner actions (submit/withdraw), admin actions (approve/reject/suspend, including ablockingFindingspanel when the scan gate blocks approval).pages/gravity/marketplace/submit.vue— single-page create-then-submit form (POST /api/marketplace-appsfollowed immediately byPOST .../:id/submit); if the submit step fails after a successful create, the app still exists as a draft and the user lands on its detail page, which has its own retry action.composables/useIsPlatformAdmin.tsdecodes the KC JWT to mirror the backend’sisSencaiAdmin(ctx)check for UI gating only — it is never itself an authorization boundary; every admin action is re-enforced server-side byloadAppForAdmin().