Skip to content

FUN-20 Marketplace APIs

Pre-Discussion

1. Introduction

The marketplace is where organizations outside Fundament publish plugins, Fundament reviews them, and users discover them. FUN-11 describes the plugin system and the in-console catalog; this FUN records the decisions behind the APIs of the marketplace itself.

All three APIs are implemented. The marketplace frontend follows the same split: one source tree (marketplace-frontend), built three times — storefront, developer portal, review backoffice — each deployed on its own host with only its own audience’s routes and API URL. The backoffice build rides the same restricted admin host policy as marketplace-admin-api, so the review UI never reaches a storefront visitor’s browser.

2. Three APIs

Deployable Package Service Caller

marketplace-catalog-api

catalog.v1

CatalogService

Anonymous visitor

marketplace-registry-api

registry.v1

PublicationService

Plugin developer

marketplace-admin-api

admin.v1

ReviewService

Fundament reviewer

These are three deployables over four proto packages in one buf module, not three systems. The split axis is who is allowed to call the API: three audiences, three credentials. That is what lets the catalog ship with no credential path at all, and lets the backoffice be restricted to an admin host without the developer surface inheriting the restriction.

marketplace.v1 holds the types more than one package needs, each defined once and imported. Plugin and PluginVersion are deliberately not shared: they carry the same name in registry.v1 and admin.v1 but different fields, because an author and a reviewer need different things.

3. One store

There is a single plugin store: the appstore schema (FUN-11). The three APIs are surfaces over it. There is no marketplace-owned registry and no sync between stores.

A second store would have duplicated the listing tables and introduced a window in which an approved version was live in one place and not the other. Since the definition hash is a consent record (FUN-17), two copies means two chances for that record to disagree.

Isolation therefore comes from database roles rather than from separate stores, matching the existing pattern (fun_authn_api, fun_dcim_api, fun_authz_worker). The catalog gets a SELECT-only role, so the anonymous, internet-facing surface cannot write and cannot reach the tenant schema at all.

Because there is one store, proto field names follow the schema rather than inventing parallel ones — name rather than slug, description_short rather than tagline.

The marketplace has no install RPC. Its "Install" button deep-links to the console, which drives organization-api against the same schema.

4. Identity

A plugin is identified by its id. Its name is unique only within the owning organization, replacing a constraint that was global: first-come-first-served names across every publisher does not fit a marketplace open to external organizations. Two organizations may each publish a cert-manager, so a name alone does not identify a listing and every RPC addresses a plugin by plugin_id.

Cluster-side, PluginInstallation resources are named <organizationName>--<pluginName>, which is why a plugin name may not contain --. FUN-17 and FUN-11 are authoritative on that rule.

All identifiers are UUIDv7, per FUN-6.

5. Review lifecycle

The reviewed unit is a version, not a listing. Each pushed version is reviewed on its own, so a bad second version cannot reach users on the strength of a trusted first one. A listing carries no review state; it goes live when its first version is approved.

One state vocabulary, marketplace.v1.SubmissionStatus, is imported by both surfaces. The developer and the reviewer are looking at the same state, so it does not get two sets of names depending on which surface you ask. It was previously copied into each package with a rule that the copies stay identical, and they had already drifted in their comments.

From To Driven by Effect

DRAFT

registry.v1 CreatePluginVersion

Inserts the definition. No submission row.

DRAFT, CHANGES_REQUESTED, WITHDRAWN

PENDING

registry.v1 SubmitPluginVersion

Opens a submission. Each round is its own row.

PENDING

WITHDRAWN

registry.v1 WithdrawPluginVersion

Closes the open submission without a decision.

PENDING

APPROVED

admin.v1 ApproveSubmission

Sets published, making the listing catalog-visible.

PENDING

CHANGES_REQUESTED

admin.v1 RequestChanges

Closes the round with feedback; the developer may resubmit.

PENDING

REJECTED

admin.v1 RejectSubmission

Terminal.

Every other transition is refused with FAILED_PRECONDITION. APPROVED and REJECTED are terminal on the publisher surface: an approved version is immutable because its hash is a consent record, and a rejected one is resubmitted as a new version rather than revived.

Every decision closes the review round in the same transaction that moves the version’s status, and both writes are guarded — the status write on status = 'pending', the round write on closed IS NULL — so a decision racing a withdrawal (or a second reviewer) fails with FAILED_PRECONDITION rather than half-landing.

6. The contract is normalized

A message carries its own fields, the key others address it by, and foreign keys as bare identifiers — no copied display names, no snapshots of data another service owns. Every identifier handed to a client must be resolvable by an RPC on the service that owns it; where nothing serves one yet, it is listed under Deferred rather than papered over by copying the value into the response.

Composition is not denormalization: FeatureBlock, DocumentationLink and PluginPermission stay nested in the messages that use them rather than getting services of their own. Two of them carry an id, because they are authored rows a publisher edits: UpdatePlugin takes the whole set each time, and one carrying its id is updated in place rather than re-created, so an id the API handed out stays valid across the edit. Without that, every update would hand a client a fresh id for a row it had just read. PluginPermission is the one with no identity at all — it is derived from the pinned manifest at read time, not stored. Categories are a curated vocabulary referenced by id; tags are free-form labels carried as plain strings, because a tag has no identity a client needs to hold.

