Skip to content

feat: marketplace catalog service - #395

Merged
ChielTimmermans merged 3 commits into
masterfrom
split/3-marketplace-catalog-service
Sep 1, 2026
Merged

feat: marketplace catalog service#395
ChielTimmermans merged 3 commits into
masterfrom
split/3-marketplace-catalog-service

Conversation

@ChielTimmermans

Copy link
Copy Markdown
Member

No description provided.

@ChielTimmermans
ChielTimmermans force-pushed the split/3-marketplace-catalog-service branch from 2fb24cb to 1250025 Compare August 25, 2026 13:51
@ChielTimmermans ChielTimmermans changed the title Split/3 marketplace catalog service feat: marketplace catalog service Aug 27, 2026
@ChielTimmermans
ChielTimmermans force-pushed the split/3-marketplace-catalog-service branch 5 times, most recently from 45500b2 to 9de068d Compare August 27, 2026 13:25
@ChielTimmermans
ChielTimmermans marked this pull request as ready for review August 27, 2026 20:27
@ChielTimmermans
ChielTimmermans force-pushed the split/3-marketplace-catalog-service branch from 9de068d to 136373b Compare August 28, 2026 14:43
@github-actions

github-actions Bot commented Aug 28, 2026

Copy link
Copy Markdown

PR Environment - Torn Down

The environment for this PR has been removed.

@ChielTimmermans
ChielTimmermans force-pushed the split/3-marketplace-catalog-service branch 2 times, most recently from 974f821 to afcfc7a Compare August 28, 2026 15:24
Implements all five catalog.v1.CatalogService RPCs against the schema from
stack 2/3, and swaps the health-only main.go from stack 1/3 for the real
one: database pool, schema version assertion and the Connect handler.

The service is anonymous and read-only. It authenticates nothing and holds
no JWT secret; every access decision is an RLS policy evaluated under the
fun_marketplace_catalog_api role, so the tests exercise the real role
rather than a superuser.

PluginVersionListByPluginID and PluginLatestPublishedDefinition join through
appstore.plugins so the plugin's own policy gates version history and
manifest bytes. Without that join a restricted or soft-deleted listing keeps
serving its versions, publish dates, definition hashes, release notes and
manifest to anyone holding the id. The check cannot live in
plugin_definitions' policy instead: plugins' policy already references
plugin_definitions, and mutual reference makes Postgres reject every query
against either table with SQLSTATE 42P17. Any future query that reads
plugin_definitions without going through plugins reopens this; rls_test.go
pins the behaviour.

The ILIKE search is deliberate rather than full-text: the catalog is small
and unpaginated, and FTS would cost a tsvector column plus a trigger. Search
terms are escaped before binding so % and _ match literally rather than as
wildcards.

PluginSort carries no FEATURED member: appstore.plugins has no featured
column, so the ordering it named could not be expressed and UNSPECIFIED now
sorts as NAME. ListPluginsRequest.sort reclaims field 30 from the removed
featured_only rather than reserving it; catalog.v1 has no deployed consumer.
@ChielTimmermans
ChielTimmermans force-pushed the split/3-marketplace-catalog-service branch from afcfc7a to 2141908 Compare August 28, 2026 15:27
jos-
jos- previously approved these changes Aug 31, 2026

@jos- jos- left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, but 3 small comments from Claude, see below.

Comment thread marketplace-api/pkg/catalog/manifest.go Outdated
Comment thread marketplace-api/cmd/fun-marketplace-catalog-api/main.go Outdated
Comment thread marketplace-api/pkg/db/plugin.sql Outdated
- /readyz wrote the raw pgx Ping error into the response body. The probe is on
  the outer mux, which the Ingress routes publicly, and this service has no JWT
  layer in front of it, so host, port, database and role were readable by any
  anonymous caller while the database was down. The body is now a fixed
  "database unavailable" and the detail goes to the log. The handler moved into
  readyz() so a test can pin it.

- accessLabel treated the RBAC verbs that grant privileges without touching the
  object as read-only. bind, escalate, impersonate, approve, sign and use now
  read as "Read and write", so a plugin that can escalate a ClusterRole is not
  shown to the storefront as read-only.

