Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f39667b469 | ||
|
|
936f1990af | ||
|
|
f1a909e7ad | ||
|
|
2dd4069009 | ||
|
|
3d12f467dd | ||
|
|
5400717c3e | ||
|
|
10d4624222 | ||
|
|
6f445c1cda | ||
|
|
d4edf0068e | ||
|
|
9c9e10e373 | ||
|
|
c39737e456 | ||
|
|
371d959cc8 | ||
|
|
b4542bb53c |
+6
-5
@@ -3163,11 +3163,12 @@ func reloadWatchSyncPluginProviders(
|
||||
continue
|
||||
}
|
||||
provider, err := watchsync.NewPluginProvider(watchsync.PluginProviderOptions{
|
||||
InstallationID: installation.ID,
|
||||
ProviderKey: fmt.Sprintf("plugin:%d:%s", installation.ID, capability.ID),
|
||||
CapabilityID: capability.ID,
|
||||
DisplayName: descriptor.GetDisplayName(),
|
||||
Descriptor: descriptor.GetWatchSyncProvider(),
|
||||
InstallationID: installation.ID,
|
||||
ProviderKey: fmt.Sprintf("plugin:%d:%s", installation.ID, capability.ID),
|
||||
CapabilityID: capability.ID,
|
||||
DisplayName: descriptor.GetDisplayName(),
|
||||
Descriptor: descriptor.GetWatchSyncProvider(),
|
||||
ConnectionConfigSchema: descriptor.GetConfigSchema(),
|
||||
ResolveClient: func(callCtx context.Context, installationID int, capabilityID string) (watchsync.WatchSyncPluginClient, error) {
|
||||
return service.WatchSyncProviderClient(callCtx, installationID, capabilityID)
|
||||
},
|
||||
|
||||
@@ -198,6 +198,66 @@ func (failingWatchSyncCapabilityStore) ListCapabilities(context.Context, int) ([
|
||||
return nil, errors.New("database unavailable")
|
||||
}
|
||||
|
||||
type staticWatchSyncCapabilityStore struct {
|
||||
capabilities []*plugins.Capability
|
||||
}
|
||||
|
||||
func (s staticWatchSyncCapabilityStore) ListEnabled(context.Context) ([]*plugins.Installation, error) {
|
||||
return []*plugins.Installation{{ID: 4, Enabled: true, Kind: plugins.KindPlugin}}, nil
|
||||
}
|
||||
|
||||
func (s staticWatchSyncCapabilityStore) ListCapabilities(context.Context, int) ([]*plugins.Capability, error) {
|
||||
return s.capabilities, nil
|
||||
}
|
||||
|
||||
func TestReloadWatchSyncPluginProvidersPreservesConnectionForm(t *testing.T) {
|
||||
manifest := &pluginv1.PluginManifest{Capabilities: []*pluginv1.CapabilityDescriptor{{
|
||||
Type: "watch_sync_provider.v1", Id: "floppy", DisplayName: "Floppy",
|
||||
ConfigSchema: []*pluginv1.ConfigSchema{{
|
||||
Key: "floppy", Title: "Your Floppy server", Required: true,
|
||||
JsonSchema: `{"type":"object","properties":{"base_url":{"type":"string"}},"required":["base_url"]}`,
|
||||
AdminForm: &pluginv1.AdminFormDescriptor{Fields: []*pluginv1.AdminFormField{{
|
||||
Key: "base_url", Label: "Server URL", Required: true,
|
||||
Control: pluginv1.AdminFormControl_ADMIN_FORM_CONTROL_TEXT,
|
||||
}}},
|
||||
}},
|
||||
WatchSyncProvider: &pluginv1.WatchSyncProviderDescriptor{
|
||||
AuthMethods: []pluginv1.WatchSyncAuthMethod{pluginv1.WatchSyncAuthMethod_WATCH_SYNC_AUTH_METHOD_API_KEY},
|
||||
},
|
||||
}}}
|
||||
records, err := plugins.CapabilityRecordsFromManifest(manifest)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
capabilities := make([]*plugins.Capability, 0, len(records))
|
||||
for i := range records {
|
||||
record := records[i]
|
||||
capabilities = append(capabilities, &record)
|
||||
}
|
||||
|
||||
registry := watchsync.NewRegistry()
|
||||
if err := reloadWatchSyncPluginProviders(
|
||||
context.Background(), registry, staticWatchSyncCapabilityStore{capabilities: capabilities}, &plugins.Service{}, nil,
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
provider, ok := registry.Get("plugin:4:floppy")
|
||||
if !ok {
|
||||
t.Fatal("Floppy provider was not registered")
|
||||
}
|
||||
configurable, ok := provider.(interface {
|
||||
ConnectionConfigSchema() []plugins.ConfigSchemaView
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("Floppy provider does not expose connection configuration")
|
||||
}
|
||||
schemas := configurable.ConnectionConfigSchema()
|
||||
if len(schemas) != 1 || schemas[0].AdminForm == nil || len(schemas[0].AdminForm.Fields) != 1 ||
|
||||
schemas[0].AdminForm.Fields[0].Control != "TEXT" {
|
||||
t.Fatalf("connection config schema = %#v", schemas)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReloadWatchSyncPluginProvidersDropsStaleProvidersOnCapabilityReadFailure(t *testing.T) {
|
||||
registry := watchsync.NewRegistry()
|
||||
provider, err := watchsync.NewPluginProvider(watchsync.PluginProviderOptions{
|
||||
|
||||
@@ -25,6 +25,8 @@ rate-limit handling, list reconciliation, and scheduled reconciliation.
|
||||
expiry, and opaque secret attributes.
|
||||
- Supply declared installation configuration to provider calls without mixing
|
||||
it with profile credentials.
|
||||
- Allow API-key providers to declare setup fields that are supplied per profile
|
||||
connection rather than forcing one installation-wide endpoint.
|
||||
- Keep provider plugins stateless and prevent credentials from entering generic
|
||||
plugin configuration.
|
||||
- Avoid behavior changes for built-in Trakt, Simkl, and MDBList providers.
|
||||
@@ -117,9 +119,14 @@ The plugin never needs a Silo API key or a callback into catalog HTTP routes.
|
||||
## Authentication and credentials
|
||||
|
||||
The existing watch-provider API-key connection route passes the entered token
|
||||
to `ExchangeAPIKey`. Device-code providers use the existing start and poll
|
||||
routes. The plugin validates or exchanges the credential and returns normalized
|
||||
credentials plus provider account identity.
|
||||
to `ExchangeAPIKey`. A plugin capability may also declare `config_schema`
|
||||
entries; the host renders those fields beside the API key, validates submitted
|
||||
objects against the declared JSON schemas, classifies secret fields, and
|
||||
overlays the flattened values onto `provider_config` for that exchange only.
|
||||
The plugin validates or exchanges the credential and returns normalized
|
||||
credentials plus provider account identity. Provider-specific connection
|
||||
context needed by later calls is returned in encrypted opaque credential
|
||||
attributes. Device-code providers use the existing start and poll routes.
|
||||
|
||||
Credential refresh remains host-initiated and serialized by the existing
|
||||
watchsync service. The host encrypts an authoritative credential bundle
|
||||
@@ -202,6 +209,15 @@ are sent in `secret_values`. Nested manifest fields use the stable
|
||||
`<config-key>.<field>` key form, such as `floppy.base_url`. Profile credentials
|
||||
remain separate and encrypted in watch-provider connection storage.
|
||||
|
||||
Capability-level `config_schema` entries are connection setup fields, not
|
||||
installation defaults. For API-key authorization, the host validates and
|
||||
flattens them using the same `<config-key>.<field>` convention and lets the
|
||||
connection values override a same-named global value. This supports migrations
|
||||
from a legacy installation-wide endpoint while ensuring every new connection
|
||||
chooses its own endpoint. The submitted objects are not retained as generic
|
||||
plugin configuration; the plugin returns any context required after exchange
|
||||
inside its opaque encrypted credentials.
|
||||
|
||||
## Apply result handling
|
||||
|
||||
Each event has a stable ID. The adapter requires a matching result and maps it
|
||||
|
||||
@@ -109,7 +109,7 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.13.0
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.13.2
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30 // indirect
|
||||
|
||||
@@ -4,8 +4,10 @@ github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0g
|
||||
github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI=
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.13.0 h1:BXf4cuNbOIIsrVJrRdlQnbc1m04X6lcPSZI+AFLMeK0=
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.13.0/go.mod h1:etqmxLTwjxpFH9goAjBDfNDoqHMv2/sqUXu8yx3hNfA=
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.13.1 h1:3vMaV+aPT/vu47CPk/f3nODsjXCp1OseI0QIoLnOvOM=
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.13.1/go.mod h1:etqmxLTwjxpFH9goAjBDfNDoqHMv2/sqUXu8yx3hNfA=
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.13.2 h1:w7U0mmljVPauKfzRLNKusuiYFpuoEhuCSX4Hp3s9eRw=
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.13.2/go.mod h1:etqmxLTwjxpFH9goAjBDfNDoqHMv2/sqUXu8yx3hNfA=
|
||||
github.com/abadojack/whatlanggo v1.0.1 h1:19N6YogDnf71CTHm3Mp2qhYfkRdyvbgwWdd2EPxJRG4=
|
||||
github.com/abadojack/whatlanggo v1.0.1/go.mod h1:66WiQbSbJBIlOZMsvbKe5m6pzQovxCH9B/K8tQB2uoc=
|
||||
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
|
||||
|
||||
@@ -201,69 +201,10 @@ type pluginPresentationJSON struct {
|
||||
LicenseSPDX string `json:"license_spdx"`
|
||||
}
|
||||
|
||||
type pluginConfigSchemaJSON struct {
|
||||
Key string `json:"key"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
JSONSchema string `json:"json_schema"`
|
||||
Required bool `json:"required"`
|
||||
AdminForm *pluginAdminFormJSON `json:"admin_form,omitempty"`
|
||||
}
|
||||
|
||||
type pluginAdminFormJSON struct {
|
||||
Fields []pluginAdminFormFieldJSON `json:"fields"`
|
||||
SubmitLabel string `json:"submit_label,omitempty"`
|
||||
Sections []pluginAdminFormSectionJSON `json:"sections,omitempty"`
|
||||
}
|
||||
|
||||
type pluginAdminFormFieldJSON struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Control string `json:"control"`
|
||||
Placeholder string `json:"placeholder,omitempty"`
|
||||
Required bool `json:"required"`
|
||||
Secret bool `json:"secret"`
|
||||
Multiline bool `json:"multiline"`
|
||||
DefaultValue any `json:"default_value,omitempty"`
|
||||
Options []pluginAdminFormOptionJSON `json:"options,omitempty"`
|
||||
Rows int32 `json:"rows,omitempty"`
|
||||
DynamicOptions bool `json:"dynamic_options,omitempty"`
|
||||
ShowWhen []pluginAdminFormConditionJSON `json:"show_when,omitempty"`
|
||||
Validation *pluginAdminFormValidationJSON `json:"validation,omitempty"`
|
||||
ExclusiveGroupField string `json:"exclusive_group_field,omitempty"`
|
||||
}
|
||||
|
||||
type pluginAdminFormOptionJSON struct {
|
||||
Value string `json:"value"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
type pluginAdminFormConditionJSON struct {
|
||||
Field string `json:"field"`
|
||||
Equals []string `json:"equals"`
|
||||
}
|
||||
|
||||
type pluginAdminFormValidationJSON struct {
|
||||
HasMin bool `json:"has_min,omitempty"`
|
||||
Min float64 `json:"min,omitempty"`
|
||||
HasMax bool `json:"has_max,omitempty"`
|
||||
Max float64 `json:"max,omitempty"`
|
||||
Pattern string `json:"pattern,omitempty"`
|
||||
MinLength int32 `json:"min_length,omitempty"`
|
||||
MaxLength int32 `json:"max_length,omitempty"`
|
||||
}
|
||||
|
||||
type pluginAdminFormSectionJSON struct {
|
||||
Key string `json:"key"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Collapsible bool `json:"collapsible"`
|
||||
CollapsedDefault bool `json:"collapsed_default"`
|
||||
FieldKeys []string `json:"field_keys"`
|
||||
ShowWhen []pluginAdminFormConditionJSON `json:"show_when,omitempty"`
|
||||
}
|
||||
type pluginConfigSchemaJSON = plugins.ConfigSchemaView
|
||||
type pluginAdminFormJSON = plugins.AdminFormView
|
||||
type pluginAdminFormFieldJSON = plugins.AdminFormFieldView
|
||||
type pluginAdminFormSectionJSON = plugins.AdminFormSectionView
|
||||
|
||||
type pluginCapabilityJSON struct {
|
||||
Type string `json:"type"`
|
||||
@@ -1567,117 +1508,11 @@ func toUserPluginSettingsSummary(
|
||||
}
|
||||
|
||||
func configSchemasToJSON(schemas []*pluginv1.ConfigSchema) []pluginConfigSchemaJSON {
|
||||
response := make([]pluginConfigSchemaJSON, 0, len(schemas))
|
||||
for _, schema := range schemas {
|
||||
if schema == nil {
|
||||
continue
|
||||
}
|
||||
response = append(response, pluginConfigSchemaJSON{
|
||||
Key: schema.GetKey(),
|
||||
Title: schema.GetTitle(),
|
||||
Description: schema.GetDescription(),
|
||||
JSONSchema: schema.GetJsonSchema(),
|
||||
Required: schema.GetRequired(),
|
||||
AdminForm: adminFormToJSON(schema.GetAdminForm()),
|
||||
})
|
||||
}
|
||||
return response
|
||||
return plugins.ConfigSchemaViews(schemas)
|
||||
}
|
||||
|
||||
func adminFormToJSON(form *pluginv1.AdminFormDescriptor) *pluginAdminFormJSON {
|
||||
if form == nil {
|
||||
return nil
|
||||
}
|
||||
fields := make([]pluginAdminFormFieldJSON, 0, len(form.GetFields()))
|
||||
for _, field := range form.GetFields() {
|
||||
if field == nil {
|
||||
continue
|
||||
}
|
||||
options := make([]pluginAdminFormOptionJSON, 0, len(field.GetOptions()))
|
||||
for _, option := range field.GetOptions() {
|
||||
if option == nil {
|
||||
continue
|
||||
}
|
||||
options = append(options, pluginAdminFormOptionJSON{
|
||||
Value: option.GetValue(),
|
||||
Label: option.GetLabel(),
|
||||
Description: option.GetDescription(),
|
||||
})
|
||||
}
|
||||
var defaultValue any
|
||||
if field.GetDefaultValue() != nil {
|
||||
defaultValue = field.GetDefaultValue().AsInterface()
|
||||
}
|
||||
var validation *pluginAdminFormValidationJSON
|
||||
if v := field.GetValidation(); v != nil {
|
||||
validation = &pluginAdminFormValidationJSON{
|
||||
HasMin: v.GetHasMin(),
|
||||
Min: v.GetMin(),
|
||||
HasMax: v.GetHasMax(),
|
||||
Max: v.GetMax(),
|
||||
Pattern: v.GetPattern(),
|
||||
MinLength: v.GetMinLength(),
|
||||
MaxLength: v.GetMaxLength(),
|
||||
}
|
||||
}
|
||||
fields = append(fields, pluginAdminFormFieldJSON{
|
||||
Key: field.GetKey(),
|
||||
Label: field.GetLabel(),
|
||||
Description: field.GetDescription(),
|
||||
Control: strings.TrimPrefix(field.GetControl().String(), "ADMIN_FORM_CONTROL_"),
|
||||
Placeholder: field.GetPlaceholder(),
|
||||
Required: field.GetRequired(),
|
||||
Secret: field.GetSecret(),
|
||||
Multiline: field.GetMultiline(),
|
||||
DefaultValue: defaultValue,
|
||||
Options: options,
|
||||
Rows: field.GetRows(),
|
||||
DynamicOptions: field.GetDynamicOptions(),
|
||||
ShowWhen: adminFormConditionsToJSON(field.GetShowWhen()),
|
||||
Validation: validation,
|
||||
ExclusiveGroupField: field.GetExclusiveGroupField(),
|
||||
})
|
||||
}
|
||||
sections := make([]pluginAdminFormSectionJSON, 0, len(form.GetSections()))
|
||||
for _, section := range form.GetSections() {
|
||||
if section == nil {
|
||||
continue
|
||||
}
|
||||
sections = append(sections, pluginAdminFormSectionJSON{
|
||||
Key: section.GetKey(),
|
||||
Title: section.GetTitle(),
|
||||
Description: section.GetDescription(),
|
||||
Collapsible: section.GetCollapsible(),
|
||||
CollapsedDefault: section.GetCollapsedDefault(),
|
||||
FieldKeys: append([]string(nil), section.GetFieldKeys()...),
|
||||
ShowWhen: adminFormConditionsToJSON(section.GetShowWhen()),
|
||||
})
|
||||
}
|
||||
return &pluginAdminFormJSON{
|
||||
Fields: fields,
|
||||
SubmitLabel: form.GetSubmitLabel(),
|
||||
Sections: sections,
|
||||
}
|
||||
}
|
||||
|
||||
func adminFormConditionsToJSON(conditions []*pluginv1.AdminFormCondition) []pluginAdminFormConditionJSON {
|
||||
if len(conditions) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]pluginAdminFormConditionJSON, 0, len(conditions))
|
||||
for _, condition := range conditions {
|
||||
if condition == nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, pluginAdminFormConditionJSON{
|
||||
Field: condition.GetField(),
|
||||
Equals: append([]string(nil), condition.GetEquals()...),
|
||||
})
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
return plugins.AdminFormViewFromProto(form)
|
||||
}
|
||||
|
||||
func capabilitiesToJSON(descriptors []*pluginv1.CapabilityDescriptor) []pluginCapabilityJSON {
|
||||
|
||||
@@ -16,7 +16,7 @@ type WatchProviderService interface {
|
||||
ListProviders() []watchsync.ProviderSummary
|
||||
StartDeviceAuth(ctx context.Context, userID int, profileID string, providerKey string) (watchsync.DeviceAuthSession, error)
|
||||
PollDeviceAuth(ctx context.Context, userID int, profileID string, providerKey string, sessionID string) (watchsync.Connection, error)
|
||||
ConnectAPIKey(ctx context.Context, userID int, profileID string, providerKey string, apiKey string) (watchsync.Connection, error)
|
||||
ConnectAPIKeyWithConfig(ctx context.Context, userID int, profileID string, providerKey string, apiKey string, connectionConfig watchsync.ConnectionConfigValues) (watchsync.Connection, error)
|
||||
GetConnectionStatus(ctx context.Context, userID int, profileID string, provider string) (watchsync.ConnectionStatus, error)
|
||||
UpdateConnection(ctx context.Context, userID int, profileID string, provider string, update watchsync.ConnectionUpdate) (watchsync.ConnectionStatus, error)
|
||||
DeleteConnection(ctx context.Context, userID int, profileID string, provider string) error
|
||||
@@ -147,13 +147,14 @@ func (h *WatchProviderHandler) HandleConnectAPIKey(w http.ResponseWriter, r *htt
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
APIKey string `json:"api_key"`
|
||||
APIKey string `json:"api_key"`
|
||||
ConnectionConfig watchsync.ConnectionConfigValues `json:"connection_config"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
||||
return
|
||||
}
|
||||
if _, err := h.service.ConnectAPIKey(r.Context(), userID, profileID, provider, req.APIKey); err != nil {
|
||||
if _, err := h.service.ConnectAPIKeyWithConfig(r.Context(), userID, profileID, provider, req.APIKey, req.ConnectionConfig); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "watch_provider_error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -23,6 +25,25 @@ type stubWatchProviderService struct {
|
||||
runs []watchsync.SyncRun
|
||||
}
|
||||
|
||||
type capturingWatchProviderService struct {
|
||||
stubWatchProviderService
|
||||
apiKey string
|
||||
config watchsync.ConnectionConfigValues
|
||||
}
|
||||
|
||||
func (s *capturingWatchProviderService) ConnectAPIKeyWithConfig(
|
||||
_ context.Context,
|
||||
_ int,
|
||||
_ string,
|
||||
_ string,
|
||||
apiKey string,
|
||||
config watchsync.ConnectionConfigValues,
|
||||
) (watchsync.Connection, error) {
|
||||
s.apiKey = apiKey
|
||||
s.config = config
|
||||
return watchsync.Connection{}, nil
|
||||
}
|
||||
|
||||
func (s stubWatchProviderService) ListProviders() []watchsync.ProviderSummary {
|
||||
return s.providers
|
||||
}
|
||||
@@ -32,7 +53,7 @@ func (s stubWatchProviderService) StartDeviceAuth(context.Context, int, string,
|
||||
func (s stubWatchProviderService) PollDeviceAuth(context.Context, int, string, string, string) (watchsync.Connection, error) {
|
||||
return watchsync.Connection{}, nil
|
||||
}
|
||||
func (s stubWatchProviderService) ConnectAPIKey(context.Context, int, string, string, string) (watchsync.Connection, error) {
|
||||
func (s stubWatchProviderService) ConnectAPIKeyWithConfig(context.Context, int, string, string, string, watchsync.ConnectionConfigValues) (watchsync.Connection, error) {
|
||||
return watchsync.Connection{}, nil
|
||||
}
|
||||
func (s stubWatchProviderService) GetConnectionStatus(context.Context, int, string, string) (watchsync.ConnectionStatus, error) {
|
||||
@@ -89,7 +110,7 @@ func TestWatchProviderHandlerListsProviders(t *testing.T) {
|
||||
if len(resp.Providers) != 1 {
|
||||
t.Fatalf("providers length = %d, want 1", len(resp.Providers))
|
||||
}
|
||||
if resp.Providers[0] != service.providers[0] {
|
||||
if !reflect.DeepEqual(resp.Providers[0], service.providers[0]) {
|
||||
t.Fatalf("provider = %#v, want %#v", resp.Providers[0], service.providers[0])
|
||||
}
|
||||
}
|
||||
@@ -175,6 +196,31 @@ func TestWatchProviderHandlerStartsDeviceAuthWithFrontendJSONShape(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatchProviderHandlerPassesConnectionConfigWithAPIKey(t *testing.T) {
|
||||
service := &capturingWatchProviderService{}
|
||||
handler := NewWatchProviderHandler(service)
|
||||
req := httptest.NewRequest(http.MethodPost, "/watch-providers/plugin:4:floppy/auth/api-key", strings.NewReader(`{
|
||||
"api_key":"token",
|
||||
"connection_config":{"floppy":{"base_url":"https://floppy.example.com"}}
|
||||
}`))
|
||||
routeCtx := chi.NewRouteContext()
|
||||
routeCtx.URLParams.Add("provider", "plugin:4:floppy")
|
||||
ctx := context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx)
|
||||
ctx = middleware.SetClaims(ctx, &auth.Claims{UserID: 7})
|
||||
ctx = middleware.SetProfileID(ctx, "profile-1")
|
||||
req = req.WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.HandleConnectAPIKey(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
if service.apiKey != "token" || service.config["floppy"]["base_url"] != "https://floppy.example.com" {
|
||||
t.Fatalf("api key = %q, config = %#v", service.apiKey, service.config)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatchProviderHandlerManualSyncReturnsRun(t *testing.T) {
|
||||
startedAt := time.Date(2026, 5, 4, 16, 0, 0, 0, time.UTC)
|
||||
handler := NewWatchProviderHandler(stubWatchProviderService{
|
||||
|
||||
@@ -23,7 +23,9 @@ const (
|
||||
DefaultEventTimeout = 10 * time.Second
|
||||
DefaultAuthTimeout = 10 * time.Second
|
||||
DefaultRouteTimeout = 10 * time.Second
|
||||
DefaultWatchSyncTimeout = 60 * time.Second
|
||||
// Watch-sync providers may resolve cold external metadata before applying
|
||||
// an event. Bound that work without cutting off valid slow provider calls.
|
||||
DefaultWatchSyncTimeout = 2 * time.Minute
|
||||
// DefaultRequestRouterTimeout bounds a single request_router RPC.
|
||||
// Fulfillment hits remote arr instances, so allow generous headroom.
|
||||
DefaultRequestRouterTimeout = 60 * time.Second
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1"
|
||||
)
|
||||
|
||||
// ConfigSchemaView is the transport-safe representation of a manifest config
|
||||
// schema used by non-admin setup surfaces.
|
||||
type ConfigSchemaView struct {
|
||||
Key string `json:"key"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
JSONSchema string `json:"json_schema"`
|
||||
Required bool `json:"required"`
|
||||
AdminForm *AdminFormView `json:"admin_form,omitempty"`
|
||||
}
|
||||
|
||||
type AdminFormView struct {
|
||||
Fields []AdminFormFieldView `json:"fields"`
|
||||
SubmitLabel string `json:"submit_label,omitempty"`
|
||||
Sections []AdminFormSectionView `json:"sections,omitempty"`
|
||||
}
|
||||
|
||||
type AdminFormFieldView struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Control string `json:"control"`
|
||||
Placeholder string `json:"placeholder,omitempty"`
|
||||
Required bool `json:"required"`
|
||||
Secret bool `json:"secret"`
|
||||
Multiline bool `json:"multiline"`
|
||||
DefaultValue any `json:"default_value,omitempty"`
|
||||
Options []AdminFormOptionView `json:"options,omitempty"`
|
||||
Rows int32 `json:"rows,omitempty"`
|
||||
DynamicOptions bool `json:"dynamic_options,omitempty"`
|
||||
ShowWhen []AdminFormConditionView `json:"show_when,omitempty"`
|
||||
Validation *AdminFormValidationView `json:"validation,omitempty"`
|
||||
ExclusiveGroupField string `json:"exclusive_group_field,omitempty"`
|
||||
}
|
||||
|
||||
type AdminFormOptionView struct {
|
||||
Value string `json:"value"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
type AdminFormConditionView struct {
|
||||
Field string `json:"field"`
|
||||
Equals []string `json:"equals"`
|
||||
}
|
||||
|
||||
type AdminFormValidationView struct {
|
||||
HasMin bool `json:"has_min,omitempty"`
|
||||
Min float64 `json:"min,omitempty"`
|
||||
HasMax bool `json:"has_max,omitempty"`
|
||||
Max float64 `json:"max,omitempty"`
|
||||
Pattern string `json:"pattern,omitempty"`
|
||||
MinLength int32 `json:"min_length,omitempty"`
|
||||
MaxLength int32 `json:"max_length,omitempty"`
|
||||
}
|
||||
|
||||
type AdminFormSectionView struct {
|
||||
Key string `json:"key"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Collapsible bool `json:"collapsible"`
|
||||
CollapsedDefault bool `json:"collapsed_default"`
|
||||
FieldKeys []string `json:"field_keys"`
|
||||
ShowWhen []AdminFormConditionView `json:"show_when,omitempty"`
|
||||
}
|
||||
|
||||
func ConfigSchemaViews(schemas []*pluginv1.ConfigSchema) []ConfigSchemaView {
|
||||
views := make([]ConfigSchemaView, 0, len(schemas))
|
||||
for _, schema := range schemas {
|
||||
if schema == nil {
|
||||
continue
|
||||
}
|
||||
views = append(views, ConfigSchemaView{
|
||||
Key: schema.GetKey(),
|
||||
Title: schema.GetTitle(),
|
||||
Description: schema.GetDescription(),
|
||||
JSONSchema: schema.GetJsonSchema(),
|
||||
Required: schema.GetRequired(),
|
||||
AdminForm: AdminFormViewFromProto(schema.GetAdminForm()),
|
||||
})
|
||||
}
|
||||
return views
|
||||
}
|
||||
|
||||
func AdminFormViewFromProto(form *pluginv1.AdminFormDescriptor) *AdminFormView {
|
||||
if form == nil {
|
||||
return nil
|
||||
}
|
||||
fields := make([]AdminFormFieldView, 0, len(form.GetFields()))
|
||||
for _, field := range form.GetFields() {
|
||||
if field == nil {
|
||||
continue
|
||||
}
|
||||
options := make([]AdminFormOptionView, 0, len(field.GetOptions()))
|
||||
for _, option := range field.GetOptions() {
|
||||
if option != nil {
|
||||
options = append(options, AdminFormOptionView{Value: option.GetValue(), Label: option.GetLabel(), Description: option.GetDescription()})
|
||||
}
|
||||
}
|
||||
var defaultValue any
|
||||
if field.GetDefaultValue() != nil {
|
||||
defaultValue = field.GetDefaultValue().AsInterface()
|
||||
}
|
||||
var validation *AdminFormValidationView
|
||||
if value := field.GetValidation(); value != nil {
|
||||
validation = &AdminFormValidationView{
|
||||
HasMin: value.GetHasMin(), Min: value.GetMin(), HasMax: value.GetHasMax(), Max: value.GetMax(),
|
||||
Pattern: value.GetPattern(), MinLength: value.GetMinLength(), MaxLength: value.GetMaxLength(),
|
||||
}
|
||||
}
|
||||
fields = append(fields, AdminFormFieldView{
|
||||
Key: field.GetKey(), Label: field.GetLabel(), Description: field.GetDescription(),
|
||||
Control: strings.TrimPrefix(field.GetControl().String(), "ADMIN_FORM_CONTROL_"),
|
||||
Placeholder: field.GetPlaceholder(), Required: field.GetRequired(), Secret: field.GetSecret(),
|
||||
Multiline: field.GetMultiline(), DefaultValue: defaultValue, Options: options, Rows: field.GetRows(),
|
||||
DynamicOptions: field.GetDynamicOptions(), ShowWhen: adminFormConditionViews(field.GetShowWhen()),
|
||||
Validation: validation, ExclusiveGroupField: field.GetExclusiveGroupField(),
|
||||
})
|
||||
}
|
||||
sections := make([]AdminFormSectionView, 0, len(form.GetSections()))
|
||||
for _, section := range form.GetSections() {
|
||||
if section == nil {
|
||||
continue
|
||||
}
|
||||
sections = append(sections, AdminFormSectionView{
|
||||
Key: section.GetKey(), Title: section.GetTitle(), Description: section.GetDescription(),
|
||||
Collapsible: section.GetCollapsible(), CollapsedDefault: section.GetCollapsedDefault(),
|
||||
FieldKeys: append([]string(nil), section.GetFieldKeys()...), ShowWhen: adminFormConditionViews(section.GetShowWhen()),
|
||||
})
|
||||
}
|
||||
return &AdminFormView{Fields: fields, SubmitLabel: form.GetSubmitLabel(), Sections: sections}
|
||||
}
|
||||
|
||||
func adminFormConditionViews(conditions []*pluginv1.AdminFormCondition) []AdminFormConditionView {
|
||||
views := make([]AdminFormConditionView, 0, len(conditions))
|
||||
for _, condition := range conditions {
|
||||
if condition != nil {
|
||||
views = append(views, AdminFormConditionView{Field: condition.GetField(), Equals: append([]string(nil), condition.GetEquals()...)})
|
||||
}
|
||||
}
|
||||
return views
|
||||
}
|
||||
@@ -16,9 +16,16 @@ func GlobalConfigFieldSets(
|
||||
manifest *pluginv1.PluginManifest,
|
||||
configKey string,
|
||||
) (publicFields, secretFields []string) {
|
||||
return ConfigSchemaFieldSets(globalConfigSchema(manifest, configKey))
|
||||
}
|
||||
|
||||
// ConfigSchemaFieldSets returns the top-level public and secret fields for one
|
||||
// configuration schema. JSON Schema annotations and Admin form controls are
|
||||
// both authoritative; undeclared fields are intentionally absent from both
|
||||
// sets so callers can keep them on the protected path.
|
||||
func ConfigSchemaFieldSets(schema *pluginv1.ConfigSchema) (publicFields, secretFields []string) {
|
||||
declared := make(map[string]struct{})
|
||||
secrets := make(map[string]struct{})
|
||||
schema := globalConfigSchema(manifest, configKey)
|
||||
if schema != nil {
|
||||
if form := schema.GetAdminForm(); form != nil {
|
||||
for _, field := range form.GetFields() {
|
||||
|
||||
@@ -22,6 +22,11 @@ func TestWatchSyncProviderConfigClassifiesManifestFields(t *testing.T) {
|
||||
"undeclared": map[string]any{"token": "also-secret"},
|
||||
" ": "ignored-empty-field",
|
||||
},
|
||||
}, {
|
||||
Key: "legacy",
|
||||
Value: map[string]any{
|
||||
"base_url": "https://legacy-floppy.example",
|
||||
},
|
||||
}, {
|
||||
Key: " ",
|
||||
Value: map[string]any{
|
||||
@@ -33,7 +38,8 @@ func TestWatchSyncProviderConfigClassifiesManifestFields(t *testing.T) {
|
||||
}
|
||||
if config.GetValues()["provider.base_url"] != "https://floppy.example" ||
|
||||
config.GetSecretValues()["provider.client_secret"] != "secret" ||
|
||||
config.GetSecretValues()["provider.undeclared"] != `{"token":"also-secret"}` {
|
||||
config.GetSecretValues()["provider.undeclared"] != `{"token":"also-secret"}` ||
|
||||
config.GetSecretValues()["legacy.base_url"] != "https://legacy-floppy.example" {
|
||||
t.Fatalf("config = %#v", config)
|
||||
}
|
||||
if _, exposed := config.GetValues()["provider.undeclared"]; exposed {
|
||||
|
||||
@@ -3,15 +3,21 @@ package watchsync
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1"
|
||||
publicconfig "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginsdk/config"
|
||||
"github.com/Silo-Server/silo-server/internal/historyimport"
|
||||
hostplugins "github.com/Silo-Server/silo-server/internal/plugins"
|
||||
"github.com/Silo-Server/silo-server/internal/userstore"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
@@ -34,33 +40,37 @@ type PluginCredentialRepository interface {
|
||||
}
|
||||
|
||||
type PluginProviderOptions struct {
|
||||
InstallationID int
|
||||
ProviderKey string
|
||||
CapabilityID string
|
||||
DisplayName string
|
||||
Descriptor *pluginv1.WatchSyncProviderDescriptor
|
||||
ResolveClient WatchSyncPluginClientResolver
|
||||
ResolveConfig WatchSyncPluginConfigResolver
|
||||
Repository PluginCredentialRepository
|
||||
InstallationID int
|
||||
ProviderKey string
|
||||
CapabilityID string
|
||||
DisplayName string
|
||||
Descriptor *pluginv1.WatchSyncProviderDescriptor
|
||||
ConnectionConfigSchema []*pluginv1.ConfigSchema
|
||||
ResolveClient WatchSyncPluginClientResolver
|
||||
ResolveConfig WatchSyncPluginConfigResolver
|
||||
Repository PluginCredentialRepository
|
||||
}
|
||||
|
||||
type PluginProvider struct {
|
||||
installationID int
|
||||
providerKey string
|
||||
capabilityID string
|
||||
displayName string
|
||||
descriptor *pluginv1.WatchSyncProviderDescriptor
|
||||
authMethod string
|
||||
supportedMedia map[pluginv1.WatchSyncMediaType]struct{}
|
||||
resolveClient WatchSyncPluginClientResolver
|
||||
resolveConfig WatchSyncPluginConfigResolver
|
||||
repository PluginCredentialRepository
|
||||
installationID int
|
||||
providerKey string
|
||||
capabilityID string
|
||||
displayName string
|
||||
descriptor *pluginv1.WatchSyncProviderDescriptor
|
||||
connectionConfigSchema []*pluginv1.ConfigSchema
|
||||
authMethod string
|
||||
supportedMedia map[pluginv1.WatchSyncMediaType]struct{}
|
||||
resolveClient WatchSyncPluginClientResolver
|
||||
resolveConfig WatchSyncPluginConfigResolver
|
||||
repository PluginCredentialRepository
|
||||
}
|
||||
|
||||
const (
|
||||
watchSyncUnsupportedMovieMediaMessage = "watch sync plugin does not support movie media"
|
||||
watchSyncUnsupportedEpisodeMediaMessage = "watch sync plugin does not support episode media"
|
||||
watchSyncUnsupportedMediaMessage = "watch sync plugin does not support this media type"
|
||||
watchSyncJSONSchemaNumberType = "number"
|
||||
watchSyncJSONSchemaBooleanType = "boolean"
|
||||
)
|
||||
|
||||
func NewPluginProvider(options PluginProviderOptions) (*PluginProvider, error) {
|
||||
@@ -81,23 +91,39 @@ func NewPluginProvider(options PluginProviderOptions) (*PluginProvider, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("watch sync plugin %q %w", options.ProviderKey, err)
|
||||
}
|
||||
if err := validateWatchSyncConnectionConfigSchemas(options.ConnectionConfigSchema); err != nil {
|
||||
return nil, fmt.Errorf("watch sync plugin %q %w", options.ProviderKey, err)
|
||||
}
|
||||
if authMethod != AuthMethodAPIKey && hasWatchSyncConnectionConfigSchema(options.ConnectionConfigSchema) {
|
||||
return nil, fmt.Errorf("watch sync plugin %q connection config requires API-key authentication", options.ProviderKey)
|
||||
}
|
||||
if options.ResolveClient == nil {
|
||||
return nil, fmt.Errorf("watch sync plugin client resolver is required")
|
||||
}
|
||||
return &PluginProvider{
|
||||
installationID: options.InstallationID,
|
||||
providerKey: options.ProviderKey,
|
||||
capabilityID: options.CapabilityID,
|
||||
displayName: options.DisplayName,
|
||||
descriptor: options.Descriptor,
|
||||
authMethod: authMethod,
|
||||
supportedMedia: supportedMedia,
|
||||
resolveClient: options.ResolveClient,
|
||||
resolveConfig: options.ResolveConfig,
|
||||
repository: options.Repository,
|
||||
installationID: options.InstallationID,
|
||||
providerKey: options.ProviderKey,
|
||||
capabilityID: options.CapabilityID,
|
||||
displayName: options.DisplayName,
|
||||
descriptor: options.Descriptor,
|
||||
connectionConfigSchema: append([]*pluginv1.ConfigSchema(nil), options.ConnectionConfigSchema...),
|
||||
authMethod: authMethod,
|
||||
supportedMedia: supportedMedia,
|
||||
resolveClient: options.ResolveClient,
|
||||
resolveConfig: options.ResolveConfig,
|
||||
repository: options.Repository,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func hasWatchSyncConnectionConfigSchema(schemas []*pluginv1.ConfigSchema) bool {
|
||||
for _, schema := range schemas {
|
||||
if schema != nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (p *PluginProvider) Key() string { return p.providerKey }
|
||||
|
||||
func (p *PluginProvider) DisplayName() string {
|
||||
@@ -118,6 +144,10 @@ func (p *PluginProvider) HistorySource() userstore.WatchHistorySource {
|
||||
|
||||
func (p *PluginProvider) AuthMethod() string { return p.authMethod }
|
||||
|
||||
func (p *PluginProvider) ConnectionConfigSchema() []hostplugins.ConfigSchemaView {
|
||||
return hostplugins.ConfigSchemaViews(p.connectionConfigSchema)
|
||||
}
|
||||
|
||||
func (p *PluginProvider) usesHostPluginConfig() {}
|
||||
|
||||
func (p *PluginProvider) authoritativeRefreshProvider() {}
|
||||
@@ -147,6 +177,14 @@ func (p *PluginProvider) Capabilities() Capabilities {
|
||||
}
|
||||
|
||||
func (p *PluginProvider) ConnectWithAPIKey(ctx context.Context, apiKey string) (TokenSet, ProviderAccount, error) {
|
||||
return p.ConnectWithAPIKeyConfig(ctx, apiKey, nil)
|
||||
}
|
||||
|
||||
func (p *PluginProvider) ConnectWithAPIKeyConfig(
|
||||
ctx context.Context,
|
||||
apiKey string,
|
||||
connectionConfig ConnectionConfigValues,
|
||||
) (TokenSet, ProviderAccount, error) {
|
||||
if p.authMethod != AuthMethodAPIKey {
|
||||
return TokenSet{}, ProviderAccount{}, errors.New("watch sync plugin does not support API-key authentication")
|
||||
}
|
||||
@@ -154,6 +192,11 @@ func (p *PluginProvider) ConnectWithAPIKey(ctx context.Context, apiKey string) (
|
||||
if err != nil {
|
||||
return TokenSet{}, ProviderAccount{}, err
|
||||
}
|
||||
connectionValues, connectionSecrets, err := p.connectionConfig(connectionConfig)
|
||||
if err != nil {
|
||||
return TokenSet{}, ProviderAccount{}, err
|
||||
}
|
||||
config = mergeWatchSyncProviderConfig(config, connectionValues)
|
||||
client, err := p.resolveClient(ctx, p.installationID, p.capabilityID)
|
||||
if err != nil {
|
||||
return TokenSet{}, ProviderAccount{}, watchSyncUnavailableError()
|
||||
@@ -166,7 +209,8 @@ func (p *PluginProvider) ConnectWithAPIKey(ctx context.Context, apiKey string) (
|
||||
if err != nil {
|
||||
return TokenSet{}, ProviderAccount{}, watchSyncRPCError()
|
||||
}
|
||||
if err := watchSyncFaultError(p.Key(), response.GetFault(), apiKey); err != nil {
|
||||
faultSecrets := append([]string{apiKey}, connectionSecrets...)
|
||||
if err := watchSyncFaultError(p.Key(), response.GetFault(), faultSecrets...); err != nil {
|
||||
return TokenSet{}, ProviderAccount{}, err
|
||||
}
|
||||
tokens, err := tokenSetFromProto(response.GetCredentials())
|
||||
@@ -533,6 +577,497 @@ func (p *PluginProvider) providerConfig(ctx context.Context) (*pluginv1.WatchSyn
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func (p *PluginProvider) connectionConfig(values ConnectionConfigValues) (*pluginv1.WatchSyncProviderConfig, []string, error) {
|
||||
declared := make(map[string]*pluginv1.ConfigSchema, len(p.connectionConfigSchema))
|
||||
for _, schema := range p.connectionConfigSchema {
|
||||
if schema != nil && strings.TrimSpace(schema.GetKey()) != "" {
|
||||
declared[schema.GetKey()] = schema
|
||||
}
|
||||
}
|
||||
secrets := connectionConfigSecrets(p.connectionConfigSchema, values)
|
||||
for key := range values {
|
||||
if _, ok := declared[key]; !ok {
|
||||
return nil, nil, sanitizedConnectionConfigError(
|
||||
fmt.Errorf("watch sync connection config key %q is not declared", key),
|
||||
secrets,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
result := &pluginv1.WatchSyncProviderConfig{
|
||||
Values: make(map[string]string),
|
||||
SecretValues: make(map[string]string),
|
||||
}
|
||||
flattenedFields := make(map[string]string)
|
||||
for _, schema := range p.connectionConfigSchema {
|
||||
if schema == nil || strings.TrimSpace(schema.GetKey()) == "" {
|
||||
continue
|
||||
}
|
||||
value, exists := values[schema.GetKey()]
|
||||
if !exists {
|
||||
if schema.GetRequired() {
|
||||
return nil, nil, fmt.Errorf("watch sync connection config %q is required", schema.GetKey())
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := publicconfig.ValidateValue(schema, "watch sync connection config", schema.GetKey(), value); err != nil {
|
||||
return nil, nil, sanitizedConnectionConfigError(err, secrets)
|
||||
}
|
||||
if err := validateConnectionAdminFormValue(schema, value); err != nil {
|
||||
return nil, nil, sanitizedConnectionConfigError(err, secrets)
|
||||
}
|
||||
publicFieldNames, _ := hostplugins.ConfigSchemaFieldSets(schema)
|
||||
publicFields := make(map[string]struct{}, len(publicFieldNames))
|
||||
for _, field := range publicFieldNames {
|
||||
publicFields[field] = struct{}{}
|
||||
}
|
||||
for field, raw := range value {
|
||||
rawField := field
|
||||
field = strings.TrimSpace(field)
|
||||
if field == "" {
|
||||
continue
|
||||
}
|
||||
encoded, err := connectionConfigString(raw)
|
||||
if err != nil {
|
||||
return nil, nil, sanitizedConnectionConfigError(
|
||||
fmt.Errorf("encode watch sync connection config %q.%s: %w", schema.GetKey(), field, err),
|
||||
secrets,
|
||||
)
|
||||
}
|
||||
key := schema.GetKey() + "." + field
|
||||
source := fmt.Sprintf("config %q field %q", schema.GetKey(), rawField)
|
||||
if previous, exists := flattenedFields[key]; exists {
|
||||
return nil, nil, sanitizedConnectionConfigError(
|
||||
fmt.Errorf("watch sync connection %s conflicts with %s after flattening to %q", source, previous, key),
|
||||
secrets,
|
||||
)
|
||||
}
|
||||
flattenedFields[key] = source
|
||||
if _, public := publicFields[field]; public {
|
||||
result.Values[key] = encoded
|
||||
} else {
|
||||
result.SecretValues[key] = encoded
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, secrets, nil
|
||||
}
|
||||
|
||||
func connectionConfigSecrets(schemas []*pluginv1.ConfigSchema, values ConnectionConfigValues) []string {
|
||||
var secrets []string
|
||||
for _, schema := range schemas {
|
||||
if schema == nil {
|
||||
continue
|
||||
}
|
||||
value, exists := values[schema.GetKey()]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
_, secretFields := hostplugins.ConfigSchemaFieldSets(schema)
|
||||
for _, field := range secretFields {
|
||||
raw, exists := value[field]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
if encoded, err := connectionConfigString(raw); err == nil && strings.TrimSpace(encoded) != "" {
|
||||
secrets = append(secrets, encoded)
|
||||
}
|
||||
secrets = append(secrets, connectionConfigSecretStrings(raw)...)
|
||||
}
|
||||
}
|
||||
return secrets
|
||||
}
|
||||
|
||||
func sanitizedConnectionConfigError(err error, secrets []string) error {
|
||||
return errors.New(sanitizeWatchSyncMessage(
|
||||
err.Error(),
|
||||
"watch sync connection config is invalid",
|
||||
secrets...,
|
||||
))
|
||||
}
|
||||
|
||||
func connectionConfigSecretStrings(value any) []string {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
var values []string
|
||||
for _, child := range typed {
|
||||
values = append(values, connectionConfigSecretStrings(child)...)
|
||||
}
|
||||
return values
|
||||
case []any:
|
||||
var values []string
|
||||
for _, child := range typed {
|
||||
values = append(values, connectionConfigSecretStrings(child)...)
|
||||
}
|
||||
return values
|
||||
default:
|
||||
encoded, err := connectionConfigString(typed)
|
||||
if err != nil || strings.TrimSpace(encoded) == "" {
|
||||
return nil
|
||||
}
|
||||
return []string{encoded}
|
||||
}
|
||||
}
|
||||
|
||||
func validateWatchSyncConnectionConfigSchemas(schemas []*pluginv1.ConfigSchema) error {
|
||||
seen := make(map[string]struct{}, len(schemas))
|
||||
for _, schema := range schemas {
|
||||
if schema == nil {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(schema.GetKey())
|
||||
if key == "" {
|
||||
return fmt.Errorf("connection config key is required")
|
||||
}
|
||||
if _, exists := seen[key]; exists {
|
||||
return fmt.Errorf("connection config key %q is duplicated", key)
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
for _, field := range schema.GetAdminForm().GetFields() {
|
||||
if field == nil {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(field.GetExclusiveGroupField()) != "" {
|
||||
return fmt.Errorf(
|
||||
"connection config %q field %q uses exclusive_group_field, which watch provider setup does not support",
|
||||
schema.GetKey(),
|
||||
field.GetKey(),
|
||||
)
|
||||
}
|
||||
if field.GetDynamicOptions() && len(field.GetOptions()) == 0 {
|
||||
return fmt.Errorf(
|
||||
"connection config %q field %q requires dynamic options, which watch provider setup does not support",
|
||||
schema.GetKey(),
|
||||
field.GetKey(),
|
||||
)
|
||||
}
|
||||
if field.GetControl() == pluginv1.AdminFormControl_ADMIN_FORM_CONTROL_SELECT {
|
||||
for _, option := range field.GetOptions() {
|
||||
if option != nil && strings.TrimSpace(option.GetValue()) == "" {
|
||||
return fmt.Errorf(
|
||||
"connection config %q field %q has a blank select option value",
|
||||
schema.GetKey(),
|
||||
field.GetKey(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if pattern := field.GetValidation().GetPattern(); pattern != "" {
|
||||
if _, err := regexp.Compile(pattern); err != nil {
|
||||
return fmt.Errorf(
|
||||
"connection config %q field %q has invalid validation pattern: %w",
|
||||
schema.GetKey(),
|
||||
field.GetKey(),
|
||||
err,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := validateConnectionSchemaIsRenderable(schema); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateConnectionSchemaIsRenderable(schema *pluginv1.ConfigSchema) error {
|
||||
if strings.TrimSpace(schema.GetJsonSchema()) == "" && !schema.GetRequired() {
|
||||
return nil
|
||||
}
|
||||
var document struct {
|
||||
Type string `json:"type"`
|
||||
Properties map[string]struct {
|
||||
Type string `json:"type"`
|
||||
Items *struct {
|
||||
Type string `json:"type"`
|
||||
} `json:"items"`
|
||||
} `json:"properties"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(schema.GetJsonSchema()), &document); err != nil {
|
||||
return fmt.Errorf("connection config %q has invalid json_schema: %w", schema.GetKey(), err)
|
||||
}
|
||||
if document.Type != "object" || document.Properties == nil {
|
||||
return fmt.Errorf("connection config %q must have a renderable object json_schema", schema.GetKey())
|
||||
}
|
||||
explicit := make(map[string]*pluginv1.AdminFormField)
|
||||
if form := schema.GetAdminForm(); form != nil {
|
||||
for _, field := range form.GetFields() {
|
||||
if field != nil {
|
||||
explicit[field.GetKey()] = field
|
||||
}
|
||||
}
|
||||
}
|
||||
for key, property := range document.Properties {
|
||||
field := explicit[key]
|
||||
switch property.Type {
|
||||
case "string", watchSyncJSONSchemaNumberType, "integer", watchSyncJSONSchemaBooleanType:
|
||||
if field != nil && !connectionAdminFieldSupportsScalarType(field, property.Type) {
|
||||
return fmt.Errorf(
|
||||
"connection config %q field %q control %q cannot emit json_schema type %q",
|
||||
schema.GetKey(),
|
||||
key,
|
||||
field.GetControl().String(),
|
||||
property.Type,
|
||||
)
|
||||
}
|
||||
continue
|
||||
case "array":
|
||||
if field != nil && field.GetControl() == pluginv1.AdminFormControl_ADMIN_FORM_CONTROL_MULTI_SELECT &&
|
||||
(property.Items == nil || property.Items.Type == "string" || property.Items.Type == watchSyncJSONSchemaNumberType ||
|
||||
property.Items.Type == "integer" || property.Items.Type == watchSyncJSONSchemaBooleanType) {
|
||||
continue
|
||||
}
|
||||
default:
|
||||
// A property whose shape comes from enum/const/$ref cannot be
|
||||
// inferred from type alone, but an explicit scalar form control is
|
||||
// still a complete input mechanism. Direct object properties remain
|
||||
// unsupported because none of these controls produces an object.
|
||||
if property.Type != "object" && connectionAdminFieldRendersValue(field) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
return fmt.Errorf(
|
||||
"connection config %q property %q needs a renderable admin_form field because type %q cannot be inferred",
|
||||
schema.GetKey(),
|
||||
key,
|
||||
property.Type,
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func connectionAdminFieldSupportsScalarType(field *pluginv1.AdminFormField, propertyType string) bool {
|
||||
switch field.GetControl() {
|
||||
case pluginv1.AdminFormControl_ADMIN_FORM_CONTROL_SWITCH:
|
||||
return propertyType == watchSyncJSONSchemaBooleanType
|
||||
case pluginv1.AdminFormControl_ADMIN_FORM_CONTROL_MULTI_SELECT:
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func connectionAdminFieldRendersValue(field *pluginv1.AdminFormField) bool {
|
||||
if field == nil {
|
||||
return false
|
||||
}
|
||||
switch field.GetControl() {
|
||||
case pluginv1.AdminFormControl_ADMIN_FORM_CONTROL_TEXT,
|
||||
pluginv1.AdminFormControl_ADMIN_FORM_CONTROL_TEXTAREA,
|
||||
pluginv1.AdminFormControl_ADMIN_FORM_CONTROL_PASSWORD,
|
||||
pluginv1.AdminFormControl_ADMIN_FORM_CONTROL_NUMBER,
|
||||
pluginv1.AdminFormControl_ADMIN_FORM_CONTROL_SWITCH,
|
||||
pluginv1.AdminFormControl_ADMIN_FORM_CONTROL_SELECT,
|
||||
pluginv1.AdminFormControl_ADMIN_FORM_CONTROL_MULTI_SELECT:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func validateConnectionAdminFormValue(schema *pluginv1.ConfigSchema, value map[string]any) error {
|
||||
if schema == nil || schema.GetAdminForm() == nil {
|
||||
return nil
|
||||
}
|
||||
for _, field := range schema.GetAdminForm().GetFields() {
|
||||
if field == nil || !connectionAdminFieldIsVisible(schema.GetAdminForm(), field, value) {
|
||||
continue
|
||||
}
|
||||
raw, exists := value[field.GetKey()]
|
||||
if !exists || connectionConfigValueIsEmpty(raw) {
|
||||
if field.GetRequired() {
|
||||
return fmt.Errorf("connection config %q field %q is required", schema.GetKey(), field.GetKey())
|
||||
}
|
||||
continue
|
||||
}
|
||||
if field.GetValidation() == nil {
|
||||
continue
|
||||
}
|
||||
validation := field.GetValidation()
|
||||
if text, ok := raw.(string); ok {
|
||||
if pattern := validation.GetPattern(); pattern != "" {
|
||||
matched, err := regexp.MatchString(pattern, text)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connection config %q field %q has an invalid validation pattern", schema.GetKey(), field.GetKey())
|
||||
}
|
||||
if !matched {
|
||||
return fmt.Errorf("connection config %q field %q is invalid", schema.GetKey(), field.GetKey())
|
||||
}
|
||||
}
|
||||
length := utf8.RuneCountInString(text)
|
||||
if minimum := int(validation.GetMinLength()); minimum > 0 && length < minimum {
|
||||
return fmt.Errorf("connection config %q field %q must be at least %d characters", schema.GetKey(), field.GetKey(), minimum)
|
||||
}
|
||||
if maximum := int(validation.GetMaxLength()); maximum > 0 && length > maximum {
|
||||
return fmt.Errorf("connection config %q field %q must be at most %d characters", schema.GetKey(), field.GetKey(), maximum)
|
||||
}
|
||||
}
|
||||
if number, ok := connectionConfigNumber(raw); ok {
|
||||
if validation.GetHasMin() && number < validation.GetMin() {
|
||||
return fmt.Errorf("connection config %q field %q must be at least %g", schema.GetKey(), field.GetKey(), validation.GetMin())
|
||||
}
|
||||
if validation.GetHasMax() && number > validation.GetMax() {
|
||||
return fmt.Errorf("connection config %q field %q must be at most %g", schema.GetKey(), field.GetKey(), validation.GetMax())
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func connectionConfigValueIsEmpty(value any) bool {
|
||||
switch typed := value.(type) {
|
||||
case nil:
|
||||
return true
|
||||
case string:
|
||||
return strings.TrimSpace(typed) == ""
|
||||
case []any:
|
||||
return len(typed) == 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func connectionAdminFieldIsVisible(
|
||||
form *pluginv1.AdminFormDescriptor,
|
||||
field *pluginv1.AdminFormField,
|
||||
values map[string]any,
|
||||
) bool {
|
||||
if !connectionAdminConditionsMatch(field.GetShowWhen(), values, form.GetFields()) {
|
||||
return false
|
||||
}
|
||||
contained := false
|
||||
for _, section := range form.GetSections() {
|
||||
if section == nil || !stringSliceContains(section.GetFieldKeys(), field.GetKey()) {
|
||||
continue
|
||||
}
|
||||
contained = true
|
||||
if connectionAdminConditionsMatch(section.GetShowWhen(), values, form.GetFields()) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return !contained
|
||||
}
|
||||
|
||||
func connectionAdminConditionsMatch(
|
||||
conditions []*pluginv1.AdminFormCondition,
|
||||
values map[string]any,
|
||||
fields []*pluginv1.AdminFormField,
|
||||
) bool {
|
||||
for _, condition := range conditions {
|
||||
if condition == nil {
|
||||
continue
|
||||
}
|
||||
value, exists := values[condition.GetField()]
|
||||
if !exists {
|
||||
for _, field := range fields {
|
||||
if field != nil && field.GetKey() == condition.GetField() && field.GetDefaultValue() != nil {
|
||||
value = field.GetDefaultValue().AsInterface()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !stringSliceContains(condition.GetEquals(), connectionAdminConditionString(value)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func connectionAdminConditionString(value any) string {
|
||||
switch typed := value.(type) {
|
||||
case nil:
|
||||
return ""
|
||||
case bool:
|
||||
return strconv.FormatBool(typed)
|
||||
case float64:
|
||||
return strconv.FormatFloat(typed, 'f', -1, 64)
|
||||
case string:
|
||||
return typed
|
||||
default:
|
||||
return fmt.Sprint(typed)
|
||||
}
|
||||
}
|
||||
|
||||
func stringSliceContains(values []string, target string) bool {
|
||||
for _, value := range values {
|
||||
if value == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func connectionConfigNumber(value any) (float64, bool) {
|
||||
switch typed := value.(type) {
|
||||
case float64:
|
||||
return typed, true
|
||||
case float32:
|
||||
return float64(typed), true
|
||||
case int:
|
||||
return float64(typed), true
|
||||
case int8:
|
||||
return float64(typed), true
|
||||
case int16:
|
||||
return float64(typed), true
|
||||
case int32:
|
||||
return float64(typed), true
|
||||
case int64:
|
||||
return float64(typed), true
|
||||
case uint:
|
||||
return float64(typed), true
|
||||
case uint8:
|
||||
return float64(typed), true
|
||||
case uint16:
|
||||
return float64(typed), true
|
||||
case uint32:
|
||||
return float64(typed), true
|
||||
case uint64:
|
||||
return float64(typed), true
|
||||
case json.Number:
|
||||
number, err := typed.Float64()
|
||||
return number, err == nil
|
||||
case string:
|
||||
number, err := strconv.ParseFloat(strings.TrimSpace(typed), 64)
|
||||
return number, err == nil
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func connectionConfigString(value any) (string, error) {
|
||||
if text, ok := value.(string); ok {
|
||||
return text, nil
|
||||
}
|
||||
encoded, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(encoded), nil
|
||||
}
|
||||
|
||||
func mergeWatchSyncProviderConfig(base, connection *pluginv1.WatchSyncProviderConfig) *pluginv1.WatchSyncProviderConfig {
|
||||
merged := &pluginv1.WatchSyncProviderConfig{Values: map[string]string{}, SecretValues: map[string]string{}}
|
||||
if base != nil {
|
||||
for key, value := range base.GetValues() {
|
||||
merged.Values[key] = value
|
||||
}
|
||||
for key, value := range base.GetSecretValues() {
|
||||
merged.SecretValues[key] = value
|
||||
}
|
||||
}
|
||||
if connection != nil {
|
||||
for key, value := range connection.GetValues() {
|
||||
delete(merged.SecretValues, key)
|
||||
merged.Values[key] = value
|
||||
}
|
||||
for key, value := range connection.GetSecretValues() {
|
||||
delete(merged.Values, key)
|
||||
merged.SecretValues[key] = value
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
func (p *PluginProvider) persistUpdatedCredentials(
|
||||
ctx context.Context,
|
||||
conn Connection,
|
||||
@@ -587,6 +1122,7 @@ func watchEventFromScrobble(event ScrobbleEvent, operation pluginv1.WatchSyncOpe
|
||||
PositionSeconds: event.PositionSeconds,
|
||||
DurationSeconds: event.DurationSeconds,
|
||||
CompletionPercent: completion,
|
||||
Completed: event.Completed,
|
||||
ProviderItemKey: event.ProviderItemKey,
|
||||
Media: mediaFromIdentity(event.MediaItemID, event.Kind, "", 0,
|
||||
event.IMDbID, event.TMDBID, event.TVDBID, "", 0,
|
||||
|
||||
@@ -34,6 +34,7 @@ type fakeWatchSyncPluginClient struct {
|
||||
listResponses []*pluginv1.WatchSyncListRemoteStateResponse
|
||||
applyErr error
|
||||
applyRequest *pluginv1.WatchSyncApplyEventsRequest
|
||||
exchangeRequest *pluginv1.WatchSyncExchangeAPIKeyRequest
|
||||
refreshRequest *pluginv1.WatchSyncRefreshCredentialsRequest
|
||||
accountRequest *pluginv1.WatchSyncGetAccountRequest
|
||||
deviceStartRequest *pluginv1.WatchSyncDeviceAuthorizationServiceStartRequest
|
||||
@@ -57,7 +58,8 @@ func (f *fakeWatchSyncPluginClient) PollDeviceAuthorization(_ context.Context, r
|
||||
return &pluginv1.WatchSyncDeviceAuthorizationServicePollResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeWatchSyncPluginClient) ExchangeAPIKey(_ context.Context, _ *pluginv1.WatchSyncExchangeAPIKeyRequest) (*pluginv1.WatchSyncCredentialResponse, error) {
|
||||
func (f *fakeWatchSyncPluginClient) ExchangeAPIKey(_ context.Context, req *pluginv1.WatchSyncExchangeAPIKeyRequest) (*pluginv1.WatchSyncCredentialResponse, error) {
|
||||
f.exchangeRequest = req
|
||||
return f.exchangeResponse, nil
|
||||
}
|
||||
func (f *fakeWatchSyncPluginClient) RefreshCredentials(_ context.Context, req *pluginv1.WatchSyncRefreshCredentialsRequest) (*pluginv1.WatchSyncCredentialResponse, error) {
|
||||
@@ -215,6 +217,476 @@ func TestPluginProviderRejectsMissingAccountIdentity(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginProviderValidatesAndOverlaysConnectionConfig(t *testing.T) {
|
||||
client := &fakeWatchSyncPluginClient{exchangeResponse: &pluginv1.WatchSyncCredentialResponse{
|
||||
Credentials: &pluginv1.WatchSyncCredentials{AccessToken: testValidatedToken, TokenType: testBearerTokenType},
|
||||
Account: &pluginv1.WatchSyncAccount{ExternalSubject: "7", Username: testPluginUsername},
|
||||
}}
|
||||
schema := &pluginv1.ConfigSchema{
|
||||
Key: "floppy", Title: "Floppy server", Required: true,
|
||||
JsonSchema: `{"type":"object","properties":{"base_url":{"type":"string","format":"uri"}},"required":["base_url"],"additionalProperties":false}`,
|
||||
AdminForm: &pluginv1.AdminFormDescriptor{Fields: []*pluginv1.AdminFormField{{
|
||||
Key: "base_url", Label: "Base URL", Control: pluginv1.AdminFormControl_ADMIN_FORM_CONTROL_TEXT, Required: true,
|
||||
}}},
|
||||
}
|
||||
provider, err := NewPluginProvider(PluginProviderOptions{
|
||||
InstallationID: 4, ProviderKey: testPluginProviderKey, CapabilityID: testPluginCapabilityID,
|
||||
DisplayName: "Floppy", Descriptor: &pluginv1.WatchSyncProviderDescriptor{
|
||||
AuthMethods: []pluginv1.WatchSyncAuthMethod{pluginv1.WatchSyncAuthMethod_WATCH_SYNC_AUTH_METHOD_API_KEY},
|
||||
},
|
||||
ConnectionConfigSchema: []*pluginv1.ConfigSchema{schema},
|
||||
ResolveClient: func(context.Context, int, string) (WatchSyncPluginClient, error) { return client, nil },
|
||||
ResolveConfig: func(context.Context, int) (*pluginv1.WatchSyncProviderConfig, error) {
|
||||
return &pluginv1.WatchSyncProviderConfig{Values: map[string]string{"floppy.base_url": "https://legacy.example.com"}}, nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _, err = provider.ConnectWithAPIKeyConfig(context.Background(), "token", ConnectionConfigValues{
|
||||
"floppy": {"base_url": "https://personal.example.com"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := client.exchangeRequest.GetProviderConfig().GetValues()["floppy.base_url"]; got != "https://personal.example.com" {
|
||||
t.Fatalf("base URL = %q", got)
|
||||
}
|
||||
views := provider.ConnectionConfigSchema()
|
||||
if len(views) != 1 || views[0].AdminForm == nil || views[0].AdminForm.Fields[0].Control != "TEXT" {
|
||||
t.Fatalf("connection config schema = %#v", views)
|
||||
}
|
||||
if _, _, err := provider.ConnectWithAPIKeyConfig(context.Background(), "token", nil); err == nil || !strings.Contains(err.Error(), "required") {
|
||||
t.Fatalf("missing config error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginProviderClassifiesAndRedactsConnectionSecrets(t *testing.T) {
|
||||
client := &fakeWatchSyncPluginClient{exchangeResponse: &pluginv1.WatchSyncCredentialResponse{
|
||||
Fault: &pluginv1.WatchSyncFault{
|
||||
Code: pluginv1.WatchSyncFaultCode_WATCH_SYNC_FAULT_CODE_INVALID_CREDENTIAL,
|
||||
SafeMessage: "credentials " + testSecretValue + " were rejected",
|
||||
},
|
||||
}}
|
||||
schema := &pluginv1.ConfigSchema{
|
||||
Key: "account",
|
||||
JsonSchema: `{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"base_url":{"type":"string","format":"uri"},
|
||||
"client_secret":{"type":"string","format":"password"}
|
||||
},
|
||||
"required":["base_url","client_secret"],
|
||||
"additionalProperties":false
|
||||
}`,
|
||||
AdminForm: &pluginv1.AdminFormDescriptor{Fields: []*pluginv1.AdminFormField{
|
||||
{Key: "base_url", Control: pluginv1.AdminFormControl_ADMIN_FORM_CONTROL_TEXT},
|
||||
// JSON Schema remains authoritative even when a form incorrectly
|
||||
// presents a credential as ordinary text.
|
||||
{Key: "client_secret", Control: pluginv1.AdminFormControl_ADMIN_FORM_CONTROL_TEXT},
|
||||
}},
|
||||
}
|
||||
provider, err := NewPluginProvider(PluginProviderOptions{
|
||||
InstallationID: 4, ProviderKey: testPluginProviderKey, CapabilityID: testPluginCapabilityID,
|
||||
Descriptor: &pluginv1.WatchSyncProviderDescriptor{AuthMethods: []pluginv1.WatchSyncAuthMethod{
|
||||
pluginv1.WatchSyncAuthMethod_WATCH_SYNC_AUTH_METHOD_API_KEY,
|
||||
}},
|
||||
ConnectionConfigSchema: []*pluginv1.ConfigSchema{schema},
|
||||
ResolveClient: func(context.Context, int, string) (WatchSyncPluginClient, error) { return client, nil },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _, err = provider.ConnectWithAPIKeyConfig(context.Background(), "input-token", ConnectionConfigValues{
|
||||
"account": {
|
||||
"base_url": "https://floppy.example.com",
|
||||
"client_secret": testSecretValue,
|
||||
},
|
||||
})
|
||||
if !isWatchSyncInvalidCredentialError(err) {
|
||||
t.Fatalf("error = %#v", err)
|
||||
}
|
||||
config := client.exchangeRequest.GetProviderConfig()
|
||||
if config.GetValues()["account.base_url"] != "https://floppy.example.com" ||
|
||||
config.GetSecretValues()["account.client_secret"] != testSecretValue {
|
||||
t.Fatalf("provider config = %#v", config)
|
||||
}
|
||||
if _, exposed := config.GetValues()["account.client_secret"]; exposed {
|
||||
t.Fatal("JSON-schema password was exposed as a public provider value")
|
||||
}
|
||||
if strings.Contains(err.Error(), testSecretValue) || !strings.Contains(err.Error(), "[REDACTED]") {
|
||||
t.Fatalf("connection secrets were not redacted: %q", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectionConfigValidationRedactsNestedSecrets(t *testing.T) {
|
||||
const nestedSecret = "nested-connection-secret"
|
||||
schema := &pluginv1.ConfigSchema{
|
||||
Key: "account",
|
||||
JsonSchema: `{"type":"object","properties":{"advanced":{"type":"object","properties":{"password":{"type":"string","format":"password"}}}}}`,
|
||||
}
|
||||
secrets := connectionConfigSecrets(
|
||||
[]*pluginv1.ConfigSchema{schema},
|
||||
ConnectionConfigValues{"account": {"advanced": map[string]any{"password": nestedSecret}}},
|
||||
)
|
||||
err := sanitizedConnectionConfigError(errors.New("rejected "+nestedSecret), secrets)
|
||||
if strings.Contains(err.Error(), nestedSecret) || !strings.Contains(err.Error(), "[REDACTED]") {
|
||||
t.Fatalf("nested connection secret was not redacted: %q", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginProviderRejectsUnresolvableDynamicConnectionOptions(t *testing.T) {
|
||||
_, err := NewPluginProvider(PluginProviderOptions{
|
||||
InstallationID: 4, ProviderKey: testPluginProviderKey, CapabilityID: testPluginCapabilityID,
|
||||
Descriptor: &pluginv1.WatchSyncProviderDescriptor{AuthMethods: []pluginv1.WatchSyncAuthMethod{
|
||||
pluginv1.WatchSyncAuthMethod_WATCH_SYNC_AUTH_METHOD_API_KEY,
|
||||
}},
|
||||
ConnectionConfigSchema: []*pluginv1.ConfigSchema{{
|
||||
Key: "server",
|
||||
AdminForm: &pluginv1.AdminFormDescriptor{Fields: []*pluginv1.AdminFormField{{
|
||||
Key: "library", Control: pluginv1.AdminFormControl_ADMIN_FORM_CONTROL_SELECT,
|
||||
DynamicOptions: true,
|
||||
}}},
|
||||
}},
|
||||
ResolveClient: func(context.Context, int, string) (WatchSyncPluginClient, error) {
|
||||
return &fakeWatchSyncPluginClient{}, nil
|
||||
},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "requires dynamic options") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginProviderRejectsBlankConnectionSelectOption(t *testing.T) {
|
||||
_, err := NewPluginProvider(PluginProviderOptions{
|
||||
InstallationID: 4, ProviderKey: testPluginProviderKey, CapabilityID: testPluginCapabilityID,
|
||||
Descriptor: &pluginv1.WatchSyncProviderDescriptor{AuthMethods: []pluginv1.WatchSyncAuthMethod{
|
||||
pluginv1.WatchSyncAuthMethod_WATCH_SYNC_AUTH_METHOD_API_KEY,
|
||||
}},
|
||||
ConnectionConfigSchema: []*pluginv1.ConfigSchema{{
|
||||
Key: "server",
|
||||
JsonSchema: `{"type":"object","properties":{"mode":{"type":"string"}}}`,
|
||||
AdminForm: &pluginv1.AdminFormDescriptor{Fields: []*pluginv1.AdminFormField{{
|
||||
Key: "mode", Control: pluginv1.AdminFormControl_ADMIN_FORM_CONTROL_SELECT,
|
||||
Options: []*pluginv1.AdminFormOption{{Value: " ", Label: "Choose a mode"}},
|
||||
}}},
|
||||
}},
|
||||
ResolveClient: func(context.Context, int, string) (WatchSyncPluginClient, error) {
|
||||
return &fakeWatchSyncPluginClient{}, nil
|
||||
},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "blank select option value") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginProviderRejectsDuplicateConnectionConfigKeys(t *testing.T) {
|
||||
_, err := NewPluginProvider(PluginProviderOptions{
|
||||
InstallationID: 4, ProviderKey: testPluginProviderKey, CapabilityID: testPluginCapabilityID,
|
||||
Descriptor: &pluginv1.WatchSyncProviderDescriptor{AuthMethods: []pluginv1.WatchSyncAuthMethod{
|
||||
pluginv1.WatchSyncAuthMethod_WATCH_SYNC_AUTH_METHOD_API_KEY,
|
||||
}},
|
||||
ConnectionConfigSchema: []*pluginv1.ConfigSchema{
|
||||
{Key: "server", JsonSchema: `{"type":"object","properties":{}}`},
|
||||
{Key: "server", JsonSchema: `{"type":"object","properties":{}}`},
|
||||
},
|
||||
ResolveClient: func(context.Context, int, string) (WatchSyncPluginClient, error) {
|
||||
return &fakeWatchSyncPluginClient{}, nil
|
||||
},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "duplicated") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginProviderRejectsBlankConnectionConfigKey(t *testing.T) {
|
||||
_, err := NewPluginProvider(PluginProviderOptions{
|
||||
InstallationID: 4, ProviderKey: testPluginProviderKey, CapabilityID: testPluginCapabilityID,
|
||||
Descriptor: &pluginv1.WatchSyncProviderDescriptor{AuthMethods: []pluginv1.WatchSyncAuthMethod{
|
||||
pluginv1.WatchSyncAuthMethod_WATCH_SYNC_AUTH_METHOD_API_KEY,
|
||||
}},
|
||||
ConnectionConfigSchema: []*pluginv1.ConfigSchema{{
|
||||
Key: " \t", Required: true, JsonSchema: `{"type":"object","properties":{}}`,
|
||||
}},
|
||||
ResolveClient: func(context.Context, int, string) (WatchSyncPluginClient, error) {
|
||||
return &fakeWatchSyncPluginClient{}, nil
|
||||
},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "key is required") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginProviderRejectsUnsupportedConnectionConfigExclusivity(t *testing.T) {
|
||||
_, err := NewPluginProvider(PluginProviderOptions{
|
||||
InstallationID: 4, ProviderKey: testPluginProviderKey, CapabilityID: testPluginCapabilityID,
|
||||
Descriptor: &pluginv1.WatchSyncProviderDescriptor{AuthMethods: []pluginv1.WatchSyncAuthMethod{
|
||||
pluginv1.WatchSyncAuthMethod_WATCH_SYNC_AUTH_METHOD_API_KEY,
|
||||
}},
|
||||
ConnectionConfigSchema: []*pluginv1.ConfigSchema{{
|
||||
Key: "server", JsonSchema: `{"type":"object","properties":{"primary":{"type":"boolean"},"group":{"type":"string"}}}`,
|
||||
AdminForm: &pluginv1.AdminFormDescriptor{Fields: []*pluginv1.AdminFormField{{
|
||||
Key: "primary", Control: pluginv1.AdminFormControl_ADMIN_FORM_CONTROL_SWITCH,
|
||||
ExclusiveGroupField: "group",
|
||||
}}},
|
||||
}},
|
||||
ResolveClient: func(context.Context, int, string) (WatchSyncPluginClient, error) {
|
||||
return &fakeWatchSyncPluginClient{}, nil
|
||||
},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "exclusive_group_field") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginProviderRejectsRequiredConnectionConfigTheWebCannotRender(t *testing.T) {
|
||||
_, err := NewPluginProvider(PluginProviderOptions{
|
||||
InstallationID: 4, ProviderKey: testPluginProviderKey, CapabilityID: testPluginCapabilityID,
|
||||
Descriptor: &pluginv1.WatchSyncProviderDescriptor{AuthMethods: []pluginv1.WatchSyncAuthMethod{
|
||||
pluginv1.WatchSyncAuthMethod_WATCH_SYNC_AUTH_METHOD_API_KEY,
|
||||
}},
|
||||
ConnectionConfigSchema: []*pluginv1.ConfigSchema{{
|
||||
Key: "server", Required: true,
|
||||
JsonSchema: `{"type":"object","properties":{"headers":{"type":"object"}}}`,
|
||||
AdminForm: &pluginv1.AdminFormDescriptor{Fields: []*pluginv1.AdminFormField{{
|
||||
Key: "headers", Control: pluginv1.AdminFormControl_ADMIN_FORM_CONTROL_TEXTAREA,
|
||||
}}},
|
||||
}},
|
||||
ResolveClient: func(context.Context, int, string) (WatchSyncPluginClient, error) {
|
||||
return &fakeWatchSyncPluginClient{}, nil
|
||||
},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "cannot be inferred") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginProviderRejectsOptionalConnectionConfigTheWebCannotRender(t *testing.T) {
|
||||
_, err := NewPluginProvider(PluginProviderOptions{
|
||||
InstallationID: 4, ProviderKey: testPluginProviderKey, CapabilityID: testPluginCapabilityID,
|
||||
Descriptor: &pluginv1.WatchSyncProviderDescriptor{AuthMethods: []pluginv1.WatchSyncAuthMethod{
|
||||
pluginv1.WatchSyncAuthMethod_WATCH_SYNC_AUTH_METHOD_API_KEY,
|
||||
}},
|
||||
ConnectionConfigSchema: []*pluginv1.ConfigSchema{{
|
||||
Key: "headers",
|
||||
JsonSchema: `{"type":"object","properties":{"values":{"type":"object"}}}`,
|
||||
}},
|
||||
ResolveClient: func(context.Context, int, string) (WatchSyncPluginClient, error) {
|
||||
return &fakeWatchSyncPluginClient{}, nil
|
||||
},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "cannot be inferred") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginProviderAcceptsRenderableRequiredConnectionConfig(t *testing.T) {
|
||||
_, err := NewPluginProvider(PluginProviderOptions{
|
||||
InstallationID: 4, ProviderKey: testPluginProviderKey, CapabilityID: testPluginCapabilityID,
|
||||
Descriptor: &pluginv1.WatchSyncProviderDescriptor{AuthMethods: []pluginv1.WatchSyncAuthMethod{
|
||||
pluginv1.WatchSyncAuthMethod_WATCH_SYNC_AUTH_METHOD_API_KEY,
|
||||
}},
|
||||
ConnectionConfigSchema: []*pluginv1.ConfigSchema{
|
||||
{
|
||||
Key: "server", Required: true,
|
||||
JsonSchema: `{"type":"object","properties":{"base_url":{"type":"string"},"username":{"type":"string"}},"required":["base_url","username"]}`,
|
||||
AdminForm: &pluginv1.AdminFormDescriptor{Fields: []*pluginv1.AdminFormField{{
|
||||
Key: "base_url", Control: pluginv1.AdminFormControl_ADMIN_FORM_CONTROL_TEXT,
|
||||
}}},
|
||||
},
|
||||
{
|
||||
Key: "features", Required: true,
|
||||
JsonSchema: `{"type":"object","properties":{"flags":{"type":"array","items":{"type":"boolean"}}}}`,
|
||||
AdminForm: &pluginv1.AdminFormDescriptor{Fields: []*pluginv1.AdminFormField{{
|
||||
Key: "flags", Control: pluginv1.AdminFormControl_ADMIN_FORM_CONTROL_MULTI_SELECT,
|
||||
Options: []*pluginv1.AdminFormOption{{Value: "true", Label: "Enabled"}, {Value: "false", Label: "Disabled"}},
|
||||
}}},
|
||||
},
|
||||
{
|
||||
Key: "mode", Required: true,
|
||||
JsonSchema: `{"type":"object","properties":{"value":{"enum":["standard","anime"]}}}`,
|
||||
AdminForm: &pluginv1.AdminFormDescriptor{Fields: []*pluginv1.AdminFormField{{
|
||||
Key: "value", Control: pluginv1.AdminFormControl_ADMIN_FORM_CONTROL_SELECT,
|
||||
Options: []*pluginv1.AdminFormOption{{Value: "standard", Label: "Standard"}, {Value: "anime", Label: "Anime"}},
|
||||
}}},
|
||||
},
|
||||
{
|
||||
Key: "reference", Required: true,
|
||||
JsonSchema: `{"type":"object","properties":{"endpoint":{"$ref":"#/$defs/endpoint"}},"$defs":{"endpoint":{"type":"string","format":"uri"}}}`,
|
||||
AdminForm: &pluginv1.AdminFormDescriptor{Fields: []*pluginv1.AdminFormField{{
|
||||
Key: "endpoint", Control: pluginv1.AdminFormControl_ADMIN_FORM_CONTROL_TEXT,
|
||||
}}},
|
||||
},
|
||||
},
|
||||
ResolveClient: func(context.Context, int, string) (WatchSyncPluginClient, error) {
|
||||
return &fakeWatchSyncPluginClient{}, nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginProviderRejectsConnectionControlThatCannotEmitSchemaType(t *testing.T) {
|
||||
_, err := NewPluginProvider(PluginProviderOptions{
|
||||
InstallationID: 4, ProviderKey: testPluginProviderKey, CapabilityID: testPluginCapabilityID,
|
||||
Descriptor: &pluginv1.WatchSyncProviderDescriptor{AuthMethods: []pluginv1.WatchSyncAuthMethod{
|
||||
pluginv1.WatchSyncAuthMethod_WATCH_SYNC_AUTH_METHOD_API_KEY,
|
||||
}},
|
||||
ConnectionConfigSchema: []*pluginv1.ConfigSchema{{
|
||||
Key: "server",
|
||||
JsonSchema: `{"type":"object","properties":{"name":{"type":"string"}}}`,
|
||||
AdminForm: &pluginv1.AdminFormDescriptor{Fields: []*pluginv1.AdminFormField{{
|
||||
Key: "name", Control: pluginv1.AdminFormControl_ADMIN_FORM_CONTROL_SWITCH,
|
||||
}}},
|
||||
}},
|
||||
ResolveClient: func(context.Context, int, string) (WatchSyncPluginClient, error) {
|
||||
return &fakeWatchSyncPluginClient{}, nil
|
||||
},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "cannot emit json_schema type") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginProviderEnforcesVisibleRequiredConnectionAdminFields(t *testing.T) {
|
||||
client := &fakeWatchSyncPluginClient{exchangeResponse: &pluginv1.WatchSyncCredentialResponse{
|
||||
Credentials: &pluginv1.WatchSyncCredentials{AccessToken: testValidatedToken},
|
||||
Account: &pluginv1.WatchSyncAccount{ExternalSubject: "7"},
|
||||
}}
|
||||
provider, err := NewPluginProvider(PluginProviderOptions{
|
||||
InstallationID: 4, ProviderKey: testPluginProviderKey, CapabilityID: testPluginCapabilityID,
|
||||
Descriptor: &pluginv1.WatchSyncProviderDescriptor{AuthMethods: []pluginv1.WatchSyncAuthMethod{
|
||||
pluginv1.WatchSyncAuthMethod_WATCH_SYNC_AUTH_METHOD_API_KEY,
|
||||
}},
|
||||
ConnectionConfigSchema: []*pluginv1.ConfigSchema{{
|
||||
Key: "server",
|
||||
JsonSchema: `{"type":"object","properties":{"advanced":{"type":"boolean"},"endpoint":{"type":"string"}},"required":["advanced"],"additionalProperties":false}`,
|
||||
AdminForm: &pluginv1.AdminFormDescriptor{Fields: []*pluginv1.AdminFormField{
|
||||
{Key: "advanced", Control: pluginv1.AdminFormControl_ADMIN_FORM_CONTROL_SWITCH},
|
||||
{
|
||||
Key: "endpoint", Control: pluginv1.AdminFormControl_ADMIN_FORM_CONTROL_TEXT, Required: true,
|
||||
ShowWhen: []*pluginv1.AdminFormCondition{{Field: "advanced", Equals: []string{"true"}}},
|
||||
},
|
||||
}},
|
||||
}},
|
||||
ResolveClient: func(context.Context, int, string) (WatchSyncPluginClient, error) {
|
||||
return client, nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, _, err := provider.ConnectWithAPIKeyConfig(context.Background(), "token", ConnectionConfigValues{
|
||||
"server": {"advanced": false},
|
||||
}); err != nil {
|
||||
t.Fatalf("hidden required field: %v", err)
|
||||
}
|
||||
if _, _, err := provider.ConnectWithAPIKeyConfig(context.Background(), "token", ConnectionConfigValues{
|
||||
"server": {"advanced": true},
|
||||
}); err == nil || !strings.Contains(err.Error(), `field "endpoint" is required`) {
|
||||
t.Fatalf("visible required field error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginProviderRejectsFlattenedConnectionConfigCollision(t *testing.T) {
|
||||
client := &fakeWatchSyncPluginClient{}
|
||||
provider, err := NewPluginProvider(PluginProviderOptions{
|
||||
InstallationID: 4, ProviderKey: testPluginProviderKey, CapabilityID: testPluginCapabilityID,
|
||||
Descriptor: &pluginv1.WatchSyncProviderDescriptor{AuthMethods: []pluginv1.WatchSyncAuthMethod{
|
||||
pluginv1.WatchSyncAuthMethod_WATCH_SYNC_AUTH_METHOD_API_KEY,
|
||||
}},
|
||||
ConnectionConfigSchema: []*pluginv1.ConfigSchema{
|
||||
{Key: "a", JsonSchema: `{"type":"object","properties":{"b.c":{"type":"string"}},"additionalProperties":false}`},
|
||||
{Key: "a.b", JsonSchema: `{"type":"object","properties":{"c":{"type":"string"}},"additionalProperties":false}`},
|
||||
},
|
||||
ResolveClient: func(context.Context, int, string) (WatchSyncPluginClient, error) {
|
||||
return client, nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, _, err = provider.ConnectWithAPIKeyConfig(context.Background(), "token", ConnectionConfigValues{
|
||||
"a": {"b.c": "first"},
|
||||
"a.b": {"c": "second"},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "conflicts") || !strings.Contains(err.Error(), "after flattening") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
if client.exchangeRequest != nil {
|
||||
t.Fatal("ambiguous connection config reached the plugin")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginProviderEnforcesConnectionAdminFormValidation(t *testing.T) {
|
||||
client := &fakeWatchSyncPluginClient{exchangeResponse: &pluginv1.WatchSyncCredentialResponse{
|
||||
Credentials: &pluginv1.WatchSyncCredentials{AccessToken: testValidatedToken},
|
||||
Account: &pluginv1.WatchSyncAccount{ExternalSubject: "7"},
|
||||
}}
|
||||
provider, err := NewPluginProvider(PluginProviderOptions{
|
||||
InstallationID: 4, ProviderKey: testPluginProviderKey, CapabilityID: testPluginCapabilityID,
|
||||
Descriptor: &pluginv1.WatchSyncProviderDescriptor{AuthMethods: []pluginv1.WatchSyncAuthMethod{
|
||||
pluginv1.WatchSyncAuthMethod_WATCH_SYNC_AUTH_METHOD_API_KEY,
|
||||
}},
|
||||
ConnectionConfigSchema: []*pluginv1.ConfigSchema{{
|
||||
Key: "server", Required: true,
|
||||
JsonSchema: `{"type":"object","properties":{"name":{"type":"string"},"port":{},"password":{"type":"string","format":"password"}},"required":["name","port","password"]}`,
|
||||
AdminForm: &pluginv1.AdminFormDescriptor{Fields: []*pluginv1.AdminFormField{
|
||||
{Key: "name", Control: pluginv1.AdminFormControl_ADMIN_FORM_CONTROL_TEXT, Validation: &pluginv1.AdminFormValidation{Pattern: `^[a-z]+$`, MinLength: 3, MaxLength: 8}},
|
||||
{Key: "port", Control: pluginv1.AdminFormControl_ADMIN_FORM_CONTROL_NUMBER, Validation: &pluginv1.AdminFormValidation{HasMin: true, Min: 1, HasMax: true, Max: 65535}},
|
||||
{Key: "password", Control: pluginv1.AdminFormControl_ADMIN_FORM_CONTROL_PASSWORD, Secret: true, Validation: &pluginv1.AdminFormValidation{MinLength: 8}},
|
||||
}},
|
||||
}},
|
||||
ResolveClient: func(context.Context, int, string) (WatchSyncPluginClient, error) {
|
||||
return client, nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
values map[string]any
|
||||
message string
|
||||
}{
|
||||
{name: "pattern", values: map[string]any{"name": "Bad", "port": 443.0, "password": "long-enough"}, message: "is invalid"},
|
||||
{name: "number", values: map[string]any{"name": "good", "port": 70000.0, "password": "long-enough"}, message: "at most 65535"},
|
||||
{name: "numeric string", values: map[string]any{"name": "good", "port": "70000", "password": "long-enough"}, message: "at most 65535"},
|
||||
{name: "secret length", values: map[string]any{"name": "good", "port": 443.0, "password": "leaky"}, message: "at least 8 characters"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, _, err := provider.ConnectWithAPIKeyConfig(context.Background(), "token", ConnectionConfigValues{"server": tt.values})
|
||||
if err == nil || !strings.Contains(err.Error(), tt.message) {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "leaky") {
|
||||
t.Fatalf("secret leaked in validation error: %q", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginProviderRejectsConnectionConfigForDeviceAuthorization(t *testing.T) {
|
||||
_, err := NewPluginProvider(PluginProviderOptions{
|
||||
InstallationID: 4, ProviderKey: testPluginProviderKey, CapabilityID: testPluginCapabilityID,
|
||||
Descriptor: &pluginv1.WatchSyncProviderDescriptor{AuthMethods: []pluginv1.WatchSyncAuthMethod{
|
||||
pluginv1.WatchSyncAuthMethod_WATCH_SYNC_AUTH_METHOD_DEVICE_CODE,
|
||||
}},
|
||||
ConnectionConfigSchema: []*pluginv1.ConfigSchema{{Key: "server"}},
|
||||
ResolveClient: func(context.Context, int, string) (WatchSyncPluginClient, error) {
|
||||
return &fakeWatchSyncPluginClient{}, nil
|
||||
},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "connection config requires API-key authentication") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginProviderRefreshReturnsCredentialsAlongsideFault(t *testing.T) {
|
||||
client := &fakeWatchSyncPluginClient{refreshResponse: &pluginv1.WatchSyncCredentialResponse{
|
||||
Credentials: &pluginv1.WatchSyncCredentials{AccessToken: testRotatedAccessToken, TokenType: testBearerTokenType},
|
||||
@@ -867,3 +1339,29 @@ func TestPluginProviderForwardsLiveScrobbleLifecycle(t *testing.T) {
|
||||
t.Fatalf("scrobble event = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginProviderForwardsAuthoritativeScrobbleCompletion(t *testing.T) {
|
||||
completed := watchEventFromScrobble(ScrobbleEvent{
|
||||
PlaybackSessionID: testPlaybackSessionID,
|
||||
MediaItemID: testMovieMediaID,
|
||||
Kind: historyimport.KindMovie,
|
||||
PositionSeconds: 90,
|
||||
DurationSeconds: 100,
|
||||
Completed: true,
|
||||
}, pluginv1.WatchSyncOperation_WATCH_SYNC_OPERATION_SCROBBLE_STOP)
|
||||
if !completed.GetCompleted() {
|
||||
t.Fatal("completed event = false, want true")
|
||||
}
|
||||
|
||||
incomplete := watchEventFromScrobble(ScrobbleEvent{
|
||||
PlaybackSessionID: testPlaybackSessionID,
|
||||
MediaItemID: testMovieMediaID,
|
||||
Kind: historyimport.KindMovie,
|
||||
PositionSeconds: 10,
|
||||
DurationSeconds: 100,
|
||||
Completed: false,
|
||||
}, pluginv1.WatchSyncOperation_WATCH_SYNC_OPERATION_SCROBBLE_STOP)
|
||||
if incomplete.GetCompleted() {
|
||||
t.Fatal("incomplete event = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,11 +91,15 @@ func (r *Registry) List() []ProviderSummary {
|
||||
|
||||
summaries := make([]ProviderSummary, 0, len(r.providers))
|
||||
for key, provider := range r.providers {
|
||||
summaries = append(summaries, ProviderSummary{
|
||||
summary := ProviderSummary{
|
||||
Key: key,
|
||||
DisplayName: provider.DisplayName(),
|
||||
Capabilities: provider.Capabilities(),
|
||||
})
|
||||
}
|
||||
if configurable, ok := provider.(connectionConfigProvider); ok {
|
||||
summary.ConnectionConfigSchema = configurable.ConnectionConfigSchema()
|
||||
}
|
||||
summaries = append(summaries, summary)
|
||||
}
|
||||
sort.Slice(summaries, func(i, j int) bool {
|
||||
return summaries[i].Key < summaries[j].Key
|
||||
|
||||
@@ -34,6 +34,7 @@ type Repository interface {
|
||||
ListListEventConnections(ctx context.Context, userID int, profileID string, list ListKind) ([]Connection, error)
|
||||
UpsertHistoryExports(ctx context.Context, exports []HistoryExport) error
|
||||
ListPendingHistoryExports(ctx context.Context, connectionID string, limit int) ([]HistoryExport, error)
|
||||
ListPendingHistoryExportsByHistoryIDs(ctx context.Context, connectionID string, historyIDs []string, limit int) ([]HistoryExport, error)
|
||||
MarkHistoryExportStatus(ctx context.Context, id string, status string, lastError string) error
|
||||
MarkHistoryExportSatisfiedByScrobble(ctx context.Context, connectionID string, historyID string) error
|
||||
UpsertListItemStates(ctx context.Context, states []ListItemState) error
|
||||
@@ -765,6 +766,60 @@ func (r *PostgresRepository) ListPendingHistoryExports(ctx context.Context, conn
|
||||
return exports, nil
|
||||
}
|
||||
|
||||
func (r *PostgresRepository) ListPendingHistoryExportsByHistoryIDs(
|
||||
ctx context.Context,
|
||||
connectionID string,
|
||||
historyIDs []string,
|
||||
limit int,
|
||||
) ([]HistoryExport, error) {
|
||||
if len(historyIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if limit <= 0 || limit > len(historyIDs) {
|
||||
limit = len(historyIDs)
|
||||
}
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT id::text, connection_id::text, history_id, media_item_id, watched_at,
|
||||
provider_item_key, status, attempt_count, last_attempt_at, last_error, created_at, updated_at
|
||||
FROM watch_provider_history_exports
|
||||
WHERE connection_id = $1::uuid
|
||||
AND history_id = ANY($2::text[])
|
||||
AND status IN ('pending', 'failed')
|
||||
AND attempt_count < 5
|
||||
ORDER BY watched_at ASC
|
||||
LIMIT $3
|
||||
`, connectionID, historyIDs, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list pending history exports by history ids: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var exports []HistoryExport
|
||||
for rows.Next() {
|
||||
var export HistoryExport
|
||||
if err := rows.Scan(
|
||||
&export.ID,
|
||||
&export.ConnectionID,
|
||||
&export.HistoryID,
|
||||
&export.MediaItemID,
|
||||
&export.WatchedAt,
|
||||
&export.ProviderItemKey,
|
||||
&export.Status,
|
||||
&export.AttemptCount,
|
||||
&export.LastAttemptAt,
|
||||
&export.LastError,
|
||||
&export.CreatedAt,
|
||||
&export.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan pending history export by history ids: %w", err)
|
||||
}
|
||||
exports = append(exports, export)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate pending history exports by history ids: %w", err)
|
||||
}
|
||||
return exports, nil
|
||||
}
|
||||
|
||||
func (r *PostgresRepository) MarkHistoryExportStatus(ctx context.Context, id string, status string, lastError string) error {
|
||||
_, err := r.pool.Exec(ctx, `
|
||||
UPDATE watch_provider_history_exports
|
||||
|
||||
@@ -49,11 +49,13 @@ type watchStateImporter interface {
|
||||
const (
|
||||
manualSyncCooldown = time.Hour
|
||||
manualSyncTimeout = 10 * time.Minute
|
||||
// Built-in providers bind requests to this context and use HTTP client
|
||||
// timeouts of at most 20 seconds. Keep dispatch below the reclaim lease;
|
||||
// the per-session queue remains occupied until the worker itself exits.
|
||||
confirmedStopDispatchTimeout = 25 * time.Second
|
||||
confirmedStopLease = time.Minute
|
||||
// A completed stop may require a cold metadata lookup in a provider plugin.
|
||||
// Match the plugin-host watch-sync deadline so the durable confirmation path
|
||||
// does not cancel valid provider work before the RPC can finish. The reclaim
|
||||
// lease must remain longer than that entire dispatch and finalization window
|
||||
// so a second request cannot take over while the first is still valid.
|
||||
confirmedStopDispatchTimeout = 2 * time.Minute
|
||||
confirmedStopLease = confirmedStopDispatchTimeout + 30*time.Second
|
||||
)
|
||||
|
||||
var errConfirmedStopInProgress = errors.New("watch provider stop confirmation already in progress")
|
||||
@@ -134,6 +136,9 @@ func (s *Service) GetConnectionStatus(ctx context.Context, userID int, profileID
|
||||
SyncWatchlistOrderEnabled: true,
|
||||
ScrobbleEnabled: true,
|
||||
}
|
||||
if configurable, ok := provider.(connectionConfigProvider); ok {
|
||||
status.ConnectionConfigSchema = configurable.ConnectionConfigSchema()
|
||||
}
|
||||
if connected {
|
||||
status.ProviderUsername = conn.ProviderUsername
|
||||
status.ImportWatchedEnabled = conn.ImportWatchedEnabled
|
||||
@@ -542,6 +547,17 @@ func (s *Service) ConnectAPIKey(
|
||||
profileID string,
|
||||
providerKey string,
|
||||
apiKey string,
|
||||
) (Connection, error) {
|
||||
return s.ConnectAPIKeyWithConfig(ctx, userID, profileID, providerKey, apiKey, nil)
|
||||
}
|
||||
|
||||
func (s *Service) ConnectAPIKeyWithConfig(
|
||||
ctx context.Context,
|
||||
userID int,
|
||||
profileID string,
|
||||
providerKey string,
|
||||
apiKey string,
|
||||
connectionConfig ConnectionConfigValues,
|
||||
) (Connection, error) {
|
||||
if userID <= 0 {
|
||||
return Connection{}, fmt.Errorf("user id is required")
|
||||
@@ -562,7 +578,17 @@ func (s *Service) ConnectAPIKey(
|
||||
return Connection{}, fmt.Errorf("provider %q does not support api-key auth", providerKey)
|
||||
}
|
||||
|
||||
tokens, account, err := authProvider.ConnectWithAPIKey(ctx, apiKey)
|
||||
var tokens TokenSet
|
||||
var account ProviderAccount
|
||||
var err error
|
||||
if configured, ok := provider.(configuredAPIKeyAuthProvider); ok {
|
||||
tokens, account, err = configured.ConnectWithAPIKeyConfig(ctx, apiKey, connectionConfig)
|
||||
} else {
|
||||
if len(connectionConfig) > 0 {
|
||||
return Connection{}, fmt.Errorf("provider %q does not accept connection configuration", providerKey)
|
||||
}
|
||||
tokens, account, err = authProvider.ConnectWithAPIKey(ctx, apiKey)
|
||||
}
|
||||
if err != nil {
|
||||
return Connection{}, err
|
||||
}
|
||||
@@ -1510,7 +1536,19 @@ func (s *Service) exportLocalPlays(
|
||||
if err := s.repo.UpsertHistoryExports(ctx, exports); err != nil {
|
||||
return err
|
||||
}
|
||||
pending, err := s.repo.ListPendingHistoryExports(ctx, conn.ID, 100)
|
||||
historyIDs := make([]string, 0, len(exports))
|
||||
for _, export := range exports {
|
||||
historyIDs = append(historyIDs, export.HistoryID)
|
||||
}
|
||||
// A live watch event must not wait behind a connection's historical
|
||||
// backlog. Scheduled sync still drains that backlog oldest-first, while
|
||||
// this path selects only the events the user just created.
|
||||
pending, err := s.repo.ListPendingHistoryExportsByHistoryIDs(
|
||||
ctx,
|
||||
conn.ID,
|
||||
historyIDs,
|
||||
watchedExportBatchSize(exporter, len(historyIDs)),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1603,20 +1641,25 @@ func (s *Service) persistConnectionError(ctx context.Context, conn Connection, m
|
||||
}
|
||||
|
||||
func limitWatchedExportBatch(exporter WatchedExporter, plays []LocalPlay) ([]LocalPlay, bool) {
|
||||
bounded, ok := exporter.(singleBatchWatchedExporter)
|
||||
_, ok := exporter.(singleBatchWatchedExporter)
|
||||
if !ok {
|
||||
return plays, false
|
||||
}
|
||||
limit := bounded.ExportBatchSize()
|
||||
if limit <= 0 {
|
||||
limit = 1
|
||||
}
|
||||
limit := watchedExportBatchSize(exporter, len(plays))
|
||||
if len(plays) > limit {
|
||||
plays = plays[:limit]
|
||||
}
|
||||
return plays, true
|
||||
}
|
||||
|
||||
func watchedExportBatchSize(exporter WatchedExporter, fallback int) int {
|
||||
bounded, ok := exporter.(singleBatchWatchedExporter)
|
||||
if !ok {
|
||||
return max(1, fallback)
|
||||
}
|
||||
return max(1, bounded.ExportBatchSize())
|
||||
}
|
||||
|
||||
func reconcileHistoryExports(connectionID string, local []LocalPlay, remote []RemotePlay) []HistoryExport {
|
||||
remoteExact := make(map[string]struct{}, len(remote))
|
||||
for _, play := range remote {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -45,6 +46,8 @@ type serviceFakeRepo struct {
|
||||
settings map[string]string
|
||||
syncRuns []SyncRun
|
||||
historyExports []HistoryExport
|
||||
historyLookupIDs []string
|
||||
historyLookupLimit int
|
||||
listItemStates []ListItemState
|
||||
scrobbleConnections []Connection
|
||||
scrobbleSessions []ScrobbleSession
|
||||
@@ -346,6 +349,27 @@ func (r *serviceFakeRepo) ListPendingHistoryExports(_ context.Context, connectio
|
||||
return exports, nil
|
||||
}
|
||||
|
||||
func (r *serviceFakeRepo) ListPendingHistoryExportsByHistoryIDs(_ context.Context, connectionID string, historyIDs []string, limit int) ([]HistoryExport, error) {
|
||||
r.historyLookupIDs = append([]string(nil), historyIDs...)
|
||||
r.historyLookupLimit = limit
|
||||
wanted := make(map[string]struct{}, len(historyIDs))
|
||||
for _, historyID := range historyIDs {
|
||||
wanted[historyID] = struct{}{}
|
||||
}
|
||||
var exports []HistoryExport
|
||||
for _, export := range r.historyExports {
|
||||
_, matches := wanted[export.HistoryID]
|
||||
if export.ConnectionID == connectionID && matches &&
|
||||
(export.Status == historyExportStatusPending || export.Status == historyExportStatusFailed) && export.AttemptCount < 5 {
|
||||
exports = append(exports, export)
|
||||
if limit > 0 && len(exports) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return exports, nil
|
||||
}
|
||||
|
||||
func (r *serviceFakeRepo) MarkHistoryExportStatus(_ context.Context, id string, status string, lastError string) error {
|
||||
if r.markHistoryStatusErr != nil {
|
||||
return r.markHistoryStatusErr
|
||||
@@ -789,10 +813,20 @@ func (p progressBatchImporterStub) FetchProgressBatch(context.Context, ServerCon
|
||||
type watchedExporterStub struct {
|
||||
exportErr error
|
||||
exportResult ExportResult
|
||||
exported *[]LocalPlay
|
||||
key string
|
||||
source userstore.WatchHistorySource
|
||||
}
|
||||
|
||||
type singleBatchWatchedExporterStub struct {
|
||||
watchedExporterStub
|
||||
batchSize int
|
||||
}
|
||||
|
||||
func (p singleBatchWatchedExporterStub) ExportBatchSize() int {
|
||||
return p.batchSize
|
||||
}
|
||||
|
||||
func (p watchedExporterStub) Key() string {
|
||||
if p.key != "" {
|
||||
return p.key
|
||||
@@ -812,7 +846,10 @@ func (p watchedExporterStub) FetchHistory(context.Context, ServerConfig, Connect
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (p watchedExporterStub) ExportHistory(context.Context, ServerConfig, Connection, []LocalPlay) (ExportResult, error) {
|
||||
func (p watchedExporterStub) ExportHistory(_ context.Context, _ ServerConfig, _ Connection, plays []LocalPlay) (ExportResult, error) {
|
||||
if p.exported != nil {
|
||||
*p.exported = append(*p.exported, plays...)
|
||||
}
|
||||
return p.exportResult, p.exportErr
|
||||
}
|
||||
|
||||
@@ -2587,6 +2624,12 @@ func TestServiceConfirmedStopSerializesConcurrentConfirmation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfirmedStopLeaseExceedsDispatchTimeout(t *testing.T) {
|
||||
if confirmedStopLease <= confirmedStopDispatchTimeout {
|
||||
t.Fatalf("confirmed stop lease %s must exceed dispatch timeout %s", confirmedStopLease, confirmedStopDispatchTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceConfirmedStopCannotCompleteReclaimedLease(t *testing.T) {
|
||||
repo := newServiceFakeRepo()
|
||||
repo.scrobbleConnections = []Connection{{
|
||||
@@ -3130,6 +3173,74 @@ func TestServicePluginTransportFailureLeavesExportPending(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceLocalWatchEventBypassesHistoricalExportBacklog(t *testing.T) {
|
||||
repo := newServiceFakeRepo()
|
||||
for i := 0; i < 100; i++ {
|
||||
repo.historyExports = append(repo.historyExports, HistoryExport{
|
||||
ID: fmt.Sprintf("old-export-%d", i),
|
||||
ConnectionID: "conn-1",
|
||||
HistoryID: fmt.Sprintf("old-history-%d", i),
|
||||
Status: historyExportStatusPending,
|
||||
WatchedAt: time.Date(2025, time.January, 1, 0, i, 0, 0, time.UTC),
|
||||
})
|
||||
}
|
||||
var exported []LocalPlay
|
||||
service := NewService(repo, NewRegistry())
|
||||
play := LocalPlay{
|
||||
HistoryID: "new-history",
|
||||
MediaItemID: testMovieMediaID,
|
||||
ProviderItemKey: testMovieProviderItemKey,
|
||||
WatchedAt: time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC),
|
||||
}
|
||||
err := service.exportLocalPlays(context.Background(), Connection{ID: "conn-1"}, ServerConfig{}, watchedExporterStub{
|
||||
exported: &exported,
|
||||
exportResult: ExportResult{Sent: []string{play.HistoryID}},
|
||||
}, []LocalPlay{play})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(exported) != 1 || exported[0].HistoryID != play.HistoryID {
|
||||
t.Fatalf("exported = %#v, want only the live event", exported)
|
||||
}
|
||||
if got := repo.historyExports[len(repo.historyExports)-1].Status; got != historyExportStatusSent {
|
||||
t.Fatalf("new export status = %q, want %q", got, historyExportStatusSent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceLocalWatchEventBoundsHistoryLookupToProviderBatch(t *testing.T) {
|
||||
repo := newServiceFakeRepo()
|
||||
var exported []LocalPlay
|
||||
plays := make([]LocalPlay, 0, 25)
|
||||
for i := 0; i < 25; i++ {
|
||||
plays = append(plays, LocalPlay{
|
||||
HistoryID: fmt.Sprintf("history-%d", i),
|
||||
MediaItemID: testMovieMediaID,
|
||||
ProviderItemKey: testMovieProviderItemKey,
|
||||
WatchedAt: time.Date(2026, time.August, 6, 12, i, 0, 0, time.UTC),
|
||||
})
|
||||
}
|
||||
service := NewService(repo, NewRegistry())
|
||||
err := service.exportLocalPlays(
|
||||
context.Background(),
|
||||
Connection{ID: "conn-1"},
|
||||
ServerConfig{},
|
||||
singleBatchWatchedExporterStub{
|
||||
watchedExporterStub: watchedExporterStub{exported: &exported},
|
||||
batchSize: 1,
|
||||
},
|
||||
plays,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if repo.historyLookupLimit != 1 {
|
||||
t.Fatalf("history lookup limit = %d, want 1", repo.historyLookupLimit)
|
||||
}
|
||||
if len(exported) != 1 {
|
||||
t.Fatalf("exported %d plays, want 1", len(exported))
|
||||
}
|
||||
}
|
||||
|
||||
func TestServicePluginInvalidCredentialLeavesExportPendingAndRecordsConnectionError(t *testing.T) {
|
||||
repo := newServiceFakeRepo()
|
||||
client := &fakeWatchSyncPluginClient{applyResponse: &pluginv1.WatchSyncApplyEventsResponse{
|
||||
|
||||
+44
-29
@@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/historyimport"
|
||||
"github.com/Silo-Server/silo-server/internal/plugins"
|
||||
"github.com/Silo-Server/silo-server/internal/userstore"
|
||||
)
|
||||
|
||||
@@ -74,6 +75,18 @@ type APIKeyAuthProvider interface {
|
||||
ConnectWithAPIKey(ctx context.Context, apiKey string) (TokenSet, ProviderAccount, error)
|
||||
}
|
||||
|
||||
// ConnectionConfigValues contains manifest-declared, per-connection setup
|
||||
// values keyed first by config-schema key and then by field key.
|
||||
type ConnectionConfigValues map[string]map[string]any
|
||||
|
||||
type configuredAPIKeyAuthProvider interface {
|
||||
ConnectWithAPIKeyConfig(ctx context.Context, apiKey string, config ConnectionConfigValues) (TokenSet, ProviderAccount, error)
|
||||
}
|
||||
|
||||
type connectionConfigProvider interface {
|
||||
ConnectionConfigSchema() []plugins.ConfigSchemaView
|
||||
}
|
||||
|
||||
type WatchedImporter interface {
|
||||
FetchWatched(ctx context.Context, cfg ServerConfig, conn Connection) ([]RemoteWatch, error)
|
||||
}
|
||||
@@ -660,38 +673,40 @@ func (f RemoteFavorite) HistoryRecord() historyimport.Record {
|
||||
}
|
||||
|
||||
type ProviderSummary struct {
|
||||
Key string `json:"key"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Capabilities Capabilities `json:"capabilities"`
|
||||
Key string `json:"key"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Capabilities Capabilities `json:"capabilities"`
|
||||
ConnectionConfigSchema []plugins.ConfigSchemaView `json:"connection_config_schema,omitempty"`
|
||||
}
|
||||
|
||||
type ConnectionStatus struct {
|
||||
Provider string `json:"provider"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Capabilities Capabilities `json:"capabilities"`
|
||||
AuthMethod string `json:"auth_method"`
|
||||
Connected bool `json:"connected"`
|
||||
ProviderUsername string `json:"provider_username,omitempty"`
|
||||
ImportWatchedEnabled bool `json:"import_watched_enabled"`
|
||||
ImportProgressEnabled bool `json:"import_progress_enabled"`
|
||||
ExportWatchedEnabled bool `json:"export_watched_enabled"`
|
||||
ExportUnwatchedEnabled bool `json:"export_unwatched_enabled"`
|
||||
ImportFavoritesEnabled bool `json:"import_favorites_enabled"`
|
||||
ExportFavoritesEnabled bool `json:"export_favorites_enabled"`
|
||||
SyncFavoriteRemovalsEnabled bool `json:"sync_favorite_removals_enabled"`
|
||||
ImportWatchlistEnabled bool `json:"import_watchlist_enabled"`
|
||||
ExportWatchlistEnabled bool `json:"export_watchlist_enabled"`
|
||||
SyncWatchlistRemovalsEnabled bool `json:"sync_watchlist_removals_enabled"`
|
||||
SyncWatchlistOrderEnabled bool `json:"sync_watchlist_order_enabled"`
|
||||
ScrobbleEnabled bool `json:"scrobble_enabled"`
|
||||
CredentialsConfigured bool `json:"credentials_configured"`
|
||||
LastInboundSyncAt *time.Time `json:"last_inbound_sync_at,omitempty"`
|
||||
LastProgressSyncAt *time.Time `json:"last_progress_sync_at,omitempty"`
|
||||
LastOutboundSyncAt *time.Time `json:"last_outbound_sync_at,omitempty"`
|
||||
LastFavoritesSyncAt *time.Time `json:"last_favorites_sync_at,omitempty"`
|
||||
LastWatchlistSyncAt *time.Time `json:"last_watchlist_sync_at,omitempty"`
|
||||
LastScrobbleErrorAt *time.Time `json:"last_scrobble_error_at,omitempty"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
Provider string `json:"provider"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Capabilities Capabilities `json:"capabilities"`
|
||||
AuthMethod string `json:"auth_method"`
|
||||
Connected bool `json:"connected"`
|
||||
ProviderUsername string `json:"provider_username,omitempty"`
|
||||
ImportWatchedEnabled bool `json:"import_watched_enabled"`
|
||||
ImportProgressEnabled bool `json:"import_progress_enabled"`
|
||||
ExportWatchedEnabled bool `json:"export_watched_enabled"`
|
||||
ExportUnwatchedEnabled bool `json:"export_unwatched_enabled"`
|
||||
ImportFavoritesEnabled bool `json:"import_favorites_enabled"`
|
||||
ExportFavoritesEnabled bool `json:"export_favorites_enabled"`
|
||||
SyncFavoriteRemovalsEnabled bool `json:"sync_favorite_removals_enabled"`
|
||||
ImportWatchlistEnabled bool `json:"import_watchlist_enabled"`
|
||||
ExportWatchlistEnabled bool `json:"export_watchlist_enabled"`
|
||||
SyncWatchlistRemovalsEnabled bool `json:"sync_watchlist_removals_enabled"`
|
||||
SyncWatchlistOrderEnabled bool `json:"sync_watchlist_order_enabled"`
|
||||
ScrobbleEnabled bool `json:"scrobble_enabled"`
|
||||
CredentialsConfigured bool `json:"credentials_configured"`
|
||||
ConnectionConfigSchema []plugins.ConfigSchemaView `json:"connection_config_schema,omitempty"`
|
||||
LastInboundSyncAt *time.Time `json:"last_inbound_sync_at,omitempty"`
|
||||
LastProgressSyncAt *time.Time `json:"last_progress_sync_at,omitempty"`
|
||||
LastOutboundSyncAt *time.Time `json:"last_outbound_sync_at,omitempty"`
|
||||
LastFavoritesSyncAt *time.Time `json:"last_favorites_sync_at,omitempty"`
|
||||
LastWatchlistSyncAt *time.Time `json:"last_watchlist_sync_at,omitempty"`
|
||||
LastScrobbleErrorAt *time.Time `json:"last_scrobble_error_at,omitempty"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
}
|
||||
|
||||
type ConnectionUpdate struct {
|
||||
|
||||
@@ -36,6 +36,30 @@ const schema: PluginConfigSchema = {
|
||||
};
|
||||
|
||||
describe("PluginConfigForm secrets", () => {
|
||||
it("derives a form when a plugin only supplies JSON Schema", () => {
|
||||
render(
|
||||
<PluginConfigForm
|
||||
schema={{
|
||||
key: "server",
|
||||
title: "Server",
|
||||
json_schema: JSON.stringify({
|
||||
type: "object",
|
||||
properties: {
|
||||
base_url: { type: "string", title: "Base URL" },
|
||||
api_key: { type: "string", format: "password" },
|
||||
},
|
||||
required: ["base_url", "api_key"],
|
||||
}),
|
||||
required: true,
|
||||
}}
|
||||
onSave={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText("Base URL")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Api Key")).toHaveAttribute("type", "password");
|
||||
});
|
||||
|
||||
it("shows redacted saved state and only clears through an explicit action", async () => {
|
||||
const onSave = vi.fn();
|
||||
render(
|
||||
|
||||
@@ -10,11 +10,14 @@ import { ConnectionCheckAction } from "@/components/admin/ConnectionCheckAction"
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
import { adminFormForConfigSchema, humanizeConfigKey } from "./configSchemaAdminForm";
|
||||
import { SchemaForm } from "./SchemaForm";
|
||||
import { buildSchemaValues } from "./schemaFormUtils";
|
||||
|
||||
type PluginConfigValue = Record<string, unknown>;
|
||||
|
||||
const EMPTY_FIELDS: PluginAdminFormField[] = [];
|
||||
|
||||
type Props = {
|
||||
schema: PluginConfigSchema;
|
||||
value?: PluginConfigValue;
|
||||
@@ -29,82 +32,7 @@ type Props = {
|
||||
isTesting?: boolean;
|
||||
};
|
||||
|
||||
type SupportedField = PluginAdminFormField & {
|
||||
inferredType?: "string" | "number" | "integer" | "boolean";
|
||||
};
|
||||
|
||||
type ParsedObjectSchema = {
|
||||
supported: boolean;
|
||||
fields: SupportedField[];
|
||||
};
|
||||
|
||||
function humanizeKey(value: string) {
|
||||
return value
|
||||
.split("_")
|
||||
.filter(Boolean)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function parseJSONSchema(schema: PluginConfigSchema): ParsedObjectSchema {
|
||||
try {
|
||||
const parsed = JSON.parse(schema.json_schema) as {
|
||||
type?: string;
|
||||
required?: string[];
|
||||
properties?: Record<
|
||||
string,
|
||||
{
|
||||
type?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
writeOnly?: boolean;
|
||||
format?: string;
|
||||
}
|
||||
>;
|
||||
};
|
||||
if (parsed.type !== "object" || !parsed.properties) {
|
||||
return { supported: false, fields: [] };
|
||||
}
|
||||
|
||||
const fields = Object.entries(parsed.properties).map(([key, property]) => {
|
||||
const propertyType = property.type;
|
||||
if (!propertyType || !["string", "number", "integer", "boolean"].includes(propertyType)) {
|
||||
return null;
|
||||
}
|
||||
const isSensitive = property.writeOnly === true || property.format === "password";
|
||||
const control =
|
||||
propertyType === "boolean"
|
||||
? "SWITCH"
|
||||
: propertyType === "number" || propertyType === "integer"
|
||||
? "NUMBER"
|
||||
: isSensitive
|
||||
? "PASSWORD"
|
||||
: "TEXT";
|
||||
return {
|
||||
key,
|
||||
label: property.title || humanizeKey(key),
|
||||
description: property.description,
|
||||
control,
|
||||
placeholder: "",
|
||||
required: parsed.required?.includes(key) ?? false,
|
||||
secret: isSensitive,
|
||||
multiline: false,
|
||||
options: [],
|
||||
rows: 0,
|
||||
inferredType: propertyType as "string" | "number" | "integer" | "boolean",
|
||||
} satisfies SupportedField;
|
||||
});
|
||||
|
||||
if (fields.some((field) => field == null)) {
|
||||
return { supported: false, fields: [] };
|
||||
}
|
||||
return { supported: true, fields: fields.filter(Boolean) as SupportedField[] };
|
||||
} catch {
|
||||
return { supported: false, fields: [] };
|
||||
}
|
||||
}
|
||||
|
||||
function defaultValueForField(field: SupportedField): string | boolean {
|
||||
function defaultValueForField(field: PluginAdminFormField): string | boolean {
|
||||
if (field.default_value !== undefined) {
|
||||
if (typeof field.default_value === "boolean") {
|
||||
return field.default_value;
|
||||
@@ -122,7 +50,10 @@ function defaultValueForField(field: SupportedField): string | boolean {
|
||||
return "";
|
||||
}
|
||||
|
||||
function valueForField(field: SupportedField, configValue?: PluginConfigValue): string | boolean {
|
||||
function valueForField(
|
||||
field: PluginAdminFormField,
|
||||
configValue?: PluginConfigValue,
|
||||
): string | boolean {
|
||||
const raw = configValue?.[field.key];
|
||||
if (typeof raw === "boolean") {
|
||||
return raw;
|
||||
@@ -145,19 +76,12 @@ export function PluginConfigForm({
|
||||
isSaving = false,
|
||||
isTesting = false,
|
||||
}: Props) {
|
||||
const parsedFallback = useMemo(() => parseJSONSchema(schema), [schema]);
|
||||
const fields = useMemo<SupportedField[]>(() => {
|
||||
if (schema.admin_form?.fields?.length) {
|
||||
return schema.admin_form.fields;
|
||||
}
|
||||
return parsedFallback.fields;
|
||||
}, [parsedFallback.fields, schema.admin_form?.fields]);
|
||||
|
||||
const supported =
|
||||
fields.length > 0 && (schema.admin_form?.fields?.length ? true : parsedFallback.supported);
|
||||
const inferredDescriptor = useMemo(() => adminFormForConfigSchema(schema), [schema]);
|
||||
const fields = inferredDescriptor?.fields ?? EMPTY_FIELDS;
|
||||
const supported = inferredDescriptor != null;
|
||||
|
||||
const descriptor = useMemo<PluginAdminForm>(() => {
|
||||
const base = schema.admin_form ?? { fields };
|
||||
const base = inferredDescriptor ?? { fields };
|
||||
const configured = new Set(configuredSecrets);
|
||||
return {
|
||||
...base,
|
||||
@@ -167,7 +91,7 @@ export function PluginConfigForm({
|
||||
: field,
|
||||
),
|
||||
};
|
||||
}, [configuredSecrets, fields, schema.admin_form]);
|
||||
}, [configuredSecrets, fields, inferredDescriptor]);
|
||||
|
||||
const [values, setValues] = useState<PluginConfigValue>(() =>
|
||||
Object.fromEntries(fields.map((field) => [field.key, valueForField(field, value)])),
|
||||
@@ -248,7 +172,7 @@ export function PluginConfigForm({
|
||||
return (
|
||||
<div key={key} className="flex items-center justify-between gap-3 text-xs">
|
||||
<span className={clearing ? "text-destructive" : "text-muted-foreground"}>
|
||||
{field?.label || humanizeKey(key)}: {clearing ? "will be cleared" : "saved"}
|
||||
{field?.label || humanizeConfigKey(key)}: {clearing ? "will be cleared" : "saved"}
|
||||
{required ? " (required)" : ""}
|
||||
</span>
|
||||
{!required ? (
|
||||
|
||||
@@ -98,6 +98,32 @@ describe("SchemaForm", () => {
|
||||
"true",
|
||||
);
|
||||
});
|
||||
it("uses a controlling field default when rendering a conditional field", () => {
|
||||
const d: PluginAdminForm = {
|
||||
fields: [
|
||||
{
|
||||
key: "advanced_enabled",
|
||||
label: "Advanced",
|
||||
control: "SWITCH",
|
||||
required: false,
|
||||
secret: false,
|
||||
multiline: false,
|
||||
default_value: true,
|
||||
},
|
||||
{
|
||||
key: "endpoint",
|
||||
label: "Endpoint",
|
||||
control: "TEXT",
|
||||
required: false,
|
||||
secret: false,
|
||||
multiline: false,
|
||||
show_when: [{ field: "advanced_enabled", equals: ["true"] }],
|
||||
},
|
||||
],
|
||||
};
|
||||
render(<SchemaForm descriptor={d} values={{}} onChange={vi.fn()} />);
|
||||
expect(screen.getByText("Endpoint")).toBeTruthy();
|
||||
});
|
||||
it("reports validity through onValidityChange (#14)", () => {
|
||||
const onValidityChange = vi.fn();
|
||||
const d: PluginAdminForm = {
|
||||
@@ -193,6 +219,43 @@ describe("SchemaForm collapsible sections", () => {
|
||||
fireEvent.click(screen.getByText("Show"));
|
||||
expect(screen.getByText("Verbose")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("uses a controlling field default when rendering a conditional section", () => {
|
||||
const d: PluginAdminForm = {
|
||||
fields: [
|
||||
{
|
||||
key: "advanced_enabled",
|
||||
label: "Advanced",
|
||||
control: "SWITCH",
|
||||
required: false,
|
||||
secret: false,
|
||||
multiline: false,
|
||||
default_value: true,
|
||||
},
|
||||
{
|
||||
key: "endpoint",
|
||||
label: "Endpoint",
|
||||
control: "TEXT",
|
||||
required: false,
|
||||
secret: false,
|
||||
multiline: false,
|
||||
},
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
key: "advanced",
|
||||
title: "Advanced options",
|
||||
collapsible: false,
|
||||
collapsed_default: false,
|
||||
field_keys: ["endpoint"],
|
||||
show_when: [{ field: "advanced_enabled", equals: ["true"] }],
|
||||
},
|
||||
],
|
||||
};
|
||||
render(<SchemaForm descriptor={d} values={{}} onChange={vi.fn()} />);
|
||||
expect(screen.getByText("Advanced options")).toBeTruthy();
|
||||
expect(screen.getByText("Endpoint")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("marks a show_when-gated field as nested when it is revealed", () => {
|
||||
|
||||
@@ -97,11 +97,13 @@ function ChipsSkeleton() {
|
||||
function SchemaFormSection({
|
||||
section,
|
||||
values,
|
||||
fields,
|
||||
forceOpen,
|
||||
renderFields,
|
||||
}: {
|
||||
section: PluginAdminFormSection;
|
||||
values: Record<string, unknown>;
|
||||
fields: PluginAdminFormField[];
|
||||
forceOpen: boolean;
|
||||
renderFields: (keys: string[]) => React.ReactNode;
|
||||
}) {
|
||||
@@ -109,7 +111,7 @@ function SchemaFormSection({
|
||||
// (the section has unresolved errors) always wins so setup can't be hidden.
|
||||
const [userOpen, setUserOpen] = useState<boolean | null>(null);
|
||||
|
||||
if (!evaluateShowWhen(section.show_when, values)) {
|
||||
if (!evaluateShowWhen(section.show_when, values, fields)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -331,7 +333,9 @@ export function SchemaForm({
|
||||
// bordered, divided container so toggles read as a cohesive group instead of
|
||||
// a column of separate boxes. Honors show_when on each field.
|
||||
function renderFieldList(fields: PluginAdminFormField[]): React.ReactNode {
|
||||
const visible = fields.filter((field) => evaluateShowWhen(field.show_when, values));
|
||||
const visible = fields.filter((field) =>
|
||||
evaluateShowWhen(field.show_when, values, descriptor.fields),
|
||||
);
|
||||
const nodes: React.ReactNode[] = [];
|
||||
let run: PluginAdminFormField[] = [];
|
||||
// Key switch groups by their position in the list, not by their first
|
||||
@@ -389,6 +393,7 @@ export function SchemaForm({
|
||||
key={section.key}
|
||||
section={section}
|
||||
values={values}
|
||||
fields={descriptor.fields}
|
||||
forceOpen={section.field_keys.some((key) => mergedErrors[key] != null)}
|
||||
renderFields={(keys) => renderFieldList(resolveKeys(keys))}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { PluginConfigSchema } from "@/api/types";
|
||||
|
||||
import { adminFormForConfigSchema } from "./configSchemaAdminForm";
|
||||
|
||||
function schema(overrides: Partial<PluginConfigSchema> = {}): PluginConfigSchema {
|
||||
return {
|
||||
key: "connection",
|
||||
title: "Connection",
|
||||
json_schema: JSON.stringify({
|
||||
type: "object",
|
||||
properties: {},
|
||||
additionalProperties: false,
|
||||
}),
|
||||
required: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("adminFormForConfigSchema", () => {
|
||||
it("preserves primitive JSON Schema defaults on inferred fields", () => {
|
||||
const form = adminFormForConfigSchema(
|
||||
schema({
|
||||
json_schema: JSON.stringify({
|
||||
type: "object",
|
||||
properties: {
|
||||
base_url: { type: "string", default: "https://floppy.example.com" },
|
||||
port: { type: "integer", default: 8080 },
|
||||
verify_tls: { type: "boolean", default: true },
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(form?.fields.map(({ key, default_value }) => ({ key, default_value }))).toEqual([
|
||||
{ key: "base_url", default_value: "https://floppy.example.com" },
|
||||
{ key: "port", default_value: 8080 },
|
||||
{ key: "verify_tls", default_value: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("adds scalar schema properties omitted by a partial admin form", () => {
|
||||
const form = adminFormForConfigSchema(
|
||||
schema({
|
||||
json_schema: JSON.stringify({
|
||||
type: "object",
|
||||
properties: {
|
||||
base_url: { type: "string" },
|
||||
username: { type: "string" },
|
||||
},
|
||||
required: ["base_url", "username"],
|
||||
}),
|
||||
admin_form: {
|
||||
fields: [
|
||||
{
|
||||
key: "base_url",
|
||||
label: "Custom URL",
|
||||
control: "TEXT",
|
||||
required: true,
|
||||
secret: false,
|
||||
multiline: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(form?.fields.map(({ key, label, required }) => ({ key, label, required }))).toEqual([
|
||||
{ key: "base_url", label: "Custom URL", required: true },
|
||||
{ key: "username", label: "Username", required: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves an explicit form when its schema cannot be inferred", () => {
|
||||
const explicit = {
|
||||
fields: [
|
||||
{
|
||||
key: "api_key",
|
||||
label: "API Key",
|
||||
control: "PASSWORD" as const,
|
||||
required: true,
|
||||
secret: true,
|
||||
multiline: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(adminFormForConfigSchema(schema({ json_schema: "", admin_form: explicit }))).toBe(
|
||||
explicit,
|
||||
);
|
||||
});
|
||||
|
||||
it("applies JSON Schema sensitivity to matching explicit fields", () => {
|
||||
const form = adminFormForConfigSchema(
|
||||
schema({
|
||||
json_schema: JSON.stringify({
|
||||
type: "object",
|
||||
properties: {
|
||||
api_key: { type: "string", writeOnly: true },
|
||||
password: { type: "string", format: "password" },
|
||||
advanced: { type: "object", writeOnly: true },
|
||||
},
|
||||
}),
|
||||
admin_form: {
|
||||
fields: [
|
||||
{
|
||||
key: "api_key",
|
||||
label: "API Key",
|
||||
control: "TEXT",
|
||||
required: false,
|
||||
secret: false,
|
||||
multiline: false,
|
||||
},
|
||||
{
|
||||
key: "password",
|
||||
label: "Password",
|
||||
control: "TEXT",
|
||||
required: false,
|
||||
secret: false,
|
||||
multiline: false,
|
||||
},
|
||||
{
|
||||
key: "advanced",
|
||||
label: "Advanced",
|
||||
control: "TEXTAREA",
|
||||
required: false,
|
||||
secret: false,
|
||||
multiline: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(form?.fields.map(({ key, secret }) => ({ key, secret }))).toEqual([
|
||||
{ key: "api_key", secret: true },
|
||||
{ key: "password", secret: true },
|
||||
{ key: "advanced", secret: true },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { PluginAdminForm, PluginAdminFormField, PluginConfigSchema } from "@/api/types";
|
||||
|
||||
export function humanizeConfigKey(value: string) {
|
||||
return value
|
||||
.split("_")
|
||||
.filter(Boolean)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
export function adminFormForConfigSchema(schema: PluginConfigSchema): PluginAdminForm | null {
|
||||
const explicitFields = schema.admin_form?.fields ?? [];
|
||||
try {
|
||||
const parsed = JSON.parse(schema.json_schema) as {
|
||||
type?: string;
|
||||
required?: string[];
|
||||
properties?: Record<
|
||||
string,
|
||||
{
|
||||
type?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
writeOnly?: boolean;
|
||||
format?: string;
|
||||
default?: unknown;
|
||||
}
|
||||
>;
|
||||
};
|
||||
if (parsed.type !== "object" || !parsed.properties) {
|
||||
return explicitFields.length > 0 ? schema.admin_form! : null;
|
||||
}
|
||||
|
||||
const inferredFields = Object.entries(parsed.properties).map(
|
||||
([key, property]): PluginAdminFormField | null => {
|
||||
const propertyType = property.type;
|
||||
if (!propertyType || !["string", "number", "integer", "boolean"].includes(propertyType)) {
|
||||
return null;
|
||||
}
|
||||
const secret = property.writeOnly === true || property.format === "password";
|
||||
const control =
|
||||
propertyType === "boolean"
|
||||
? "SWITCH"
|
||||
: propertyType === "number" || propertyType === "integer"
|
||||
? "NUMBER"
|
||||
: secret
|
||||
? "PASSWORD"
|
||||
: "TEXT";
|
||||
return {
|
||||
key,
|
||||
label: property.title || humanizeConfigKey(key),
|
||||
description: property.description,
|
||||
control,
|
||||
placeholder: "",
|
||||
required: parsed.required?.includes(key) ?? false,
|
||||
secret,
|
||||
multiline: false,
|
||||
default_value:
|
||||
typeof property.default === "string" ||
|
||||
typeof property.default === "number" ||
|
||||
typeof property.default === "boolean"
|
||||
? property.default
|
||||
: undefined,
|
||||
options: [],
|
||||
rows: 0,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
if (explicitFields.length > 0) {
|
||||
const explicitKeys = new Set(explicitFields.map((field) => field.key));
|
||||
const sensitiveKeys = new Set(
|
||||
Object.entries(parsed.properties)
|
||||
.filter(([, property]) => property.writeOnly === true || property.format === "password")
|
||||
.map(([key]) => key),
|
||||
);
|
||||
return {
|
||||
...schema.admin_form,
|
||||
fields: [
|
||||
...explicitFields.map((field) =>
|
||||
sensitiveKeys.has(field.key) ? { ...field, secret: true } : field,
|
||||
),
|
||||
...inferredFields.filter(
|
||||
(field): field is PluginAdminFormField => field != null && !explicitKeys.has(field.key),
|
||||
),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (inferredFields.some((field) => field == null)) return null;
|
||||
|
||||
return {
|
||||
...schema.admin_form,
|
||||
fields: inferredFields.filter((field): field is PluginAdminFormField => field != null),
|
||||
};
|
||||
} catch {
|
||||
return explicitFields.length > 0 ? schema.admin_form! : null;
|
||||
}
|
||||
}
|
||||
@@ -172,6 +172,7 @@ describe("parseFieldTypes (#15)", () => {
|
||||
root_folder: { type: "string" },
|
||||
tags: { type: "array", items: { type: "integer" } },
|
||||
labels: { type: "array", items: { type: "string" } },
|
||||
flags: { type: "array", items: { type: "boolean" } },
|
||||
enabled: { type: "boolean" },
|
||||
},
|
||||
});
|
||||
@@ -180,6 +181,7 @@ describe("parseFieldTypes (#15)", () => {
|
||||
root_folder: "string",
|
||||
tags: "array:int",
|
||||
labels: "array",
|
||||
flags: "array:bool",
|
||||
enabled: "boolean",
|
||||
});
|
||||
});
|
||||
@@ -443,3 +445,63 @@ describe("coerceFieldValue array:num coercion (CodeRabbit #5)", () => {
|
||||
expect(coerceFieldValue(numArrayField, ["abc"], "array:num")).toEqual(["abc"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("coerceFieldValue array:bool coercion", () => {
|
||||
it("coerces boolean multi-select values using their declared item type", () => {
|
||||
expect(coerceFieldValue(numArrayField, ["true", "false", true], "array:bool")).toEqual([
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("section visibility", () => {
|
||||
const sectionDescriptor: PluginAdminForm = {
|
||||
fields: [
|
||||
{
|
||||
key: "advanced_enabled",
|
||||
label: "Advanced",
|
||||
control: "SWITCH",
|
||||
required: false,
|
||||
secret: false,
|
||||
multiline: false,
|
||||
default_value: false,
|
||||
},
|
||||
{
|
||||
key: "endpoint",
|
||||
label: "Endpoint",
|
||||
control: "TEXT",
|
||||
required: true,
|
||||
secret: false,
|
||||
multiline: false,
|
||||
},
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
key: "advanced",
|
||||
title: "Advanced",
|
||||
collapsible: false,
|
||||
collapsed_default: false,
|
||||
field_keys: ["endpoint"],
|
||||
show_when: [{ field: "advanced_enabled", equals: ["true"] }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it("does not validate required fields in a hidden section", () => {
|
||||
expect(validateSchemaValues(sectionDescriptor, { endpoint: "stale" })).toEqual({});
|
||||
expect(validateSchemaValues(sectionDescriptor, { advanced_enabled: true }).endpoint).toMatch(
|
||||
/required/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not persist stale values from a hidden section", () => {
|
||||
expect(buildSchemaValues(sectionDescriptor, { endpoint: "stale" })).toEqual({
|
||||
advanced_enabled: false,
|
||||
});
|
||||
expect(
|
||||
buildSchemaValues(sectionDescriptor, { advanced_enabled: true, endpoint: "active" }),
|
||||
).toEqual({ advanced_enabled: true, endpoint: "active" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -45,7 +45,7 @@ export function validateSchemaValues(
|
||||
): Record<string, string> {
|
||||
const errors: Record<string, string> = {};
|
||||
for (const field of descriptor.fields) {
|
||||
if (!evaluateShowWhen(field.show_when, values, descriptor.fields)) continue;
|
||||
if (!fieldIsVisible(descriptor, field, values)) continue;
|
||||
const raw = effectiveValue(field, values);
|
||||
if (field.required && isEmpty(raw)) {
|
||||
errors[field.key] = `${field.label || field.key} is required`;
|
||||
@@ -120,6 +120,7 @@ export type FieldType =
|
||||
| "number"
|
||||
| "boolean"
|
||||
| "array"
|
||||
| "array:bool"
|
||||
| "array:int"
|
||||
| "array:num";
|
||||
|
||||
@@ -150,6 +151,7 @@ export function parseFieldTypes(jsonSchema: string | undefined | null): Record<s
|
||||
items && typeof items === "object" ? (items as { type?: unknown }).type : undefined;
|
||||
if (itemType === "integer") out[key] = "array:int";
|
||||
else if (itemType === "number") out[key] = "array:num";
|
||||
else if (itemType === "boolean") out[key] = "array:bool";
|
||||
else out[key] = "array";
|
||||
} else if (type === "string" || type === "integer" || type === "number" || type === "boolean") {
|
||||
out[key] = type;
|
||||
@@ -191,10 +193,12 @@ export function coerceFieldValue(
|
||||
return raw;
|
||||
}
|
||||
case "array":
|
||||
case "array:bool":
|
||||
case "array:int":
|
||||
case "array:num": {
|
||||
const arr = Array.isArray(raw) ? raw : [];
|
||||
if (fieldType === "array") return arr;
|
||||
if (fieldType === "array:bool") return arr.map((v) => coerceBoolean(v));
|
||||
if (fieldType === "array:int") return arr.map((v) => coerceNumericString(v));
|
||||
return arr.map((v) => coerceNumberString(v));
|
||||
}
|
||||
@@ -237,6 +241,23 @@ export function effectiveValue(
|
||||
return values[field.key] !== undefined ? values[field.key] : field.default_value;
|
||||
}
|
||||
|
||||
export function fieldIsVisible(
|
||||
descriptor: PluginAdminForm,
|
||||
field: PluginAdminFormField,
|
||||
values: Record<string, unknown>,
|
||||
): boolean {
|
||||
if (!evaluateShowWhen(field.show_when, values, descriptor.fields)) return false;
|
||||
const containingSections = (descriptor.sections ?? []).filter((section) =>
|
||||
section.field_keys.includes(field.key),
|
||||
);
|
||||
return (
|
||||
containingSections.length === 0 ||
|
||||
containingSections.some((section) =>
|
||||
evaluateShowWhen(section.show_when, values, descriptor.fields),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function buildSchemaValues(
|
||||
descriptor: PluginAdminForm,
|
||||
draft: Record<string, unknown>,
|
||||
@@ -244,7 +265,7 @@ export function buildSchemaValues(
|
||||
): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const field of descriptor.fields) {
|
||||
if (!evaluateShowWhen(field.show_when, draft, descriptor.fields)) continue; // don't persist hidden fields' stale values
|
||||
if (!fieldIsVisible(descriptor, field, draft)) continue; // don't persist hidden fields' stale values
|
||||
// Fall back to the declared default for untouched fields so an unmodified
|
||||
// default persists exactly as it is displayed.
|
||||
const rawSource = draft[field.key] !== undefined ? draft[field.key] : field.default_value;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
connectWatchProviderAPIKey,
|
||||
fetchWatchProviders,
|
||||
pollWatchProviderDeviceAuth,
|
||||
startWatchProviderDeviceAuth,
|
||||
@@ -42,6 +43,7 @@ vi.mock("@/api/client", () => ({
|
||||
return {
|
||||
path,
|
||||
method: init?.method ?? "GET",
|
||||
body: init?.body,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
@@ -61,6 +63,18 @@ describe("watch provider queries", () => {
|
||||
path: "/watch-providers/trakt/auth/poll",
|
||||
method: "POST",
|
||||
});
|
||||
await expect(
|
||||
connectWatchProviderAPIKey("plugin:4:floppy", "token", {
|
||||
floppy: { base_url: "https://floppy.example.com" },
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
path: "/watch-providers/plugin:4:floppy/auth/api-key",
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
api_key: "token",
|
||||
connection_config: { floppy: { base_url: "https://floppy.example.com" } },
|
||||
}),
|
||||
});
|
||||
await expect(
|
||||
updateWatchProviderConnection("trakt", { scrobble_enabled: true }),
|
||||
).resolves.toMatchObject({
|
||||
|
||||
@@ -3,11 +3,15 @@ import { api, ApiClientError } from "@/api/client";
|
||||
import { favoriteKeys, watchlistKeys, watchProviderKeys } from "./keys";
|
||||
import { toast } from "sonner";
|
||||
import { storage } from "@/utils/storage";
|
||||
import type { PluginConfigSchema } from "@/api/types";
|
||||
|
||||
export type WatchProviderConnectionConfig = Record<string, Record<string, unknown>>;
|
||||
|
||||
export interface WatchProviderSummary {
|
||||
key: string;
|
||||
display_name: string;
|
||||
capabilities: WatchProviderCapabilities;
|
||||
connection_config_schema?: PluginConfigSchema[];
|
||||
}
|
||||
|
||||
export const WatchProviderAuthMethod = {
|
||||
@@ -52,6 +56,7 @@ export interface WatchProviderConnection {
|
||||
sync_watchlist_order_enabled: boolean;
|
||||
scrobble_enabled: boolean;
|
||||
credentials_configured: boolean;
|
||||
connection_config_schema?: PluginConfigSchema[];
|
||||
last_inbound_sync_at?: string;
|
||||
last_progress_sync_at?: string;
|
||||
last_outbound_sync_at?: string;
|
||||
@@ -159,10 +164,14 @@ export function pollWatchProviderDeviceAuth(provider: string, authSessionId: str
|
||||
});
|
||||
}
|
||||
|
||||
export function connectWatchProviderAPIKey(provider: string, apiKey: string) {
|
||||
export function connectWatchProviderAPIKey(
|
||||
provider: string,
|
||||
apiKey: string,
|
||||
connectionConfig: WatchProviderConnectionConfig = {},
|
||||
) {
|
||||
return api<WatchProviderConnection>(`/watch-providers/${provider}/auth/api-key`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ api_key: apiKey }),
|
||||
body: JSON.stringify({ api_key: apiKey, connection_config: connectionConfig }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -248,7 +257,13 @@ export function usePollWatchProviderDeviceAuth(provider: string) {
|
||||
export function useConnectWatchProviderAPIKey(provider: string) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (apiKey: string) => connectWatchProviderAPIKey(provider, apiKey),
|
||||
mutationFn: ({
|
||||
apiKey,
|
||||
connectionConfig,
|
||||
}: {
|
||||
apiKey: string;
|
||||
connectionConfig?: WatchProviderConnectionConfig;
|
||||
}) => connectWatchProviderAPIKey(provider, apiKey, connectionConfig),
|
||||
onSuccess: (connection) => {
|
||||
const profileId = getActiveProfileId();
|
||||
queryClient.setQueryData(watchProviderKeys.connection(profileId, provider), connection);
|
||||
|
||||
@@ -32,6 +32,14 @@ import {
|
||||
type WatchProviderSyncRun,
|
||||
} from "@/hooks/queries/watchProviders";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { SchemaForm } from "@/components/admin/plugins/SchemaForm";
|
||||
import type { PluginConfigSchema } from "@/api/types";
|
||||
import type { WatchProviderConnectionConfig } from "@/hooks/queries/watchProviders";
|
||||
import {
|
||||
buildConnectionConfig,
|
||||
connectionSchemasAreValid,
|
||||
renderableConnectionSchemas,
|
||||
} from "./watchProviderConnectionConfig";
|
||||
|
||||
function formatRelativeTime(value?: string) {
|
||||
if (!value) return "Never";
|
||||
@@ -214,17 +222,29 @@ function AuthCodeBlock({
|
||||
|
||||
function APIKeyBlock({
|
||||
displayName,
|
||||
providerKey,
|
||||
configSchemas,
|
||||
pending,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: {
|
||||
displayName: string;
|
||||
providerKey: string;
|
||||
configSchemas: PluginConfigSchema[];
|
||||
pending: boolean;
|
||||
onSubmit: (apiKey: string) => void;
|
||||
onSubmit: (apiKey: string, connectionConfig: WatchProviderConnectionConfig) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [value, setValue] = useState("");
|
||||
const [connectionConfig, setConnectionConfig] = useState<WatchProviderConnectionConfig>({});
|
||||
const [configValidity, setConfigValidity] = useState<Record<string, boolean>>({});
|
||||
const trimmed = value.trim();
|
||||
const renderableSchemas = renderableConnectionSchemas(configSchemas);
|
||||
const configValid = connectionSchemasAreValid(
|
||||
renderableSchemas,
|
||||
connectionConfig,
|
||||
configValidity,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="border-primary/30 bg-primary/5 rounded-xl border border-dashed p-4">
|
||||
@@ -245,6 +265,29 @@ function APIKeyBlock({
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{renderableSchemas.map((schema) => (
|
||||
<div key={schema.key} className="mt-4 space-y-2">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{schema.title || schema.key}</div>
|
||||
{schema.description ? (
|
||||
<div className="text-muted-foreground mt-0.5 text-xs leading-snug">
|
||||
{schema.description}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<SchemaForm
|
||||
descriptor={schema.admin_form}
|
||||
values={connectionConfig[schema.key] ?? {}}
|
||||
onChange={(next) =>
|
||||
setConnectionConfig((current) => ({ ...current, [schema.key]: next }))
|
||||
}
|
||||
idPrefix={`watch-provider-${providerKey}-${schema.key}`}
|
||||
onValidityChange={(valid) =>
|
||||
setConfigValidity((current) => ({ ...current, [schema.key]: valid }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div className="mt-3 flex flex-col gap-2 sm:flex-row sm:items-stretch">
|
||||
<Input
|
||||
type="password"
|
||||
@@ -258,8 +301,10 @@ function APIKeyBlock({
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={pending || trimmed.length === 0}
|
||||
onClick={() => onSubmit(trimmed)}
|
||||
disabled={pending || trimmed.length === 0 || !configValid}
|
||||
onClick={() =>
|
||||
onSubmit(trimmed, buildConnectionConfig(renderableSchemas, connectionConfig))
|
||||
}
|
||||
className="sm:flex-none"
|
||||
>
|
||||
{pending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Check className="h-4 w-4" />}
|
||||
@@ -454,12 +499,15 @@ function WatchProviderCard({ providerKey }: { providerKey: string }) {
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmitAPIKey = (apiKey: string) => {
|
||||
connectAPIKey.mutate(apiKey, {
|
||||
onSuccess: () => {
|
||||
setApiKeyPrompt(false);
|
||||
const handleSubmitAPIKey = (apiKey: string, connectionConfig: WatchProviderConnectionConfig) => {
|
||||
connectAPIKey.mutate(
|
||||
{ apiKey, connectionConfig },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setApiKeyPrompt(false);
|
||||
},
|
||||
},
|
||||
});
|
||||
);
|
||||
};
|
||||
|
||||
const handleCancelAPIKey = () => {
|
||||
@@ -549,6 +597,8 @@ function WatchProviderCard({ providerKey }: { providerKey: string }) {
|
||||
<div className="mt-4">
|
||||
<APIKeyBlock
|
||||
displayName={displayName}
|
||||
providerKey={providerKey}
|
||||
configSchemas={connection.connection_config_schema ?? []}
|
||||
pending={connectAPIKey.isPending}
|
||||
onSubmit={handleSubmitAPIKey}
|
||||
onCancel={handleCancelAPIKey}
|
||||
@@ -622,14 +672,18 @@ function WatchProviderCard({ providerKey }: { providerKey: string }) {
|
||||
disabled={isBusy}
|
||||
onChange={(checked) => updateConnection.mutate({ export_watched_enabled: checked })}
|
||||
/>
|
||||
<ToggleRow
|
||||
id={`watch-provider-${providerKey}-export-unwatched`}
|
||||
label="Send unwatched changes"
|
||||
description="When you mark something unwatched, remove matching history from this provider."
|
||||
checked={connection.export_unwatched_enabled}
|
||||
disabled={isBusy}
|
||||
onChange={(checked) => updateConnection.mutate({ export_unwatched_enabled: checked })}
|
||||
/>
|
||||
{connection.capabilities.export_unwatched ? (
|
||||
<ToggleRow
|
||||
id={`watch-provider-${providerKey}-export-unwatched`}
|
||||
label="Send unwatched changes"
|
||||
description="When you mark something unwatched, remove matching history from this provider."
|
||||
checked={connection.export_unwatched_enabled}
|
||||
disabled={isBusy}
|
||||
onChange={(checked) =>
|
||||
updateConnection.mutate({ export_unwatched_enabled: checked })
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{connection.capabilities.import_favorites ||
|
||||
connection.capabilities.export_favorites ? (
|
||||
<ToggleRow
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { PluginConfigSchema } from "@/api/types";
|
||||
|
||||
import {
|
||||
activeConnectionSchemas,
|
||||
buildConnectionConfig,
|
||||
connectionSchemasAreValid,
|
||||
renderableConnectionSchemas,
|
||||
} from "./watchProviderConnectionConfig";
|
||||
|
||||
const optionalSchema: PluginConfigSchema = {
|
||||
key: "optional",
|
||||
title: "Optional server settings",
|
||||
description: "Only sent when configured.",
|
||||
json_schema: JSON.stringify({
|
||||
type: "object",
|
||||
properties: { base_url: { type: "string" } },
|
||||
required: ["base_url"],
|
||||
}),
|
||||
required: false,
|
||||
admin_form: {
|
||||
fields: [
|
||||
{
|
||||
key: "base_url",
|
||||
label: "Server URL",
|
||||
control: "TEXT",
|
||||
required: true,
|
||||
secret: false,
|
||||
multiline: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
describe("watch provider connection config", () => {
|
||||
it("omits an untouched optional schema without blocking Connect", () => {
|
||||
expect(activeConnectionSchemas([optionalSchema], {})).toEqual([]);
|
||||
expect(connectionSchemasAreValid([optionalSchema], {}, { optional: false })).toBe(true);
|
||||
expect(buildConnectionConfig([optionalSchema], {})).toEqual({});
|
||||
});
|
||||
|
||||
it("validates and submits an optional schema after the user enters a value", () => {
|
||||
const drafts = { optional: { base_url: "https://floppy.example.com" } };
|
||||
expect(connectionSchemasAreValid([optionalSchema], drafts, { optional: false })).toBe(false);
|
||||
expect(connectionSchemasAreValid([optionalSchema], drafts, { optional: true })).toBe(true);
|
||||
expect(buildConnectionConfig([optionalSchema], drafts)).toEqual({
|
||||
optional: { base_url: "https://floppy.example.com" },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps required schemas active before any values are entered", () => {
|
||||
const requiredSchema = { ...optionalSchema, key: "required", required: true };
|
||||
expect(activeConnectionSchemas([requiredSchema], {})).toEqual([requiredSchema]);
|
||||
expect(connectionSchemasAreValid([requiredSchema], {}, {})).toBe(false);
|
||||
});
|
||||
|
||||
it("derives a usable form for a required JSON-schema-only block", () => {
|
||||
const headless = { ...optionalSchema, required: true, admin_form: undefined };
|
||||
const schemas = renderableConnectionSchemas([headless]);
|
||||
expect(schemas).toHaveLength(1);
|
||||
const renderable = schemas[0]!;
|
||||
expect(renderable.admin_form.fields).toEqual([
|
||||
expect.objectContaining({
|
||||
key: "base_url",
|
||||
label: "Base Url",
|
||||
control: "TEXT",
|
||||
required: true,
|
||||
}),
|
||||
]);
|
||||
expect(connectionSchemasAreValid([renderable], {}, {})).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { PluginConfigSchema } from "@/api/types";
|
||||
import { adminFormForConfigSchema } from "@/components/admin/plugins/configSchemaAdminForm";
|
||||
import { buildSchemaValues, parseFieldTypes } from "@/components/admin/plugins/schemaFormUtils";
|
||||
import type { WatchProviderConnectionConfig } from "@/hooks/queries/watchProviders";
|
||||
|
||||
export type RenderableConnectionSchema = PluginConfigSchema & {
|
||||
admin_form: NonNullable<PluginConfigSchema["admin_form"]>;
|
||||
};
|
||||
|
||||
export function renderableConnectionSchemas(
|
||||
schemas: PluginConfigSchema[],
|
||||
): RenderableConnectionSchema[] {
|
||||
return schemas.flatMap((schema) => {
|
||||
const adminForm = adminFormForConfigSchema(schema);
|
||||
return adminForm == null ? [] : [{ ...schema, admin_form: adminForm }];
|
||||
});
|
||||
}
|
||||
|
||||
function hasEnteredValue(value: unknown): boolean {
|
||||
if (value == null) return false;
|
||||
if (typeof value === "string") return value.trim().length > 0;
|
||||
if (Array.isArray(value)) return value.some(hasEnteredValue);
|
||||
if (typeof value === "object") return Object.values(value).some(hasEnteredValue);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function activeConnectionSchemas(
|
||||
schemas: PluginConfigSchema[],
|
||||
drafts: WatchProviderConnectionConfig,
|
||||
): RenderableConnectionSchema[] {
|
||||
return schemas.filter(
|
||||
(schema): schema is RenderableConnectionSchema =>
|
||||
schema.admin_form != null && (schema.required || hasEnteredValue(drafts[schema.key])),
|
||||
);
|
||||
}
|
||||
|
||||
export function connectionSchemasAreValid(
|
||||
schemas: PluginConfigSchema[],
|
||||
drafts: WatchProviderConnectionConfig,
|
||||
validity: Record<string, boolean>,
|
||||
): boolean {
|
||||
return activeConnectionSchemas(schemas, drafts).every((schema) => validity[schema.key] ?? false);
|
||||
}
|
||||
|
||||
export function buildConnectionConfig(
|
||||
schemas: PluginConfigSchema[],
|
||||
drafts: WatchProviderConnectionConfig,
|
||||
): WatchProviderConnectionConfig {
|
||||
return Object.fromEntries(
|
||||
activeConnectionSchemas(schemas, drafts).map((schema) => [
|
||||
schema.key,
|
||||
buildSchemaValues(
|
||||
schema.admin_form,
|
||||
drafts[schema.key] ?? {},
|
||||
parseFieldTypes(schema.json_schema),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user