This is why ReviewService serves its own reads rather than borrowing them. The catalog holds only approved public listings, so a pending submission’s plugin is absent from it; PublicationService authorizes "owns this plugin", which a Fundament reviewer does not; and organization-api’s `GetOrganization is scoped to organization members, which a reviewer is not.

One deliberate exception. registry.v1.PluginVersion.review_feedback originates in a review record, but a developer has no credential for admin.v1 and never will, so an identifier pointing into the backoffice would be unresolvable. The field is the hand-off of the reviewer’s note onto the developer’s surface.

7. Visibility

PUBLIC or RESTRICTED, set together with allowed_organization_ids through UpdatePlugin. CatalogService never returns a restricted listing and exposes no visibility field — returning the flag would leak the listing’s existence. Installability for a permitted organization is resolved in the console.

8. What the schema needed

Most of appstore already fit: the listing fields, visibility, status and published on plugin_definitions, the trust labels and the per-organization name uniqueness all shipped with the catalog. Three things were missing.

A review record. appstore.submissions is one row per review round rather than per version, so resubmitting after CHANGES_REQUESTED leaves the previous round readable instead of overwriting it. It deliberately does not repeat plugin_definitions.status — a second copy of the same state is a second chance for it to drift — and carries no plugin_id or organization_id, both being reachable through plugin_definition_id. closed is separate from reviewed because a withdrawal closes a round with no reviewer, and a partial unique index keeps at most one round open per version while closed rounds accumulate.

An allow-list. appstore.plugin_allowed_organizations backs RESTRICTED visibility. It has no deleted column, a deliberate deviation from the project-wide soft-delete rule: it is a pure association table, replaced wholesale by UpdatePlugin. The distinction is identity — within a full replacement, feature blocks and documentation links are soft-deleted because the API hands out their ids, while tags, categories and the allow-list are edges and are removed outright.

Small additions. plugins.updated backs a field that had nothing to read. plugin_documentation_links gained deleted — it was the only appstore table without one, which put full-replacement semantics in conflict with the soft-delete rule — and position, for a stable render order.

9. Database roles and RLS

marketplace-registry-api gets its own role, fun_marketplace_registry_api. It is not fun_fundament_api: reusing that role would hand the marketplace the whole tenant schema, and the point of the split is that each surface reaches only what it needs.

Its policies gate ownership only, reaching the owning organization through plugin_id. Lifecycle filtering is the queries' job. This is not a simplification for its own sake: under FOR ALL, the USING predicate also gates updates, so a policy carrying deleted IS NULL makes a row stop satisfying its own policy at the moment a statement sets deleted, and soft delete becomes impossible. The policies stay a pure tenancy boundary.

RLS is the tenancy boundary, not the whole authorization story. Every write RPC first checks OpenFGA — can_create_plugin on the organization for CreatePlugin, can_edit/can_delete on the plugin for everything else — so publishing stays an organization-admin action, matching what the model already said and what the superseded PutPluginDefinition enforced. The ownership tuples arrive through the plugins_outbox trigger and the authz worker (FUN-15), so the plugin-object checks retry briefly on denial: pushing a version right after creating the listing is the normal flow, and the tuple syncs asynchronously. Reads stay RLS-scoped only.

USING and WITH CHECK are kept separate so an owner cannot reassign a plugin to another organization by updating organization_id — the same no-transfer rule plugins_update_owner already enforces.

The policies reference in one direction only: definitions reach up to plugins, and plugins reach nothing. The catalog’s policies are mutually referential and had to work around SQLSTATE 42P17; not repeating that is deliberate.

marketplace-admin-api gets fun_marketplace_admin_api. The role is not organization-scoped — a reviewer acts on submissions from every publishing organization — so its policies are USING (true) reads over the listing tables plus a tenant.organizations SELECT covering every organization with a listing, published or not: ListPublishers must resolve a publisher whose first version is still under review, which the catalog’s public-only policy deliberately hides. What the role may write is fenced by column grants instead of policies: on appstore.submissions the decision columns (reviewer_user_id, reviewed, closed, rejection_reason, feedback), on appstore.plugin_definitions only status and published. hash, manifest and image are the consent record FUN-17 makes immutable, so the grant — not just the service’s Go — refuses them, and a round’s identity (which version, who submitted) stays the publisher’s alone. There is no OpenFGA check on this surface: the model has no reviewer concept yet (see Deferred), so "is a Fundament reviewer" is the admin-host restriction plus the credential the service validates — the DCIM token, issued by dcim-authn-api against the staff IDP (dexDcim). Reviewers are Fundament staff, so they authenticate where staff already do; a customer’s console token or a plugin token is refused by issuer alone, both being minted by fundament-authn-api.

One hole this work does not close. plugins_tags_all_api and categories_plugins_all_api are FOR ALL TO fun_fundament_api USING (true) with no ownership predicate, so any authenticated session can attach a tag or category to any organization’s plugin. The registry’s own policies are ownership-scoped from the start; tightening the fun_fundament_api ones is follow-up work.

10. Implementation notes

Only the parts that are decisions rather than mechanics.

The manifest hash is reused, never reimplemented. CreatePluginVersion parses and hashes with pluginruntime.ParseDefinition and HashManifest. The plugin-controller recomputes that hash over the same bytes to verify an install’s consent pin (FUN-19), so a second implementation is a second chance for the two to disagree — precisely the failure the pin exists to prevent. CreatePluginVersionRequest has no image field for the same reason: the image is read out of the manifest, so it cannot drift from the bytes that get hashed.

Organization scoping fails closed. The organization is pushed into a session GUC on connection acquire and reset on release, destroying the connection if the reset fails rather than returning a poisoned one to the pool. This is organization-api’s mechanism, reused rather than reinvented. An unset GUC is not an error, because public and user-scoped endpoints share a pool; that is safe only because every policy compares against authn.current_organization_id(), and NULL never matches. Policies must never be written to grant on an absent GUC.

NOT_FOUND is the default denial. Every RPC addresses rows by id and RLS scopes the role to the caller’s organization, so an id the caller does not own resolves to nothing. PERMISSION_DENIED is reserved for the denials the service can actually observe: a failed organization-membership check, an OpenFGA refusal on a write, and SQLSTATE 42501 from a policy refusing a write (FUN-12). The OpenFGA check runs after the RLS-scoped lookup, so another organization’s listing still reads as NOT_FOUND rather than confirming its existence.

A name containing -- is rejected before the database sees it. protovalidate’s DNS-1123 rule permits it but plugins_ck_name does not, and a check violation surfacing as INTERNAL would be a worse answer than an accurate INVALID_ARGUMENT.

11. Superseding organization-api’s PluginService

organization-api’s `PluginService.PutPluginDefinition writes appstore.plugin_definitions today, and registry.v1.CreatePluginVersion writes the same table with different semantics: Put has a replace flag and no review state, while the registry lands everything in DRAFT and expects review to move it. Two writers to one table with different rules is not a steady state.

