Marketplace & extensions

The marketplace turns the platform from a product into an ecosystem: anyone can package models, views, taxonomies, delivery channels, HTTP integrations, field presets, automations and translations into an extension, publish it to a catalogue, and let other tenants install it into their own account with one click.

This document is the architecture contract. Data foundation: migration platform_marketplace_* tables. Logic: `src/platform/marketplace/types.ts` and `src/platform/marketplace/manifest.ts`, tested in src/platform/__tests__/marketplace-manifest.test.ts.

Non-negotiable principles

  1. Declarative only. An extension is a JSON manifest. The platform interprets contributions; it never evaluates vendor code — no eval, no server isolates, no vendor scripts in the admin bundle. This keeps review tractable and removes the largest class of supply-chain risk.
  2. Namespaced or rejected. Every contributed key must be prefixed with the publisher slug (acme_review, ext.acme.review.title). Two extensions can therefore never collide on a model, view, channel or message key.
  3. Tenant-owned artefacts. Contributions are written into the installing account with account_id stamped, under the tenant's existing RLS. Nothing lands in a shared namespace.
  4. Scopes, not trust. An extension declares API scopes in the same <model>:<action> grammar as an API token. The install screen shows them, the installation row stores the granted set, and runtime never widens it.
  5. Reversible. Install is a plan of discrete steps; uninstall replays that plan in reverse. Upgrades diff two plans and flag removals and new scopes as breaking.
  6. Immutable releases. A published version's manifest, checksum and review verdict are never edited; a change is a new semantic version.

Extension kinds

KindWhat it contributesTypical author
schema-packModels, views, taxonomies, demo structureSolution partner
integrationHTTP call templates bound to platform eventsSaaS vendor
field-presetPreconfigured field kind + variant + defaultsAgency
automationDeclarative hooks (event → condition → action)Ops team
bundleAny combination of the abovePartner

Manifest contract

{
  "key": "acme.review-kit",
  "publisher": "acme",
  "name": "Review kit",
  "version": "1.2.0",
  "kind": "bundle",
  "summary": "Product reviews with moderation and Slack alerts.",
  "minPlatformVersion": "0.60.0",
  "requestedScopes": ["acme_review:read", "acme_review:update"],
  "settings": [
    { "key": "slackWebhook", "label": "Slack webhook", "kind": "secret", "required": true }
  ],
  "contributions": {
    "schemas": [{ "key": "acme_review", "kind": "collection", "fields": [] }],
    "views": [{ "key": "acme_review_board" }],
    "taxonomies": [{ "kind": "category", "value": "acme_reviews" }],
    "channels": [{ "key": "acme_widget", "kind": "web", "name": "Review widget" }],
    "fieldPresets": [{ "key": "acme_star_rating", "baseKind": "rating", "variant": "stars" }],
    "integrations": [
      {
        "key": "acme_slack",
        "events": ["entry.published"],
        "method": "POST",
        "url": "https://hooks.slack.com/services/...",
        "headers": { "Authorization": "Bearer {{settings.slackWebhook}}" },
        "bodyTemplate": { "text": "New review: {{entry.title}}" },
        "signWithSetting": "slackWebhook"
      }
    ],
    "hooks": [
      {
        "key": "acme_on_publish",
        "event": "entry.published",
        "action": { "kind": "call-integration", "target": "acme_slack" }
      }
    ],
    "messages": { "en": { "ext.acme.review.title": "Review" } }
  }
}

Validation rules enforced by validateManifest (errors block publish and install, warnings are studio hints):

  • key must be publisher.extension and start with the declared publisher slug; version must be semantic.
  • At least one contribution or setting; contribution keys namespaced with the publisher prefix.
  • Integration URLs must be absolute https; Authorization / x-api-key headers must reference a declared secret setting instead of a literal credential; HMAC signing settings must exist.
  • Hooks may only target integrations the same manifest declares, and only the allow-listed action kinds.
  • Requested scopes are parsed with the API scope grammar (src/platform/api/scopes.ts); unknown actions are errors.
  • Message keys must live under ext.<publisher>.; en is the source locale.

