mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 17:36:45 +08:00
* MUL-6582 feat(plugins): add durable scheduled hooks Co-authored-by: multica-agent <[email protected]> * MUL-6582: add schedule-pulse example plugin Ship a real scheduled plugin that records each unique delivery_id in workspace storage, so a host retry does not look like a second pulse. The cmd/server test installs this example manifest and drives two scheduler replicas through a dropped first attempt. Co-authored-by: multica-agent <[email protected]> * MUL-6582 fix(migrations): make schedule indexes retry-safe Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: multica-agent <[email protected]>
787 lines
31 KiB
Go
787 lines
31 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgconn"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/multica-ai/multica/server/internal/util"
|
|
"github.com/multica-ai/multica/server/internal/util/secretbox"
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
"github.com/multica-ai/multica/server/pkg/featureflag"
|
|
"github.com/multica-ai/multica/server/pkg/plugincontract"
|
|
)
|
|
|
|
// PluginService owns plugin publishing, installation, configuration, and
|
|
// plugin-owned state.
|
|
type PluginService struct {
|
|
Queries *db.Queries
|
|
TxStarter TxStarter
|
|
// Secrets encrypts `secret` config values. Nil disables secret writes so a
|
|
// misconfigured deployment fails closed instead of storing plaintext.
|
|
Secrets *secretbox.Box
|
|
// LocalDir is MULTICA_PLUGIN_DIR: a directory of plugin sources an author
|
|
// can publish from without zipping and uploading after every edit. Empty
|
|
// disables the development publish channel.
|
|
LocalDir string
|
|
// DevOrigins is MULTICA_PLUGIN_DEV_ORIGINS: hook endpoint origins an author
|
|
// may point a plugin at while building one, typically a localhost server.
|
|
// Empty in every deployment that has not opted in, which is the only reason
|
|
// it is safe to skip the public-HTTPS guard for them.
|
|
DevOrigins []string
|
|
// Host gates which declared contributions this build can actually run.
|
|
Host plugincontract.Capabilities
|
|
// DeploymentKey is the raw MULTICA_PLUGIN_SECRET_KEY, used to derive each
|
|
// installation's hook signing secret. Held separately from Secrets because
|
|
// signing needs a key it can reproduce, not a sealed box.
|
|
DeploymentKey []byte
|
|
// Callbacks issues the short-lived tokens a hook handler uses to call back.
|
|
// Nil means hooks go out without one.
|
|
Callbacks *CallbackTokens
|
|
// CallbackBaseURL is the Action API root a hook handler should call, sent
|
|
// alongside the callback token so a plugin does not hardcode our hostname.
|
|
CallbackBaseURL string
|
|
// FeatureFlags gates the hook engine. Held here because the EVENT path has
|
|
// no request to read the flag from: it runs on a worker, so the check has to
|
|
// live with the service rather than in a handler. Nil reads as disabled,
|
|
// which is the safe direction for the one path that reaches a third party.
|
|
FeatureFlags *featureflag.Service
|
|
// HookClient overrides the outbound client, and ONLY for an endpoint whose
|
|
// origin the operator already named in DevOrigins. Nil everywhere else, so
|
|
// a public endpoint always goes through the SSRF-guarded client and this
|
|
// field cannot widen what a deployment can reach.
|
|
HookClient *http.Client
|
|
}
|
|
|
|
func NewPluginService(queries *db.Queries, txStarter TxStarter) *PluginService {
|
|
service := &PluginService{
|
|
Queries: queries,
|
|
TxStarter: txStarter,
|
|
LocalDir: strings.TrimSpace(os.Getenv("MULTICA_PLUGIN_DIR")),
|
|
DevOrigins: parseDevOrigins(os.Getenv("MULTICA_PLUGIN_DEV_ORIGINS")),
|
|
Host: plugincontract.HostCapabilities(),
|
|
}
|
|
service.HookClient = devHookClient(service.DevOrigins, os.Getenv("MULTICA_PLUGIN_DEV_CA"))
|
|
return service
|
|
}
|
|
|
|
// devHookClient trusts an additional CA for dev-origin hook endpoints only.
|
|
//
|
|
// A hook URL must be HTTPS — the manifest requires it — so an author testing
|
|
// against a local server needs its certificate trusted by something. This adds
|
|
// a named CA file rather than turning verification off: the failure mode of
|
|
// InsecureSkipVerify is that it survives into production behind a config flag
|
|
// somebody forgot, and there is no way to tell from the code whether it is
|
|
// active. A CA that has to be supplied by path cannot silently apply to
|
|
// anything else.
|
|
//
|
|
// Nil unless BOTH an origin allowlist and a CA path are set, and it is only
|
|
// ever consulted for an endpoint already inside that allowlist.
|
|
func devHookClient(devOrigins []string, caPath string) *http.Client {
|
|
if len(devOrigins) == 0 || strings.TrimSpace(caPath) == "" {
|
|
return nil
|
|
}
|
|
pem, err := os.ReadFile(caPath)
|
|
if err != nil {
|
|
slog.Warn("plugins: MULTICA_PLUGIN_DEV_CA could not be read; dev hook endpoints will not be trusted", "path", caPath, "error", err)
|
|
return nil
|
|
}
|
|
pool := x509.NewCertPool()
|
|
if !pool.AppendCertsFromPEM(pem) {
|
|
slog.Warn("plugins: MULTICA_PLUGIN_DEV_CA contained no certificates", "path", caPath)
|
|
return nil
|
|
}
|
|
transport := http.DefaultTransport.(*http.Transport).Clone()
|
|
transport.Proxy = nil
|
|
transport.TLSClientConfig = &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}
|
|
slog.Info("plugins: dev hook endpoints will trust an additional CA", "path", caPath, "origins", devOrigins)
|
|
return &http.Client{
|
|
Timeout: 30 * time.Second,
|
|
Transport: transport,
|
|
// Refuse redirects, matching the secure client. A 302 from a dev
|
|
// endpoint would replay the SIGNED body and the callback token to
|
|
// wherever it pointed — the one destination check this path skipped is
|
|
// exactly the one a redirect would need.
|
|
CheckRedirect: func(*http.Request, []*http.Request) error {
|
|
return errors.New("hook endpoints must not redirect")
|
|
},
|
|
}
|
|
}
|
|
|
|
type PluginErrorKind string
|
|
|
|
const (
|
|
PluginErrorInvalid PluginErrorKind = "invalid"
|
|
PluginErrorNotFound PluginErrorKind = "not_found"
|
|
PluginErrorConflict PluginErrorKind = "conflict"
|
|
PluginErrorForbidden PluginErrorKind = "forbidden"
|
|
PluginErrorIncompatible PluginErrorKind = "incompatible"
|
|
PluginErrorQuota PluginErrorKind = "quota"
|
|
PluginErrorUnavailable PluginErrorKind = "unavailable"
|
|
)
|
|
|
|
type PluginError struct {
|
|
Kind PluginErrorKind
|
|
Message string
|
|
Err error
|
|
}
|
|
|
|
func (e *PluginError) Error() string {
|
|
if e.Err != nil {
|
|
return e.Message + ": " + e.Err.Error()
|
|
}
|
|
return e.Message
|
|
}
|
|
|
|
func (e *PluginError) Unwrap() error { return e.Err }
|
|
|
|
func pluginErrf(kind PluginErrorKind, format string, args ...any) *PluginError {
|
|
return &PluginError{Kind: kind, Message: fmt.Sprintf(format, args...)}
|
|
}
|
|
|
|
// PluginPreview is what the consent screen renders. It is deliberately the raw
|
|
// manifest text plus the scope list: there is no signature, no trust tier, and
|
|
// no publisher verification in this model, so the administrator reading the
|
|
// scopes IS the trust decision.
|
|
//
|
|
// What it describes is one published version, and installing binds to that same
|
|
// version — so unlike the URL model this replaced, the code approved here is the
|
|
// code that runs.
|
|
type PluginPreview struct {
|
|
Manifest plugincontract.Manifest `json:"manifest"`
|
|
Scopes []string `json:"scopes"`
|
|
ConfigSchema []PluginConfigField `json:"config_schema"`
|
|
VersionID string `json:"version_id"`
|
|
Version string `json:"version"`
|
|
Digest string `json:"digest"`
|
|
// Upgrade describes what changes if this replaces an existing install.
|
|
Installed bool `json:"installed"`
|
|
InstalledVersion string `json:"installed_version,omitempty"`
|
|
AddedScopes []string `json:"added_scopes,omitempty"`
|
|
}
|
|
|
|
// PluginConfigField is one host-rendered configuration input.
|
|
type PluginConfigField struct {
|
|
Key string `json:"key"`
|
|
Type string `json:"type"`
|
|
Label string `json:"label"`
|
|
Description string `json:"description,omitempty"`
|
|
Required bool `json:"required"`
|
|
Options []string `json:"options,omitempty"`
|
|
Placeholder string `json:"placeholder,omitempty"`
|
|
Multiline bool `json:"multiline,omitempty"`
|
|
}
|
|
|
|
// ConfigFieldsForManifest flattens the manifest config schema into declaration
|
|
// order so the client renders the same form the server validates against.
|
|
func ConfigFieldsForManifest(manifest plugincontract.Manifest) []PluginConfigField {
|
|
fields := make([]PluginConfigField, 0, manifest.Config.Len())
|
|
for _, field := range manifest.Config.Fields {
|
|
fields = append(fields, PluginConfigField{
|
|
Key: field.Key,
|
|
Type: field.Type,
|
|
Label: field.Label,
|
|
Description: field.Description,
|
|
Required: field.Required,
|
|
Options: field.Options,
|
|
Placeholder: field.Placeholder,
|
|
Multiline: field.Multiline,
|
|
})
|
|
}
|
|
return fields
|
|
}
|
|
|
|
// ManifestForVersion decodes the manifest a published version froze at publish
|
|
// time.
|
|
//
|
|
// Nothing is fetched. The manifest, the surface scripts and the skill text were
|
|
// all validated together when the author published, and they are read back
|
|
// together — an installation cannot end up consenting to one version's manifest
|
|
// while running another version's code.
|
|
func ManifestForVersion(version db.PluginPackageVersion) (plugincontract.Manifest, []byte, error) {
|
|
var manifest plugincontract.Manifest
|
|
if err := json.Unmarshal(version.Manifest, &manifest); err != nil {
|
|
return plugincontract.Manifest{}, nil, &PluginError{Kind: PluginErrorInvalid, Message: "published plugin manifest is unreadable", Err: err}
|
|
}
|
|
return manifest, version.Manifest, nil
|
|
}
|
|
|
|
// parseDevOrigins reads the opt-in list. Anything that is not a bare origin is
|
|
// dropped rather than half-honored: a path or query here would read as a broader
|
|
// grant than it is.
|
|
func parseDevOrigins(raw string) []string {
|
|
origins := make([]string, 0)
|
|
for _, candidate := range strings.Split(raw, ",") {
|
|
candidate = strings.TrimRight(strings.TrimSpace(candidate), "/")
|
|
if candidate == "" {
|
|
continue
|
|
}
|
|
parsed, err := url.Parse(candidate)
|
|
if err != nil || parsed.Host == "" || parsed.Path != "" || parsed.RawQuery != "" {
|
|
continue
|
|
}
|
|
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
|
continue
|
|
}
|
|
origins = append(origins, parsed.Scheme+"://"+parsed.Host)
|
|
}
|
|
return origins
|
|
}
|
|
|
|
func (s *PluginService) isDevOrigin(sourceURL string) bool {
|
|
if len(s.DevOrigins) == 0 {
|
|
return false
|
|
}
|
|
parsed, err := url.Parse(sourceURL)
|
|
if err != nil || parsed.Host == "" {
|
|
return false
|
|
}
|
|
origin := parsed.Scheme + "://" + parsed.Host
|
|
for _, allowed := range s.DevOrigins {
|
|
if origin == allowed {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// PreviewPlugin reads a published version's manifest without writing anything.
|
|
// It is the first half of the two-step install: the administrator must see the
|
|
// scopes before an installation row exists.
|
|
func (s *PluginService) PreviewPlugin(ctx context.Context, workspaceID pgtype.UUID, versionID string) (*PluginPreview, error) {
|
|
version, err := s.VersionForWorkspace(ctx, workspaceID, versionID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
manifest, _, err := ManifestForVersion(version)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := manifest.CheckCapabilities(s.Host); err != nil {
|
|
return nil, &PluginError{Kind: PluginErrorIncompatible, Message: capabilityMessage(err), Err: err}
|
|
}
|
|
|
|
preview := &PluginPreview{
|
|
Manifest: manifest,
|
|
Scopes: manifest.Scopes,
|
|
ConfigSchema: ConfigFieldsForManifest(manifest),
|
|
VersionID: uuidString(version.ID),
|
|
Version: version.Version,
|
|
Digest: version.Digest,
|
|
}
|
|
existing, err := s.Queries.GetWorkspacePluginInstallationByKey(ctx, db.GetWorkspacePluginInstallationByKeyParams{
|
|
WorkspaceID: workspaceID,
|
|
PluginKey: manifest.Key,
|
|
})
|
|
if err == nil {
|
|
preview.Installed = true
|
|
preview.InstalledVersion = existing.Version
|
|
preview.AddedScopes = addedScopes(decodeScopes(existing.GrantedScopes), manifest.Scopes)
|
|
} else if !errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, &PluginError{Kind: PluginErrorUnavailable, Message: "load existing installation", Err: err}
|
|
}
|
|
return preview, nil
|
|
}
|
|
|
|
func capabilityMessage(err error) string {
|
|
var unavailable *plugincontract.ErrCapabilityUnavailable
|
|
if errors.As(err, &unavailable) {
|
|
return "This plugin declares capabilities that are not enabled yet: " + strings.Join(unavailable.Missing, ", ")
|
|
}
|
|
return "This plugin declares capabilities that are not enabled yet"
|
|
}
|
|
|
|
func addedScopes(granted []string, wanted []string) []string {
|
|
have := make(map[string]bool, len(granted))
|
|
for _, scope := range granted {
|
|
have[scope] = true
|
|
}
|
|
added := make([]string, 0)
|
|
for _, scope := range wanted {
|
|
if !have[scope] {
|
|
added = append(added, scope)
|
|
}
|
|
}
|
|
return added
|
|
}
|
|
|
|
func decodeScopes(raw []byte) []string {
|
|
var scopes []string
|
|
if len(raw) == 0 {
|
|
return nil
|
|
}
|
|
if err := json.Unmarshal(raw, &scopes); err != nil {
|
|
return nil
|
|
}
|
|
return scopes
|
|
}
|
|
|
|
// InstallPlugin is the second half of the install. grantedScopes must match the
|
|
// manifest exactly: partial consent would leave the plugin silently broken, and
|
|
// consenting to a scope the manifest does not request would grant access the
|
|
// administrator was never shown a reason for.
|
|
//
|
|
// The installation is bound to one published version. A later publish creates
|
|
// another version and does not touch this row — upgrading is a second, explicit
|
|
// consent, which is what makes "approved the manifest, ran the code" a true
|
|
// statement rather than an aspiration.
|
|
func (s *PluginService) InstallPlugin(ctx context.Context, workspaceID, userID pgtype.UUID, versionID string, grantedScopes []string) (db.PluginInstallation, error) {
|
|
version, err := s.VersionForWorkspace(ctx, workspaceID, versionID)
|
|
if err != nil {
|
|
return db.PluginInstallation{}, err
|
|
}
|
|
manifest, canonical, err := ManifestForVersion(version)
|
|
if err != nil {
|
|
return db.PluginInstallation{}, err
|
|
}
|
|
if err := manifest.CheckCapabilities(s.Host); err != nil {
|
|
return db.PluginInstallation{}, &PluginError{Kind: PluginErrorIncompatible, Message: capabilityMessage(err), Err: err}
|
|
}
|
|
if err := requireExactScopes(manifest.Scopes, grantedScopes); err != nil {
|
|
return db.PluginInstallation{}, err
|
|
}
|
|
scopesJSON, err := json.Marshal(manifest.Scopes)
|
|
if err != nil {
|
|
return db.PluginInstallation{}, &PluginError{Kind: PluginErrorInvalid, Message: "encode granted scopes", Err: err}
|
|
}
|
|
|
|
existing, err := s.Queries.GetWorkspacePluginInstallationByKey(ctx, db.GetWorkspacePluginInstallationByKeyParams{
|
|
WorkspaceID: workspaceID,
|
|
PluginKey: manifest.Key,
|
|
})
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
// A transaction even though it is one row: the skill resources land in
|
|
// the same commit. An installation whose skills half-arrived is worse
|
|
// than one that failed, because the missing half is invisible.
|
|
tx, txErr := s.TxStarter.Begin(ctx)
|
|
if txErr != nil {
|
|
return db.PluginInstallation{}, &PluginError{Kind: PluginErrorUnavailable, Message: "begin install", Err: txErr}
|
|
}
|
|
defer func() { _ = tx.Rollback(ctx) }()
|
|
queries := s.Queries.WithTx(tx)
|
|
|
|
if lockErr := lockPluginPackageKey(ctx, queries, workspaceID, manifest.Key); lockErr != nil {
|
|
return db.PluginInstallation{}, lockErr
|
|
}
|
|
if recheckErr := requireVersionStillPublished(ctx, queries, workspaceID, version.ID); recheckErr != nil {
|
|
return db.PluginInstallation{}, recheckErr
|
|
}
|
|
|
|
installation, createErr := queries.CreatePluginInstallation(ctx, db.CreatePluginInstallationParams{
|
|
WorkspaceID: workspaceID,
|
|
PluginKey: manifest.Key,
|
|
PackageVersionID: version.ID,
|
|
// The published version string, not the manifest's: a development
|
|
// publish lands under a `+dev.N` suffix, and the settings page has to
|
|
// name the version that actually exists.
|
|
Version: version.Version,
|
|
Manifest: canonical,
|
|
GrantedScopes: scopesJSON,
|
|
InstalledBy: userID,
|
|
})
|
|
if createErr != nil {
|
|
// Two admins installing the same plugin at once: one loses the
|
|
// unique index. That is a conflict to retry, not a broken backend.
|
|
if isUniqueViolation(createErr) {
|
|
return db.PluginInstallation{}, pluginErrf(PluginErrorConflict, "this plugin is already installed in this workspace")
|
|
}
|
|
return db.PluginInstallation{}, &PluginError{Kind: PluginErrorUnavailable, Message: "install plugin", Err: createErr}
|
|
}
|
|
if skillErr := s.InstallSkillResources(ctx, queries, installation, manifest, userID); skillErr != nil {
|
|
return db.PluginInstallation{}, skillErr
|
|
}
|
|
if scheduleErr := s.reconcilePluginHookSchedules(ctx, queries, installation, manifest); scheduleErr != nil {
|
|
return db.PluginInstallation{}, scheduleErr
|
|
}
|
|
if commitErr := tx.Commit(ctx); commitErr != nil {
|
|
return db.PluginInstallation{}, &PluginError{Kind: PluginErrorUnavailable, Message: "commit install", Err: commitErr}
|
|
}
|
|
return installation, nil
|
|
}
|
|
if err != nil {
|
|
return db.PluginInstallation{}, &PluginError{Kind: PluginErrorUnavailable, Message: "load existing installation", Err: err}
|
|
}
|
|
|
|
// Upgrade in place. Values whose fields the new manifest dropped are pruned
|
|
// rather than left as state nothing can reach — and that applies to secrets
|
|
// too, which is the case where unreachable residue is ciphertext. The whole
|
|
// upgrade is one transaction so a snapshot can never land with the previous
|
|
// version's secrets still attached.
|
|
config := pruneConfig(existing.Config, manifest)
|
|
orphanedSecrets, err := s.orphanedSecretKeys(ctx, existing.ID, manifest)
|
|
if err != nil {
|
|
return db.PluginInstallation{}, err
|
|
}
|
|
|
|
tx, err := s.TxStarter.Begin(ctx)
|
|
if err != nil {
|
|
return db.PluginInstallation{}, &PluginError{Kind: PluginErrorUnavailable, Message: "begin upgrade", Err: err}
|
|
}
|
|
defer func() { _ = tx.Rollback(ctx) }()
|
|
queries := s.Queries.WithTx(tx)
|
|
|
|
if err := lockPluginPackageKey(ctx, queries, workspaceID, manifest.Key); err != nil {
|
|
return db.PluginInstallation{}, err
|
|
}
|
|
if err := requireVersionStillPublished(ctx, queries, workspaceID, version.ID); err != nil {
|
|
return db.PluginInstallation{}, err
|
|
}
|
|
|
|
for _, key := range orphanedSecrets {
|
|
if _, err := queries.DeletePluginSecret(ctx, db.DeletePluginSecretParams{InstallationID: existing.ID, Key: key}); err != nil {
|
|
return db.PluginInstallation{}, &PluginError{Kind: PluginErrorUnavailable, Message: "prune plugin secret", Err: err}
|
|
}
|
|
}
|
|
updated, err := queries.UpdatePluginInstallationManifest(ctx, db.UpdatePluginInstallationManifestParams{
|
|
ID: existing.ID,
|
|
PackageVersionID: version.ID,
|
|
Version: version.Version,
|
|
Manifest: canonical,
|
|
GrantedScopes: scopesJSON,
|
|
Config: config,
|
|
})
|
|
if err != nil {
|
|
return db.PluginInstallation{}, &PluginError{Kind: PluginErrorUnavailable, Message: "upgrade plugin", Err: err}
|
|
}
|
|
// Re-run on upgrade so a changed SKILL.md takes effect and a dropped one is
|
|
// pruned. Same transaction as the manifest snapshot: the two must never
|
|
// disagree about what this version contributes.
|
|
if err := s.InstallSkillResources(ctx, queries, updated, manifest, userID); err != nil {
|
|
return db.PluginInstallation{}, err
|
|
}
|
|
if err := s.reconcilePluginHookSchedules(ctx, queries, updated, manifest); err != nil {
|
|
return db.PluginInstallation{}, err
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return db.PluginInstallation{}, &PluginError{Kind: PluginErrorUnavailable, Message: "commit upgrade", Err: err}
|
|
}
|
|
return updated, nil
|
|
}
|
|
|
|
// orphanedSecretKeys returns stored secrets the new manifest no longer declares
|
|
// as a secret field — including a field that changed type away from secret.
|
|
func (s *PluginService) orphanedSecretKeys(ctx context.Context, installationID pgtype.UUID, manifest plugincontract.Manifest) ([]string, error) {
|
|
stored, err := s.Queries.ListPluginSecretKeys(ctx, installationID)
|
|
if err != nil {
|
|
return nil, &PluginError{Kind: PluginErrorUnavailable, Message: "list plugin secrets", Err: err}
|
|
}
|
|
orphaned := make([]string, 0)
|
|
for _, row := range stored {
|
|
field, ok := manifest.Config.Field(row.Key)
|
|
if !ok || field.Type != plugincontract.ConfigSecret {
|
|
orphaned = append(orphaned, row.Key)
|
|
}
|
|
}
|
|
return orphaned, nil
|
|
}
|
|
|
|
func requireExactScopes(manifestScopes, grantedScopes []string) error {
|
|
if len(manifestScopes) != len(grantedScopes) {
|
|
return pluginErrf(PluginErrorConflict, "granted_scopes must match the manifest scopes exactly")
|
|
}
|
|
wanted := make(map[string]bool, len(manifestScopes))
|
|
for _, scope := range manifestScopes {
|
|
wanted[scope] = true
|
|
}
|
|
for _, scope := range grantedScopes {
|
|
if !wanted[scope] {
|
|
return pluginErrf(PluginErrorConflict, "granted_scopes contains %q, which the manifest does not request", scope)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func pruneConfig(raw []byte, manifest plugincontract.Manifest) []byte {
|
|
var values map[string]any
|
|
if len(raw) == 0 || json.Unmarshal(raw, &values) != nil {
|
|
return []byte("{}")
|
|
}
|
|
for key := range values {
|
|
if _, ok := manifest.Config.Field(key); !ok {
|
|
delete(values, key)
|
|
}
|
|
}
|
|
encoded, err := json.Marshal(values)
|
|
if err != nil {
|
|
return []byte("{}")
|
|
}
|
|
return encoded
|
|
}
|
|
|
|
func parseUUIDValue(value string) (pgtype.UUID, error) { return util.ParseUUID(value) }
|
|
|
|
func uuidString(value pgtype.UUID) string { return util.UUIDToString(value) }
|
|
|
|
// ParseInstallationManifest reads back the consented snapshot. Callers must use
|
|
// this, never a freshly fetched manifest: what the source URL serves today is
|
|
// not what the administrator approved.
|
|
func ParseInstallationManifest(installation db.PluginInstallation) (plugincontract.Manifest, error) {
|
|
var manifest plugincontract.Manifest
|
|
if err := json.Unmarshal(installation.Manifest, &manifest); err != nil {
|
|
return plugincontract.Manifest{}, fmt.Errorf("decode stored plugin manifest: %w", err)
|
|
}
|
|
return manifest, nil
|
|
}
|
|
|
|
// SetConfig validates values against the stored manifest schema and splits them
|
|
// by destination: plain values go on the installation row, secrets go to the
|
|
// encrypted table. A secret value never lands in `config`.
|
|
func (s *PluginService) SetConfig(ctx context.Context, installation db.PluginInstallation, values map[string]any) (db.PluginInstallation, error) {
|
|
manifest, err := ParseInstallationManifest(installation)
|
|
if err != nil {
|
|
return db.PluginInstallation{}, &PluginError{Kind: PluginErrorInvalid, Message: "stored plugin manifest is unreadable", Err: err}
|
|
}
|
|
|
|
plain := map[string]any{}
|
|
secrets := map[string]string{}
|
|
for key, value := range values {
|
|
field, ok := manifest.Config.Field(key)
|
|
if !ok {
|
|
return db.PluginInstallation{}, pluginErrf(PluginErrorInvalid, "unknown config field %q", key)
|
|
}
|
|
if field.Type == plugincontract.ConfigSecret {
|
|
text, ok := value.(string)
|
|
if !ok {
|
|
return db.PluginInstallation{}, pluginErrf(PluginErrorInvalid, "config field %q must be a string", key)
|
|
}
|
|
if len(text) > MaxPluginSecretBytes {
|
|
return db.PluginInstallation{}, pluginErrf(PluginErrorInvalid, "config field %q exceeds %d bytes", key, MaxPluginSecretBytes)
|
|
}
|
|
secrets[key] = text
|
|
continue
|
|
}
|
|
normalized, err := normalizeConfigValue(field, value)
|
|
if err != nil {
|
|
return db.PluginInstallation{}, err
|
|
}
|
|
plain[key] = normalized
|
|
}
|
|
|
|
// Merge over the stored values so a partial update does not silently clear
|
|
// fields the form did not submit.
|
|
merged := map[string]any{}
|
|
if len(installation.Config) > 0 {
|
|
_ = json.Unmarshal(installation.Config, &merged)
|
|
}
|
|
for key, value := range plain {
|
|
merged[key] = value
|
|
}
|
|
encoded, err := json.Marshal(merged)
|
|
if err != nil {
|
|
return db.PluginInstallation{}, &PluginError{Kind: PluginErrorInvalid, Message: "encode plugin config", Err: err}
|
|
}
|
|
|
|
if len(secrets) > 0 && s.Secrets == nil {
|
|
return db.PluginInstallation{}, pluginErrf(PluginErrorUnavailable, "plugin secrets are disabled: MULTICA_PLUGIN_SECRET_KEY is not configured")
|
|
}
|
|
|
|
// Secrets and plain values are two tables with no foreign key between them,
|
|
// so one transaction is what keeps a saved form from landing half-applied.
|
|
tx, err := s.TxStarter.Begin(ctx)
|
|
if err != nil {
|
|
return db.PluginInstallation{}, &PluginError{Kind: PluginErrorUnavailable, Message: "begin configure", Err: err}
|
|
}
|
|
defer func() { _ = tx.Rollback(ctx) }()
|
|
queries := s.Queries.WithTx(tx)
|
|
|
|
for key, text := range secrets {
|
|
// An empty submission clears the secret rather than storing "".
|
|
if text == "" {
|
|
if _, err := queries.DeletePluginSecret(ctx, db.DeletePluginSecretParams{InstallationID: installation.ID, Key: key}); err != nil {
|
|
return db.PluginInstallation{}, &PluginError{Kind: PluginErrorUnavailable, Message: "clear plugin secret", Err: err}
|
|
}
|
|
continue
|
|
}
|
|
ciphertext, sealErr := s.Secrets.Seal([]byte(text))
|
|
if sealErr != nil {
|
|
return db.PluginInstallation{}, &PluginError{Kind: PluginErrorUnavailable, Message: "encrypt plugin secret", Err: sealErr}
|
|
}
|
|
if err := queries.UpsertPluginSecret(ctx, db.UpsertPluginSecretParams{
|
|
InstallationID: installation.ID,
|
|
Key: key,
|
|
Ciphertext: ciphertext,
|
|
}); err != nil {
|
|
return db.PluginInstallation{}, &PluginError{Kind: PluginErrorUnavailable, Message: "store plugin secret", Err: err}
|
|
}
|
|
}
|
|
|
|
updated, err := queries.UpdatePluginInstallationConfig(ctx, db.UpdatePluginInstallationConfigParams{
|
|
ID: installation.ID,
|
|
Config: encoded,
|
|
})
|
|
if err != nil {
|
|
return db.PluginInstallation{}, &PluginError{Kind: PluginErrorUnavailable, Message: "store plugin config", Err: err}
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return db.PluginInstallation{}, &PluginError{Kind: PluginErrorUnavailable, Message: "commit configure", Err: err}
|
|
}
|
|
return updated, nil
|
|
}
|
|
|
|
// isUniqueViolation reports a Postgres 23505, the only class of write conflict
|
|
// the plugin surface can produce.
|
|
func isUniqueViolation(err error) bool {
|
|
var pgErr *pgconn.PgError
|
|
return errors.As(err, &pgErr) && pgErr.Code == "23505"
|
|
}
|
|
|
|
// MaxPluginSecretBytes bounds one secret value before encryption.
|
|
const MaxPluginSecretBytes = 8192
|
|
|
|
func normalizeConfigValue(field plugincontract.ConfigField, value any) (any, error) {
|
|
label := fmt.Sprintf("config field %q", field.Key)
|
|
switch field.Type {
|
|
case plugincontract.ConfigString:
|
|
text, ok := value.(string)
|
|
if !ok {
|
|
return nil, pluginErrf(PluginErrorInvalid, "%s must be a string", label)
|
|
}
|
|
if len(text) > 4096 {
|
|
return nil, pluginErrf(PluginErrorInvalid, "%s exceeds 4096 bytes", label)
|
|
}
|
|
return text, nil
|
|
case plugincontract.ConfigNumber:
|
|
number, ok := value.(float64)
|
|
if !ok {
|
|
return nil, pluginErrf(PluginErrorInvalid, "%s must be a number", label)
|
|
}
|
|
return number, nil
|
|
case plugincontract.ConfigBool:
|
|
flag, ok := value.(bool)
|
|
if !ok {
|
|
return nil, pluginErrf(PluginErrorInvalid, "%s must be a boolean", label)
|
|
}
|
|
return flag, nil
|
|
case plugincontract.ConfigEnum:
|
|
text, ok := value.(string)
|
|
if !ok {
|
|
return nil, pluginErrf(PluginErrorInvalid, "%s must be a string", label)
|
|
}
|
|
for _, option := range field.Options {
|
|
if option == text {
|
|
return text, nil
|
|
}
|
|
}
|
|
return nil, pluginErrf(PluginErrorInvalid, "%s must be one of the declared options", label)
|
|
default:
|
|
return nil, pluginErrf(PluginErrorInvalid, "%s has an unsupported type", label)
|
|
}
|
|
}
|
|
|
|
// ConfiguredSecretKeys reports which secret fields hold a value. It returns
|
|
// names only — no endpoint ever returns a secret value, not even masked.
|
|
func (s *PluginService) ConfiguredSecretKeys(ctx context.Context, installationID pgtype.UUID) ([]string, error) {
|
|
rows, err := s.Queries.ListPluginSecretKeys(ctx, installationID)
|
|
if err != nil {
|
|
return nil, &PluginError{Kind: PluginErrorUnavailable, Message: "list plugin secrets", Err: err}
|
|
}
|
|
keys := make([]string, 0, len(rows))
|
|
for _, row := range rows {
|
|
keys = append(keys, row.Key)
|
|
}
|
|
return keys, nil
|
|
}
|
|
|
|
// SetEnabled toggles an installation. Disabling hides every contribution
|
|
// immediately but keeps storage and secrets, so re-enabling is not a reinstall.
|
|
func (s *PluginService) SetEnabled(ctx context.Context, installation db.PluginInstallation, enabled bool) (db.PluginInstallation, error) {
|
|
if installation.Enabled == enabled {
|
|
return installation, nil
|
|
}
|
|
tx, err := s.TxStarter.Begin(ctx)
|
|
if err != nil {
|
|
return db.PluginInstallation{}, &PluginError{Kind: PluginErrorUnavailable, Message: "begin plugin state update", Err: err}
|
|
}
|
|
defer func() { _ = tx.Rollback(ctx) }()
|
|
queries := s.Queries.WithTx(tx)
|
|
|
|
updated, err := queries.SetPluginInstallationEnabled(ctx, db.SetPluginInstallationEnabledParams{
|
|
ID: installation.ID,
|
|
Enabled: enabled,
|
|
})
|
|
if err != nil {
|
|
return db.PluginInstallation{}, &PluginError{Kind: PluginErrorUnavailable, Message: "update plugin state", Err: err}
|
|
}
|
|
if err := s.setPluginHookSchedulesEnabled(ctx, queries, installation.ID, enabled); err != nil {
|
|
return db.PluginInstallation{}, err
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return db.PluginInstallation{}, &PluginError{Kind: PluginErrorUnavailable, Message: "commit plugin state update", Err: err}
|
|
}
|
|
return updated, nil
|
|
}
|
|
|
|
// Uninstall removes the installation and all of its application-owned rows.
|
|
// There are no foreign keys or cascades by repository policy, so the deletes
|
|
// share one transaction: a partial uninstall would strand rows nothing can
|
|
// reach or clean up later.
|
|
func (s *PluginService) Uninstall(ctx context.Context, installation db.PluginInstallation) error {
|
|
tx, err := s.TxStarter.Begin(ctx)
|
|
if err != nil {
|
|
return &PluginError{Kind: PluginErrorUnavailable, Message: "begin uninstall", Err: err}
|
|
}
|
|
defer func() { _ = tx.Rollback(ctx) }()
|
|
|
|
queries := s.Queries.WithTx(tx)
|
|
if err := queries.DeletePluginHookSchedulesByInstallation(ctx, installation.ID); err != nil {
|
|
return &PluginError{Kind: PluginErrorUnavailable, Message: "delete plugin hook schedules", Err: err}
|
|
}
|
|
if err := queries.DeletePluginStorageByInstallation(ctx, installation.ID); err != nil {
|
|
return &PluginError{Kind: PluginErrorUnavailable, Message: "delete plugin storage", Err: err}
|
|
}
|
|
if err := queries.DeletePluginSecretsByInstallation(ctx, installation.ID); err != nil {
|
|
return &PluginError{Kind: PluginErrorUnavailable, Message: "delete plugin secrets", Err: err}
|
|
}
|
|
// Hook records go too. They name an installation that is about to stop
|
|
// existing, and keeping them would leave rows nothing can attribute — the
|
|
// same reason storage and secrets are cleared here rather than swept later.
|
|
if err := queries.DeletePluginInvocationsByInstallation(ctx, installation.ID); err != nil {
|
|
return &PluginError{Kind: PluginErrorUnavailable, Message: "delete plugin invocations", Err: err}
|
|
}
|
|
// The skills this installation contributed go with it. Scoped by
|
|
// plugin_installation_id, so a skill a person wrote is untouched even if it
|
|
// happens to share a name with something the plugin once provided.
|
|
if err := queries.DeletePluginSkillsByInstallation(ctx, installation.ID); err != nil {
|
|
return &PluginError{Kind: PluginErrorUnavailable, Message: "delete plugin skills", Err: err}
|
|
}
|
|
if err := queries.DeletePluginInstallation(ctx, installation.ID); err != nil {
|
|
return &PluginError{Kind: PluginErrorUnavailable, Message: "delete plugin installation", Err: err}
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return &PluginError{Kind: PluginErrorUnavailable, Message: "commit uninstall", Err: err}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// InstallationForWorkspace loads an installation and confirms it belongs to the
|
|
// workspace in the URL, so an id from another workspace cannot be operated on.
|
|
func (s *PluginService) InstallationForWorkspace(ctx context.Context, workspaceID pgtype.UUID, installationID string) (db.PluginInstallation, error) {
|
|
parsed, err := util.ParseUUID(installationID)
|
|
if err != nil {
|
|
return db.PluginInstallation{}, pluginErrf(PluginErrorNotFound, "plugin installation not found")
|
|
}
|
|
installation, err := s.Queries.GetWorkspacePluginInstallation(ctx, db.GetWorkspacePluginInstallationParams{
|
|
WorkspaceID: workspaceID,
|
|
ID: parsed,
|
|
})
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return db.PluginInstallation{}, pluginErrf(PluginErrorNotFound, "plugin installation not found")
|
|
}
|
|
if err != nil {
|
|
return db.PluginInstallation{}, &PluginError{Kind: PluginErrorUnavailable, Message: "load plugin installation", Err: err}
|
|
}
|
|
return installation, nil
|
|
}
|