registry.v1.PublicationService is the publishing surface, so the whole of PluginService is deprecated once these stacks merge, moving in follow-ups:

organization-api RPC Moves to Consumer to cut over

PutPluginDefinition

registry.v1 CreatePluginVersion

functl plugin publish (done; it replaced plugins/cmd/plugin-publish)

GetPluginDefinition

registry.v1, as an unauthenticated procedure

plugin-controller/pkg/defclient

ListPlugins, GetPluginDetail, ListPluginDefinitions

registry.v1

console-frontend (five call sites)

Two wrinkles are worth naming before those MRs rather than discovering inside them.

GetPluginDefinition is anonymous and cross-cluster: plugin-controller calls it on every reconcile, from the plugin cluster (FUN-19). Landing it on registry.v1 gives the developer-authenticated deployable one endpoint that takes no credential, which cuts against the split axis in Three APIs. It cannot go to catalog.v1 instead, because the controller must resolve definitions for restricted listings too. However it lands, it must stay reachable from the plugin cluster and must not gain a status gate — hiding an approved definition from the controller breaks reconciliation.

The console reads are the authenticated in-product catalog (FUN-11), which catalog.v1 cannot serve because it is anonymous and public-only. Moving them to registry.v1 widens that service past "plugins the caller’s organization owns" — the console browses plugins it may install, not ones it publishes. That is a contract change, or a fourth surface, and should be settled before the MR.

12. Deferred

  • The marketplace-frontend application.

  • Idempotency keys on CreatePlugin and CreatePluginVersion. FUN-15 is opt-in per procedure, and tenant.idempotency_keys would give the marketplace role access to tenant, which One store avoids. Both RPCs are naturally idempotent on their unique constraints meanwhile.

  • A publisher role. FUN-7 has only admin and viewer, so any member of the owning organization can publish — no worse than the status quo, but not the end state.

  • Republishing a version. FUN-19 describes soft-delete-then-publish; CreatePluginVersion is create-only.

  • functl plugin publish exists and is the publisher (it absorbed plugins/cmd/plugin-publish, taking its auth from functl’s API-key session). Republishing a version through it stays impossible by design — see the republish bullet below.

  • Sideloading a build onto a cluster. It creates a PluginInstallation, so it belongs to organization-api.

  • Developer login via the Console, for the publishing surface.

  • Pagination on ListPlugins.

  • Who sets the editorial fields. PluginLabel and featured are curation decisions; no API writes them and the curation surface is undesigned.

  • A lookup over tenant.users. Nothing resolves an arbitrary user identifier today, so the backoffice renders identifiers where a submitter’s name and email belong.

  • A reviewer relation in the OpenFGA model. Reviewers authenticate against dcim.users (the staff store, via dcim-authn-api) — that settles which store reviewer_user_id points into, and why it is namespaced apart from submitter_user_id — but the model still has no reviewer concept, so any authenticated staff member can decide a submission.

  • An organization picker for allowed_organization_ids. The allow-list is writable but not discoverable — a developer names organizations they are not a member of, and nothing lets them browse those.

13. References