Files
kandev/apps/backend/internal/plugins/provider.go
T
João Salavisa bc2f310e73 feat(plugins): add plugin marketplace with curated registry and updates (#1798)
* feat(plugins): add plugin marketplace with curated registry and updates

Discover and install plugins from inside kandev instead of only by URL/upload.

- Backend marketplace subpackage: SQLite-backed sources, catalog fetch/merge
  (dedup, install_state, degraded-source handling), and the /api/plugins/
  marketplace[/sources] API. Install reuses the existing install-by-URL pipeline.
- Frontend: Settings > Plugins gains a Browse tab (search/category/star sort),
  source management, one-click install, and an Update button on the Installed
  tab when the catalog has a newer version. Plugins may ship an icon (manifest
  `icon` -> catalog icon_url) with a letter-tile fallback.
- Registry infra: plugin-registry/ (curated plugins.yaml + schema.json) and two
  GitHub Actions (zero-dependency Node build-index.mjs -> index.json on Pages,
  daily star refresh). Ships empty at launch; the empty catalog is valid.
- Spec: docs/specs/plugins/marketplace.md. No download telemetry; stars only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(plugins): address marketplace PR review feedback

- CodeQL: stub fetch routers keyed off a path segment, not a hostname substring.
- Nullable stars end to end: a failed star lookup stays unknown (not 0) and
  sorts last, instead of decoding null -> 0 and corrupting ranking.
- Fetch marketplace sources concurrently and dedupe identical in-flight
  downloads (singleflight), so a slow source can't serialize the Browse tab.
- Register /marketplace routes before the /:id wildcard (gin tree ordering).
- Duplicate source URL returns 409 ErrDuplicateSource, not a raw SQL error.
- Browse: filter category client-side so the category dropdown never collapses.
- marketplaceInstall swallows after toasting (no unhandled promise rejection).
- Source badge keys off source_id, not a name substring.
- build-index: read block-sequence manifest categories, not just inline.
- Registry index workflow validates on pull_request; Pages deploy gated to
  push/schedule/dispatch. Spec: align package_sha256 (advisory in v1) + sort enum.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(plugins): guard catalog repo_url against javascript: href injection

repo_url comes from an untrusted index.json, so a malicious/compromised source
could set a javascript: URL that runs in the operator's session when the Repo
link is clicked (rel/target don't block it). Only render the anchor for http(s)
schemes. Adds regression tests for the scheme guard and null-star rendering.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(plugins): require https for remote sources; test oversized download

- Reject remote plain-http marketplace sources (MITM catalog-poisoning risk);
  loopback http stays allowed for local dev/e2e fixtures.
- Add the missing oversized-response assertion to TestDownloadRejectsNon200AndOversized
  so the maxIndexBytes memory bound has coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(plugins): detach source download from singleflight leader's context

Two concurrent Browse opens after a cache Refresh share one singleflight
download; if the leader's request context is canceled (it navigates away), the
waiters must not inherit that cancellation. Use context.WithoutCancel so values
are preserved but the leader can't cancel everyone; the client's 20s timeout
still bounds the download.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 17:10:53 +01:00

108 lines
4.2 KiB
Go

package plugins
import (
"fmt"
"os"
"path/filepath"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/common/config"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/db"
"github.com/kandev/kandev/internal/events/bus"
"github.com/kandev/kandev/internal/plugins/marketplace"
"github.com/kandev/kandev/internal/plugins/runtime"
"github.com/kandev/kandev/internal/plugins/state"
"github.com/kandev/kandev/internal/plugins/store"
)
// marketplaceURLEnv overrides the built-in official marketplace source URL at
// boot (used by e2e to point the catalog at a local fixture server).
const marketplaceURLEnv = "KANDEV_PLUGIN_MARKETPLACE_URL"
// pluginsSubdir is the directory name under the Kandev home dir plugins
// live under: records ("<id>.yml"/"<id>.config.yml"), extracted packages
// ("<id>/<version>/..."), and per-plugin writable data
// ("<id>/data", KANDEV_PLUGIN_DATA_DIR) all share this one root, per
// docs/plans/plugins/GRPC-CONTRACT.md §6.
const pluginsSubdir = "plugins"
// Provide builds the plugin Service, following the repo's provider pattern
// (see apps/backend/AGENTS.md "Provider Pattern" and internal/jira/provider.go):
//
// - An FS-backed installation store rooted at <cfg.ResolvedHomeDir()>/plugins.
// - The SQLite-backed plugin_state store (internal/plugins/state), built
// from dbPool and wired onto the Service (see StateStore()).
// - An in-memory Registry, loaded from the FS store so existing
// installations survive a backend restart.
// - A runtime.Manager rooted at the same plugins directory, wired with
// svc.handleStatusChange as its OnStatusChange callback so the
// supervision loop's health transitions drive Service's state machine.
//
// secrets is passed straight through to Service.RevealSecret — callers pass
// secretadapter.New(secretsStore) in production (see internal/backendapp
// initPluginsService for the equivalent pattern); tests can pass a fake.
//
// eventBus may be nil (tests, or during early boot before the bus is ready).
//
// Event delivery (internal/plugins/delivery) and spawning already-active
// plugins (Service.StartActivePlugins) are NOT started by Provide — see the
// "Extension points" doc comment on Service for how backendapp attaches
// them after calling Provide. cleanup stops the runtime manager (kills any
// spawned processes); callers should register it with addCleanup.
func Provide(cfg *config.Config, dbPool *db.Pool, secrets SecretVault, eventBus bus.EventBus, log *logger.Logger) (*Service, func() error, error) {
dir := filepath.Join(cfg.ResolvedHomeDir(), pluginsSubdir)
pluginStore := store.NewFSStore(dir)
pluginStore.SetLogger(log)
stateStore, err := state.NewStore(dbPool)
if err != nil {
return nil, nil, fmt.Errorf("plugins: init state store: %w", err)
}
registry := NewRegistry()
if err := registry.Load(pluginStore); err != nil {
return nil, nil, fmt.Errorf("plugins: load registry: %w", err)
}
svc := NewService(pluginStore, registry, eventBus, log)
svc.SetState(stateStore)
svc.SetSecrets(secrets)
svc.SetPluginsDir(dir)
if err := attachMarketplace(svc, dbPool, log); err != nil {
// Non-fatal: the rest of the plugin system still works without the
// discovery catalog (install-by-URL/upload is unaffected).
log.Warn("plugins: marketplace init failed (non-fatal)", zap.Error(err))
}
rt := runtime.NewManager(dir, svc.handleStatusChange, log)
svc.SetRuntime(rt)
cleanup := func() error {
rt.StopAll()
return nil
}
return svc, cleanup, nil
}
// attachMarketplace builds the marketplace source store, seeds the built-in
// official source (URL overridable via KANDEV_PLUGIN_MARKETPLACE_URL), and
// attaches the catalog service to svc.
func attachMarketplace(svc *Service, dbPool *db.Pool, log *logger.Logger) error {
sourceStore, err := marketplace.NewSourceStore(dbPool)
if err != nil {
return fmt.Errorf("marketplace source store: %w", err)
}
officialURL := marketplace.OfficialSourceURL
if override := os.Getenv(marketplaceURLEnv); override != "" {
officialURL = override
}
if err := sourceStore.EnsureBuiltin(marketplace.OfficialSourceName, officialURL); err != nil {
return fmt.Errorf("marketplace seed builtin: %w", err)
}
svc.SetMarketplace(marketplace.NewService(sourceStore, log))
return nil
}