Skip to content

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).

name string, required
slug uid (from name)
description text
image_ref string, required — container image reference
config_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 fee
rejection_reason text — set only by the reject action, always an admin decision
scan_error text — set only on a technical scan failure, never an admin decision
submitted_at datetime
approved_at datetime
approved_by relation → user

x-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.

submit() approve()
draft ─────────────► pending_review ─────────────► approved ──► suspend() ──► suspended
▲ │ reject()
└──── withdraw() ────────┘ └─────────────► rejected
  • draft → pending_review (POST /api/marketplace-apps/:id/submit) — requires admin+ role in publisher_organisation (or platform admin). Validates all four of name/description/image_ref/config_template are populated and that image_ref matches the accepted image-reference format, sets submitted_at, and fires triggerSecurityScan() fire-and-forget (setImmediate, never blocks the HTTP response).
  • pending_review → draft (POST .../:id/withdraw) — same role requirement. Clears submitted_at. Not available from any other status.
  • pending_review → approved (POST .../:id/approve) — platform-admin-only (isSencaiAdmin(ctx), not just org membership — see loadAppForAdmin() below). Hard-blocked (400, with a blockingFindings array) while the Trivy scan gate reports any open critical/high finding, even for an admin trying to force it through.
  • pending_review → rejected (POST .../:id/reject) — platform-admin-only. Requires a non-empty rejection_reason in 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).

Two distinct access-resolution helpers, deliberately not shared:

  • loadAppForMember(strapi, ctx, minRole) — used by update/delete/submit/withdraw. Grants access to the caller if they hold at least minRole in the app’s publisher_organisation, or if they’re a platform admin. A foreign/non-member/non-existent app is 404, never 403 — do not leak existence of another publisher’s draft.
  • loadAppForAdmin(strapi, ctx) — used by approve/reject/suspend. Grants access only to isSencaiAdmin(ctx) — the app’s own publisher-org admin gets 403 here, not 404. There’s nothing to hide from a caller who simply isn’t authorized to review submissions at all; this is the opposite convention from loadAppForMember on 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.

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.

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.

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.

Terminal window
trivy image --format json --quiet --no-progress "<image_ref>"
  • imageRef is never shell-interpolated raw — characters that could break out of the quoted argument are stripped before the command is built (defense-in-depth; image_ref is already format-validated by validateImageRef() at submit() time).
  • Findings are capped at MAX_FINDINGS_PER_SCAN = 200 per 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 to marketplace-app.scan_errornever to rejection_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.

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.

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).

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.

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-app
publisher_organisation relation → organisation (denormalized from marketplace_app at creation)
customer_organisation relation → organisation (who generated the usage)
billing_period_start/end datetime, required
gross_amount decimal, required
fee_percent decimal, required — snapshot of marketplace_app.fee_percent AT CREATION TIME
sencai_fee_amount decimal, required — computed server-side
partner_share_amount decimal, required — computed server-side
payout_status enum: pending | manual_invoice_required | paid (default: pending)
source_event_idempotency_key string, unique

Calculation 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_percent is snapshotted once, at record creation, and never re-read. A later admin edit to marketplace_app.fee_percent can never mutate an already-created revenue-share record.
  • POST /api/marketplace-revenue-shares is service-only (X-Service-Secret = BILLING_ADAPTER_SERVICE_SECRET) — billing-adapter is the sole producer, calling this after a marketplace-sourced metering event syncs successfully to Lago. Idempotent on source_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 own publisher_organisation records, 404 (never 403) for a foreign org.
  • POST /api/marketplace-revenue-shares/:id/mark-paid — platform-admin-only. The only way payout_status can become paid until F4.MARKET.06b lands — no automated flow ever sets it. Valid from pending/manual_invoice_required; 400 from paid itself.
  • sencai-admin exposes a paginated worklist (GET /sencai-admin/marketplace-revenue-shares, optional payout_status filter) and a mirrored mark-paid action for manual invoicing — this is the only UI surface for revenue-share records today; there is no publisher-facing revenue dashboard in sencai.space-frontend.

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'.

  • pages/gravity/marketplace/index.vue — three-tab catalog (apps/my-submissions/pending-review), a single GET /api/marketplace-apps call backing all three (the backend find() response is already the right union per caller, tabs are pure client-side filtering — see filterApprovedCatalog()/filterMySubmissions()/filterPendingReview() in composables/useMarketplace.ts).
  • pages/gravity/marketplace/detail/[id].vue — Deploy CTA (approved only), owner actions (submit/withdraw), admin actions (approve/reject/suspend, including a blockingFindings panel when the scan gate blocks approval).
  • pages/gravity/marketplace/submit.vue — single-page create-then-submit form (POST /api/marketplace-apps followed immediately by POST .../: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.ts decodes the KC JWT to mirror the backend’s isSencaiAdmin(ctx) check for UI gating only — it is never itself an authorization boundary; every admin action is re-enforced server-side by loadAppForAdmin().