manifestChecksum produces an order-independent digest so republishing an unchanged manifest is detectable, and planInstall / planUpgrade produce the consent screens.

Data model

platform_accounts
  └── platform_marketplace_publishers      (author identity, verified flag)
        └── platform_marketplace_listings   (the product: kind, pricing, visibility, status)
              └── platform_marketplace_versions      (immutable manifest + review verdict)
                    └── platform_marketplace_installations  (per account, per environment)
                          └── platform_marketplace_install_log (immutable install/upgrade/remove trail)
platform_marketplace_entitlements  (paid access: plan, seats, expiry, source)
platform_marketplace_reviews       (1-5 rating, one per account per listing)

Access rules (RLS):

  • Anonymous visitors read only published + public listings, their published versions and published reviews — that is the public catalogue.
  • Drafts, in-review versions and private listings are visible only to accounts with access to the publisher account.
  • Installations, install log and entitlements are visible only inside the installing account.
  • All writes go through the platform's server layer; direct Data API writes are denied, and versions and the install log are trigger-protected against mutation.

Lifecycle

draft ──submit──> in_review ──approve──> published ──deprecate──> deprecated
                     │
                     └──changes_requested / rejected──> draft

A listing may only be published when it has at least one approved version whose manifest passes validateManifest with zero errors. Deprecation hides the listing from browsing but keeps existing installs working.

Install pipeline

  1. Resolve — listing + latest published version for the tenant's plan and entitlement.
  2. Consent — render planInstall steps plus requestedScopes; the tenant explicitly grants.
  3. Configure — collect settings; secrets are stored hashed/encrypted server-side, never returned by any API.
  4. Apply — contributions written inside one transaction, stamped with account_id and the extension key: schemas through the Schema Compiler, views, taxonomy terms, channels, integrations as scoped webhook subscriptions, hooks as declarative automations, messages as UI translation overrides.
  5. Record — platform_marketplace_installations row + immutable platform_marketplace_install_log entry + platform audit event.
  6. Upgrade / uninstall — planUpgrade diffs manifests; removals and new scopes require re-consent. Uninstall removes only artefacts tagged with the extension key, and destructive model drops require the same confirmation as a manual schema drop.

Environments are respected: an install may target a single environment (for example development) and be promoted through the existing promotion chain.

Monetisation (prepared, not yet charging)

pricing_model (free / one_time / subscription), price_cents, currency and billing_period live on the listing; platform_marketplace_entitlements records who may install a paid extension, with plan, seats, expires_at and source (manual, stripe, partner). The installer consults entitlements only — swapping in a payment provider later means writing entitlement rows, not touching the install pipeline. Payouts and invoicing are deliberately out of scope for this iteration.

Review and trust

  • Automated gate: validateManifest zero errors, checksum recorded, scope inventory diffed against the previous approved version.
  • Human gate: platform reviewer sets review_status with a note; changes_requested returns the version to the author.
  • Trust signals in the catalogue: verified publisher badge, install count, rating average, last-updated date, requested-scope summary.

Delivery surfaces

  • Public catalogue pages (browse, listing detail with manifest and scope disclosure) — SSR, SEO-indexed.
  • Admin: /admin/marketplace (browse + installed) and /admin/marketplace/studio (publisher studio: listings, versions, manifest validation, submit for review).
  • Machine access: catalogue exposed read-only through the public REST surface and the Management MCP server, so agents can discover and propose extensions.

Deliberate limits

No vendor code execution, no custom React components, no arbitrary SQL, no direct database access for extensions, no cross-tenant data reads. Anything an extension cannot express declaratively is a signal that the platform is missing a primitive — the fix is a new declarative capability, not an escape hatch.