- GetPlugin returned Internal when a manifest failed to parse, losing the whole
  detail page over one field from a newer plugin-sdk. It now logs and serves the
  column-backed listing with capabilities and permissions empty.

- latest_version_id was ordered by published DESC in two identical subqueries;
  only PluginList got the id DESC tiebreaker, so on a tie the grid card and the
  detail page could name different versions as latest. PluginGetByID matches.

- Pins the soft-deleted half of the manifest guard. plugin_definitions' policy
  checks only its own deleted and published, so a soft-deleted plugin's rows stay
  readable at the table level and only the JOIN through appstore.plugins holds
  the manifest back; that was tested for RESTRICTED listings but not deleted
  ones.

- Migrates the h2c handler to net/http's Server.Protocols and drops the
  x/net/http2 imports. The Upgrade: h2c handshake goes away with it, which
  nothing here uses — the ingress speaks prior-knowledge h2c.

- Marks the four organization.v1.PluginService RPCs the catalog replaces as
  deprecated: ListPlugins, GetPluginDetail, ListPluginDefinitions and
  GetPluginDefinition. GetPluginDefinition is in isPublicEndpoint and filters
  neither visibility nor published, so it serves draft and RESTRICTED manifests
  anonymously; its caller plugin-controller has no user JWT, so the move needs a
  credentialed path.

- Enables marketplaceCatalogApi in values-local.yaml and copies
  plugin-sdk/pluginruntime in both Dockerfiles, without which the image does not
  build.
@ChielTimmermans
ChielTimmermans force-pushed the split/3-marketplace-catalog-service branch 3 times, most recently from fea7515 to 4f54f53 Compare September 1, 2026 06:44
catalog.v1 exposed definition_hash but no way to fetch the bytes it hashes, on
the premise that manifests were organization-api's business. That premise died
with the previous commit: organization.v1's GetPluginDefinition is anonymous,
filters neither visibility nor published, and is now deprecated — so the bytes
needed a home.

GetPluginDefinition returns the stored manifest verbatim plus its hash, and
takes either key: plugin_id, which the storefront holds having just listed the
plugin, or the (organization_name, plugin_name) pair a PluginInstallation
carries. plugin-controller reads the CR, never ListPlugins, so without the name
form it could not call this at all. Both lookups join through appstore.plugins,
and the name one through tenant.organizations as well, so a RESTRICTED or
taken-down listing is unreachable either way; unknown version, unknown publisher
and hidden listing all answer NotFound, as GetPlugin already does.

It serves unpublished versions deliberately. plugin-controller installs
definitions that were never published — PutPluginDefinition has never set
published — and it holds no user JWT, so migration 036 relaxes the three catalog
policies: plugin_definitions_select_catalog to `deleted IS NULL`,
plugins_select_catalog to `deleted IS NULL AND visibility = 'public'`, and
organizations_select_catalog to drop its published requirement, without which a
publisher owning only drafts could not be resolved by name.

The consequence is that the storefront's own rules leave RLS and become explicit
in the queries: an EXISTS filter in PluginList, PluginGetByID and PublisherList,
and published IS NOT NULL in PluginVersionListByPluginID and both
latest_version_id subqueries. A bug in one of those six now shows an unpublished
listing, where the policies used to catch it regardless of the query. Each
carries a comment saying why it is there.

One note for whoever picks this up: plugins_select_catalog no longer references
plugin_definitions, so the mutual-reference 42P17 that forced the join-through-
plugins pattern no longer applies.

plugin-controller can move onto this by swapping defclient's client and URL. A
listing its organization owns but has not made public stays unreachable — the
catalog is anonymous and cannot tell who is asking.
@ChielTimmermans
ChielTimmermans force-pushed the split/3-marketplace-catalog-service branch from 4f54f53 to 9cb0265 Compare September 1, 2026 07:26
@ChielTimmermans
ChielTimmermans merged commit f4af24f into master Sep 1, 2026
43 checks passed
@ChielTimmermans
ChielTimmermans deleted the split/3-marketplace-catalog-service branch September 1, 2026 09:07
ChielTimmermans added a commit that referenced this pull request Sep 1, 2026
Moves every reader of organization.v1.PluginService onto catalog.v1, which is
what #395 deprecated them for. organization-api keeps serving the RPCs; nothing
is deleted here, so the two can be deployed in either order.

plugin-controller fetches definitions from marketplace-catalog-api by
(organization, plugin) name — the spelling a PluginInstallation carries.
ORGANIZATION_API_URL becomes MARKETPLACE_CATALOG_API_URL, defaulting to
externalUrls.marketplace. That rename reaches two deployments: the chart's
controller, and the shoot-side one cluster-worker provisions, whose
PLUGIN_ORGANIZATION_API_URL becomes PLUGIN_MARKETPLACE_CATALOG_API_URL — a
shoot controller would otherwise come up without the URL it now requires. The
local sandbox relay (`just plugins sandbox-catalog`) bridges the catalog's
NodePort instead of organization-api's. FUN-19's cross-cluster constraint is
unchanged: the controller runs outside the management cluster and needs an
externally-routable URL.

The console reads the storefront from catalog.v1 over its own unauthenticated
transport: no cookie, no Fun-Organization header. catalog.v1 returns ids where
organization.v1 returned nested names, so the plugins page resolves publisher
and category names once through ListPublishers and ListCategories rather than
per plugin, and the detail page maps the response into a view model so its
template is unchanged apart from tags, which are plain labels now. The install
modal's version picker reads ListPluginVersions, which returns only published
versions — organization.v1's ListPluginDefinitions returned drafts too, so a
draft is no longer offered as something to install.

ListPresets has no catalog equivalent and stays on organization.v1; the demo
transport answers that and the catalog, and no longer stubs the browse RPCs the
console stopped calling.

Adds externalUrls.marketplace per environment, the console's marketplaceApiUrl
config key, and that origin to CONNECT_SRC — without which the browser's CSP
blocks every catalog call.
ChielTimmermans added a commit that referenced this pull request Sep 1, 2026
Moves every reader of organization.v1.PluginService onto catalog.v1, which is
what #395 deprecated them for. organization-api keeps serving the RPCs; nothing
is deleted here, so the two can be deployed in either order.

plugin-controller fetches definitions from marketplace-catalog-api by
(organization, plugin) name — the spelling a PluginInstallation carries.
ORGANIZATION_API_URL becomes MARKETPLACE_CATALOG_API_URL, defaulting to
externalUrls.marketplace. That rename reaches two deployments: the chart's
controller, and the shoot-side one cluster-worker provisions, whose
PLUGIN_ORGANIZATION_API_URL becomes PLUGIN_MARKETPLACE_CATALOG_API_URL — a
shoot controller would otherwise come up without the URL it now requires. The
local sandbox relay (`just plugins sandbox-catalog`) bridges the catalog's
NodePort instead of organization-api's. FUN-19's cross-cluster constraint is
unchanged: the controller runs outside the management cluster and needs an
externally-routable URL.

The console reads the storefront from catalog.v1 over its own unauthenticated
transport: no cookie, no Fun-Organization header. catalog.v1 returns ids where
organization.v1 returned nested names, so the plugins page resolves publisher
and category names once through ListPublishers and ListCategories rather than
per plugin, and the detail page maps the response into a view model so its
template is unchanged apart from tags, which are plain labels now. The install
modal's version picker reads ListPluginVersions, which returns only published
versions — organization.v1's ListPluginDefinitions returned drafts too, so a
draft is no longer offered as something to install.

ListPresets has no catalog equivalent and stays on organization.v1; the demo
transport answers that and the catalog, and no longer stubs the browse RPCs the
console stopped calling.

Adds externalUrls.marketplace per environment, the console's marketplaceApiUrl
config key, and that origin to CONNECT_SRC — without which the browser's CSP
blocks every catalog call.
ChielTimmermans added a commit that referenced this pull request Sep 1, 2026
Moves every reader of organization.v1.PluginService onto catalog.v1, which is
what #395 deprecated them for. organization-api keeps serving the RPCs; nothing
is deleted here, so the two can be deployed in either order.

plugin-controller fetches definitions from marketplace-catalog-api by
(organization, plugin) name — the spelling a PluginInstallation carries.
ORGANIZATION_API_URL becomes MARKETPLACE_CATALOG_API_URL, defaulting to
externalUrls.marketplace. That rename reaches two deployments: the chart's
controller, and the shoot-side one cluster-worker provisions, whose
PLUGIN_ORGANIZATION_API_URL becomes PLUGIN_MARKETPLACE_CATALOG_API_URL — a
shoot controller would otherwise come up without the URL it now requires. The
local sandbox relay (`just plugins sandbox-catalog`) bridges the catalog's
NodePort instead of organization-api's. FUN-19's cross-cluster constraint is
unchanged: the controller runs outside the management cluster and needs an
externally-routable URL.

The console reads the storefront from catalog.v1 over its own unauthenticated
transport: no cookie, no Fun-Organization header. catalog.v1 returns ids where
organization.v1 returned nested names, so the plugins page resolves publisher
and category names once through ListPublishers and ListCategories rather than
per plugin, and the detail page maps the response into a view model so its
template is unchanged apart from tags, which are plain labels now. The install
modal's version picker reads ListPluginVersions, which returns only published
versions — organization.v1's ListPluginDefinitions returned drafts too, so a
draft is no longer offered as something to install.

ListPresets has no catalog equivalent and stays on organization.v1; the demo
transport answers that and the catalog, and no longer stubs the browse RPCs the
console stopped calling.

Adds externalUrls.marketplace per environment, the console's marketplaceApiUrl
config key, and that origin to CONNECT_SRC — without which the browser's CSP
blocks every catalog call.
ChielTimmermans added a commit that referenced this pull request Sep 1, 2026
Moves the storefront readers of organization.v1.PluginService onto catalog.v1,
which is what #395 deprecated them for. organization-api keeps serving the RPCs;
nothing is deleted here, so the two can be deployed in either order.

plugin-controller fetches definitions from marketplace-catalog-api by
(organization, plugin) name — the spelling a PluginInstallation carries.
ORGANIZATION_API_URL becomes MARKETPLACE_CATALOG_API_URL, defaulting to the
in-cluster marketplace-catalog-api Service as the old value did. That rename
reaches two deployments: the chart's controller, and the shoot-side one
cluster-worker provisions, whose PLUGIN_ORGANIZATION_API_URL becomes
PLUGIN_MARKETPLACE_CATALOG_API_URL — a shoot controller would otherwise come up
without the URL it now requires. Setting the old pluginController.organizationApiUrl
key now fails the render rather than silently falling back to the default. The
local sandbox relay (`just plugins sandbox-catalog`) bridges the catalog's
NodePort instead of organization-api's.

The console's plugins page and plugin detail page read catalog.v1 over their own
unauthenticated transport: no cookie, no Fun-Organization header. catalog.v1
returns ids where organization.v1 returned nested names, so publisher and
category names are resolved once through ListPublishers and ListCategories
rather than per plugin, and the detail page maps the response into a view model
so its template is unchanged apart from tags, which are plain labels now. The
install modal's version picker reads ListPluginVersions, which returns only
published versions — organization.v1's ListPluginDefinitions returned drafts
too, so a draft is no longer offered as something to install.

Still on organization.v1, deliberately: ListPresets, which has no catalog
equivalent, and the three cluster-side views (cluster-plugins, cluster-details,
shared-plugins-form) that list plugins to name what is installed — the catalog
only returns PUBLIC listings, so moving them would hide an organization's own
private plugins from its clusters.

Adds externalUrls.marketplace per environment, the console's marketplaceApiUrl
config key, and that origin to CONNECT_SRC — without which the browser's CSP
blocks every catalog call.

Regenerates db/fundament.sql from the model: #395 relaxed the three catalog
policies in fundament.dbm and in migration 036 but left the schema of record
carrying the old published-only predicates, which the next generated migration
would have reverted.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants