package jsplugin import ( "context" "errors" "fmt" "maps" "math" "net/url" "regexp" "slices" "sort" "strings" "sync" "sync/atomic" "time" "unicode" "unicode/utf8" "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/logger" relaycommon "github.com/QuantumNous/new-api/relay/common" "github.com/QuantumNous/new-api/relaykit/dto" ) const APIVersion1 = 1 const ( maxLocalizedTextLocales = 16 maxMetaDescriptionRunes = 512 maxUsageFieldDescriptionRunes = 256 ) var pluginKeyPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`) var pluginVersionPattern = regexp.MustCompile(`^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$`) var localeTagPattern = regexp.MustCompile(`^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$`) // LocalizedText is locale-keyed display copy. Plugin source may use a bare // string (normalized to {"en": s}) or a map that must include "en". API // responses always emit an object. type LocalizedText map[string]string func (t LocalizedText) MarshalJSON() ([]byte, error) { if t == nil { return common.Marshal(map[string]string{}) } return common.Marshal(map[string]string(t)) } func (t *LocalizedText) UnmarshalJSON(data []byte) error { trimmed := strings.TrimSpace(string(data)) if trimmed == "" || trimmed == "null" { *t = nil return nil } switch trimmed[0] { case '"': var text string if err := common.Unmarshal(data, &text); err != nil { return err } *t = LocalizedText{"en": text} return nil case '{': var object map[string]string if err := common.Unmarshal(data, &object); err != nil { return err } *t = LocalizedText(object) return nil default: return fmt.Errorf("localized text must be a string or object") } } type Meta struct { APIVersion int `json:"apiVersion"` Key string `json:"key"` Name string `json:"name"` Icon string `json:"icon,omitempty"` Description LocalizedText `json:"description,omitempty"` Version string `json:"version"` Author AuthorMeta `json:"author"` ChannelTypes []int `json:"channelTypes,omitempty"` Models []string `json:"models"` FetchMode string `json:"fetchMode"` AllowedHosts []string `json:"allowedHosts"` Routes []Route `json:"routes"` Protocols []ProtocolClaim `json:"protocols"` UsageSchema map[string]UsageFieldSchema `json:"usageSchema,omitempty"` UsageExamples []UsageExample `json:"usageExamples,omitempty"` Auth AuthMeta `json:"auth"` } // ProtocolSupports reports whether the named protocol claim includes mode. func (m Meta) ProtocolSupports(protocol, mode string) bool { for _, claim := range m.Protocols { if claim.Name == protocol { return slices.Contains(claim.Supports, mode) } } return false } // UsageExample is a display-only pricing sample: a labeled complete vector // over usageSchema. It never participates in billing. type UsageExample struct { Label string `json:"label"` Facts map[string]any `json:"facts"` } type AuthorMeta struct { Name string `json:"name"` URL string `json:"url,omitempty"` } type AuthMeta struct { Type string `json:"type"` } // UsageFieldSchema declares how one usage fact is validated before it can // influence billing. Numeric facts use one of the host-owned canonical units; // boolean facts are flags; enum facts constrain non-numeric pricing selectors. type UsageFieldSchema struct { Type string `json:"type,omitempty"` Unit string `json:"unit,omitempty"` Enum []string `json:"enum,omitempty"` Description LocalizedText `json:"description,omitempty"` } type LoadedPlugin struct { Meta Meta Engine *Engine } // RegistrySnapshot is a read-only copy of the metadata currently stored in // each registry layer. type RegistrySnapshot struct { Factory []Meta Override []Meta DisabledFactory []string } type PreparedRoutingGeneration struct { Generation *RoutingGeneration Errors map[string]string } type RoutingRebuildOutcome struct { Status string `json:"status"` AttemptedAt time.Time `json:"attempted_at"` Generation uint64 `json:"generation"` Error string `json:"error,omitempty"` } type RoutingStatus struct { Generation *RoutingGeneration LastRebuild RoutingRebuildOutcome Errors map[string]string } type RoutingGenerationPreparer func(candidate, current *RoutingGeneration) (PreparedRoutingGeneration, error) type Registry struct { mu sync.RWMutex factory map[string]*LoadedPlugin override map[string]*LoadedPlugin activeOverride map[string]*LoadedPlugin disabledFactory map[string]struct{} masterEnabled atomic.Bool overrideEnabled atomic.Bool generation atomic.Pointer[RoutingGeneration] preparer RoutingGenerationPreparer routingErrors map[string]string lastRebuildErr string lastRebuild RoutingRebuildOutcome } func NewRegistry() *Registry { registry := &Registry{ factory: make(map[string]*LoadedPlugin), override: make(map[string]*LoadedPlugin), activeOverride: make(map[string]*LoadedPlugin), routingErrors: make(map[string]string), } registry.masterEnabled.Store(true) registry.overrideEnabled.Store(true) generation, _ := buildRoutingGeneration(registry.factory, registry.override, true, 0) registry.generation.Store(generation) registry.lastRebuild = RoutingRebuildOutcome{ Status: "success", AttemptedAt: generation.PublishedAt, Generation: generation.Number, } return registry } var DefaultRegistry = NewRegistry() func (r *Registry) Register(source string, options Options) (*LoadedPlugin, error) { return r.register(source, options, false) } func (r *Registry) RegisterFactory(source string, options Options) (*LoadedPlugin, error) { return r.register(source, options, true) } func (r *Registry) register(source string, options Options, factory bool) (*LoadedPlugin, error) { plugin, err := CompilePlugin(source, options) if err != nil { return nil, err } r.mu.Lock() defer r.mu.Unlock() factoryPlugins := clonePluginMap(r.factory) overridePlugins := clonePluginMap(r.override) if factory { factoryPlugins[plugin.Meta.Key] = plugin } else { overridePlugins[plugin.Meta.Key] = plugin } enabled := r.overrideEnabled.Load() generation, routingErrors, err := r.prepareGeneration(filterDisabledFactory(factoryPlugins, r.disabledFactory), overridePlugins, enabled, false, nil) if err != nil { r.recordRebuildFailure(err) return nil, err } if rejection := routingErrors[plugin.Meta.Key]; rejection != "" { r.recordRebuildFailure(errors.New(rejection)) return nil, fmt.Errorf("%s", rejection) } r.factory = factoryPlugins r.override = overridePlugins r.publishGeneration(generation, routingErrors, r.resolveActiveOverrides(generation, overridePlugins, enabled)) return plugin, nil } // CompilePlugin validates a plugin without publishing it. Callers that refresh // multiple plugins use this together with ReplaceOverrides so readers observe a // single generation transition. func CompilePlugin(source string, options Options) (*LoadedPlugin, error) { engine, err := Compile(source, options) if err != nil { return nil, err } value, err := engine.Export(context.Background(), "meta") if err != nil { return nil, err } meta, err := decodeMeta(value) if err != nil { return nil, err } if err = normalizeV1Meta(&meta); err != nil { return nil, err } engine.key = meta.Key engine.version = meta.Version requiredHooks := []string{"buildSubmitRequest", "parseSubmitResponse", "parseTaskResult"} if meta.FetchMode == "batch" { requiredHooks = append(requiredHooks, "buildBatchQueryRequest", "parseBatchResult") } else { requiredHooks = append(requiredHooks, "buildQueryRequest") } for _, hook := range requiredHooks { has, hasErr := engine.HasCallablePath(context.Background(), hook) if hasErr != nil { return nil, hasErr } if !has { return nil, fmt.Errorf("plugin %s is missing required export %q", meta.Key, hook) } } artifactHooks := make(map[string]bool, 2) for _, hook := range []string{"listArtifacts", "buildContentRequest"} { exported, exportErr := engine.HasExport(context.Background(), hook) if exportErr != nil { return nil, exportErr } if !exported { continue } callable, callableErr := engine.HasCallablePath(context.Background(), hook) if callableErr != nil { return nil, callableErr } if !callable { return nil, fmt.Errorf("plugin %s export %q is not a function", meta.Key, hook) } artifactHooks[hook] = true } if artifactHooks["listArtifacts"] != artifactHooks["buildContentRequest"] { return nil, fmt.Errorf("plugin %s must export listArtifacts and buildContentRequest together", meta.Key) } for _, route := range meta.Routes { for kind, member := range map[string]string{"decode": route.Decode, "render": route.Render} { if member == "" { continue } has, hasErr := engine.HasCallablePath(context.Background(), "native", member) if hasErr != nil { return nil, hasErr } if !has { return nil, fmt.Errorf("plugin %s route %s %s references missing native %s %q", meta.Key, route.Method, route.Path, kind, member) } } } for _, claim := range meta.Protocols { protocol := claim.Name definition, _ := HostProtocol(protocol) required := make(map[string]struct{}) allowed := make(map[string]struct{}) modeHookUsers := make(map[string][]string) for _, operation := range definition.Operations { for _, hook := range operation.RequiredProtocolMembers { required[hook] = struct{}{} allowed[hook] = struct{}{} } for _, mode := range operation.Modes { allowed[mode.Hook] = struct{}{} if !slices.Contains(modeHookUsers[mode.Hook], mode.Name) { modeHookUsers[mode.Hook] = append(modeHookUsers[mode.Hook], mode.Name) } if slices.Contains(claim.Supports, mode.Name) { required[mode.Hook] = struct{}{} } } for _, hook := range operation.RequiredDriverHooks { has, hasErr := engine.HasCallablePath(context.Background(), hook) if hasErr != nil { return nil, hasErr } if !has { return nil, fmt.Errorf("plugin %s protocol %q is missing driver hook %q", meta.Key, protocol, hook) } } } requiredHooks := make([]string, 0, len(required)) for hook := range required { requiredHooks = append(requiredHooks, hook) } sort.Strings(requiredHooks) for _, hook := range requiredHooks { has, hasErr := engine.HasCallablePath(context.Background(), "protocols", protocol, hook) if hasErr != nil { return nil, hasErr } if !has { if users := modeHookUsers[hook]; len(users) > 0 { mentioned := "" for _, name := range claim.Supports { if slices.Contains(users, name) { mentioned = name break } } suggested := make([]string, 0) for _, mode := range definition.DefinedModes() { exported, exportedErr := engine.HasCallablePath(context.Background(), "protocols", protocol, mode.Hook) if exportedErr != nil { return nil, exportedErr } if exported && !slices.Contains(suggested, mode.Name) { suggested = append(suggested, mode.Name) } } message := fmt.Sprintf("plugin %s protocol %q supports %q but does not export protocols.%s.%s; implement it", meta.Key, protocol, mentioned, protocol, hook) if len(suggested) > 0 { message += fmt.Sprintf(" or declare supports: [%s]", quotedJoin(suggested, ", ")) } return nil, errors.New(message) } return nil, fmt.Errorf("plugin %s protocol %q is missing hook %q", meta.Key, protocol, hook) } } seenModeHook := make(map[string]struct{}) for _, operation := range definition.Operations { for _, mode := range operation.Modes { if _, seen := seenModeHook[mode.Hook]; seen { continue } seenModeHook[mode.Hook] = struct{}{} if _, need := required[mode.Hook]; need { continue } has, hasErr := engine.HasCallablePath(context.Background(), "protocols", protocol, mode.Hook) if hasErr != nil { return nil, hasErr } if has { return nil, fmt.Errorf("plugin %s protocol %q exports protocols.%s.%s but no supported mode uses it; add %s to supports or remove the hook", meta.Key, protocol, protocol, mode.Hook, quotedJoin(modeHookUsers[mode.Hook], " or ")) } } } protocolValue, exportErr := engine.Export(context.Background(), "protocols") if exportErr != nil { return nil, exportErr } protocolObject, ok := protocolValue.(map[string]any) if !ok { return nil, fmt.Errorf("plugin %s export protocols must be an object", meta.Key) } implementation, ok := protocolObject[protocol].(map[string]any) if !ok { return nil, fmt.Errorf("plugin %s protocol %q must be an object", meta.Key, protocol) } for member := range implementation { if _, accepted := allowed[member]; !accepted { return nil, fmt.Errorf("plugin %s protocol %q has unsupported member %q", meta.Key, protocol, member) } } } if protocolsValue, exportErr := engine.Export(context.Background(), "protocols"); exportErr == nil { if protocolsObject, ok := protocolsValue.(map[string]any); ok { claimed := make(map[string]struct{}, len(meta.Protocols)) for _, claim := range meta.Protocols { claimed[claim.Name] = struct{}{} } for name := range protocolsObject { if _, ok := claimed[name]; !ok { return nil, fmt.Errorf("plugin %s implements unclaimed protocol %q", meta.Key, name) } } } } for _, removed := range []string{"resolveRequest", "renderError", "renderers"} { has, e := engine.HasExport(context.Background(), removed) if e != nil { return nil, e } if has { return nil, fmt.Errorf("plugin %s export %q is no longer supported", meta.Key, removed) } } return &LoadedPlugin{Meta: meta, Engine: engine}, nil } func (r *Registry) Get(platform string) (*LoadedPlugin, bool) { return r.Generation().Get(platform) } func (r *Registry) GetByChannelType(channelType int) (*LoadedPlugin, bool) { return r.Generation().GetByChannelType(channelType) } // Enabled reports the master switch position. When false the published // routing generation contains no plugins regardless of the other layers. func (r *Registry) Enabled() bool { return r.masterEnabled.Load() } func (r *Registry) SetEnabled(enabled bool) { r.mu.Lock() defer r.mu.Unlock() if r.masterEnabled.Load() == enabled { return } previous := r.masterEnabled.Load() r.masterEnabled.Store(enabled) overrideEnabled := r.overrideEnabled.Load() var retainCurrent map[string]struct{} if enabled && overrideEnabled { retainCurrent = pluginMapKeys(r.override) } generation, routingErrors, err := r.prepareGeneration(filterDisabledFactory(r.factory, r.disabledFactory), r.override, overrideEnabled, true, retainCurrent) if err != nil { r.masterEnabled.Store(previous) r.recordRebuildFailure(err) return } r.publishGeneration(generation, routingErrors, r.resolveActiveOverrides(generation, r.override, overrideEnabled)) } func (r *Registry) SetOverrideEnabled(enabled bool) { r.mu.Lock() defer r.mu.Unlock() if r.overrideEnabled.Load() == enabled { return } var retainCurrent map[string]struct{} if enabled { retainCurrent = pluginMapKeys(r.override) } generation, routingErrors, err := r.prepareGeneration(filterDisabledFactory(r.factory, r.disabledFactory), r.override, enabled, true, retainCurrent) if err != nil { r.recordRebuildFailure(err) return } r.overrideEnabled.Store(enabled) r.publishGeneration(generation, routingErrors, r.resolveActiveOverrides(generation, r.override, enabled)) } func (r *Registry) SetDisabledFactoryKeys(keys []string) { r.mu.Lock() defer r.mu.Unlock() next := make(map[string]struct{}, len(keys)) for _, key := range keys { key = strings.TrimSpace(key) if key == "" { continue } next[key] = struct{}{} } if len(next) == len(r.disabledFactory) { same := true for key := range next { if _, ok := r.disabledFactory[key]; !ok { same = false break } } if same { return } } enabled := r.overrideEnabled.Load() var retainCurrent map[string]struct{} if enabled { retainCurrent = pluginMapKeys(r.override) } generation, routingErrors, err := r.prepareGeneration(filterDisabledFactory(r.factory, next), r.override, enabled, true, retainCurrent) if err != nil { r.recordRebuildFailure(err) return } r.disabledFactory = next r.publishGeneration(generation, routingErrors, r.resolveActiveOverrides(generation, r.override, enabled)) } func (r *Registry) Unregister(key string) error { r.mu.Lock() defer r.mu.Unlock() if _, exists := r.override[key]; !exists { return nil } overridePlugins := clonePluginMap(r.override) delete(overridePlugins, key) enabled := r.overrideEnabled.Load() var retainCurrent map[string]struct{} if enabled { retainCurrent = pluginMapKeys(overridePlugins) } generation, routingErrors, err := r.prepareGeneration(filterDisabledFactory(r.factory, r.disabledFactory), overridePlugins, enabled, true, retainCurrent) if err != nil { r.recordRebuildFailure(err) return err } r.override = overridePlugins r.publishGeneration(generation, routingErrors, r.resolveActiveOverrides(generation, overridePlugins, enabled)) return nil } // ReplaceOverrides atomically publishes a complete override layer. func (r *Registry) ReplaceOverrides(plugins []*LoadedPlugin) error { overridePlugins := make(map[string]*LoadedPlugin, len(plugins)) for _, plugin := range plugins { if plugin == nil { return fmt.Errorf("cannot publish a nil plugin") } if _, exists := overridePlugins[plugin.Meta.Key]; exists { return fmt.Errorf("duplicate override plugin key %q", plugin.Meta.Key) } overridePlugins[plugin.Meta.Key] = plugin } r.mu.Lock() defer r.mu.Unlock() if samePluginMap(r.override, overridePlugins) { return nil } enabled := r.overrideEnabled.Load() var retainCurrent map[string]struct{} if enabled { retainCurrent = pluginMapKeys(overridePlugins) } generation, routingErrors, err := r.prepareGeneration(filterDisabledFactory(r.factory, r.disabledFactory), overridePlugins, enabled, true, retainCurrent) if err != nil { r.recordRebuildFailure(err) return err } r.override = overridePlugins r.publishGeneration(generation, routingErrors, r.resolveActiveOverrides(generation, overridePlugins, enabled)) return nil } func (r *Registry) Generation() *RoutingGeneration { return r.generation.Load() } func (r *Registry) OverridePlugins() map[string]*LoadedPlugin { r.mu.RLock() defer r.mu.RUnlock() return clonePluginMap(r.override) } func (r *Registry) ActiveOverridePlugins() map[string]*LoadedPlugin { r.mu.RLock() defer r.mu.RUnlock() return clonePluginMap(r.activeOverride) } func (r *Registry) SetGenerationPreparer(preparer RoutingGenerationPreparer) error { r.mu.Lock() defer r.mu.Unlock() previous := r.preparer r.preparer = preparer enabled := r.overrideEnabled.Load() var retainCurrent map[string]struct{} if enabled { retainCurrent = pluginMapKeys(r.override) } generation, routingErrors, err := r.prepareGeneration(filterDisabledFactory(r.factory, r.disabledFactory), r.override, enabled, true, retainCurrent) if err != nil { r.preparer = previous r.recordRebuildFailure(err) return err } r.publishGeneration(generation, routingErrors, r.resolveActiveOverrides(generation, r.override, enabled)) return nil } func (r *Registry) RoutingErrors() map[string]string { r.mu.RLock() defer r.mu.RUnlock() errorsCopy := make(map[string]string, len(r.routingErrors)) maps.Copy(errorsCopy, r.routingErrors) return errorsCopy } func (r *Registry) LastRebuildError() string { r.mu.RLock() defer r.mu.RUnlock() return r.lastRebuildErr } func (r *Registry) LastRebuildOutcome() RoutingRebuildOutcome { r.mu.RLock() defer r.mu.RUnlock() return r.lastRebuild } func (r *Registry) RoutingStatus() RoutingStatus { r.mu.RLock() defer r.mu.RUnlock() errorsCopy := make(map[string]string, len(r.routingErrors)) maps.Copy(errorsCopy, r.routingErrors) return RoutingStatus{ Generation: r.generation.Load(), LastRebuild: r.lastRebuild, Errors: errorsCopy, } } func (r *Registry) prepareGeneration( factory, override map[string]*LoadedPlugin, enabled, tolerateConflicts bool, retainCurrent map[string]struct{}, ) (*RoutingGeneration, map[string]string, error) { if !r.masterEnabled.Load() { factory = map[string]*LoadedPlugin{} override = map[string]*LoadedPlugin{} } current := r.generation.Load() number := uint64(1) if current != nil { number = current.Number + 1 } var ( generation *RoutingGeneration routingErrors map[string]string err error ) if tolerateConflicts { generation, routingErrors, err = buildRoutingGenerationAdmitting(factory, override, enabled, number, current, retainCurrent) } else { generation, err = buildRoutingGeneration(factory, override, enabled, number) routingErrors = make(map[string]string) } if err != nil { return nil, nil, err } if !tolerateConflicts { // Both runtime switch positions must remain publishable so toggling the // override layer never exposes an invalid generation. if _, err = buildRoutingGeneration(factory, override, !enabled, number); err != nil { return nil, nil, err } } if r.preparer != nil { prepared, prepareErr := r.preparer(generation, current) if prepareErr != nil { return nil, nil, prepareErr } if prepared.Generation == nil { return nil, nil, fmt.Errorf("routing generation preparer returned a nil generation") } if prepared.Generation.Number != generation.Number { return nil, nil, fmt.Errorf("routing generation preparer changed generation number from %d to %d", generation.Number, prepared.Generation.Number) } generation = prepared.Generation maps.Copy(routingErrors, prepared.Errors) } return generation, routingErrors, nil } func (r *Registry) publishGeneration( generation *RoutingGeneration, routingErrors map[string]string, activeOverride map[string]*LoadedPlugin, ) { previous := r.generation.Load() var previousNumber uint64 if previous != nil { previousNumber = previous.Number } r.routingErrors = routingErrors r.activeOverride = activeOverride r.lastRebuildErr = "" status := "success" if len(routingErrors) > 0 { status = "partial" } r.lastRebuild = RoutingRebuildOutcome{ Status: status, AttemptedAt: time.Now(), Generation: generation.Number, } r.generation.Store(generation) logger.LogDebug( context.Background(), "task_plugin subsystem=registry event=publish previous_generation=%d generation=%d status=%q plugins=%d routes=%d endpoint_bindings=%d channel_types=%d active_overrides=%d rejected=%d", previousNumber, generation.Number, status, len(generation.plugins), len(generation.routes), len(generation.protocolIndex), len(generation.byChannelType), len(activeOverride), len(routingErrors), ) if len(routingErrors) > 0 { keys := make([]string, 0, len(routingErrors)) for key := range routingErrors { keys = append(keys, key) } sort.Strings(keys) for _, key := range keys { logger.LogDebug( context.Background(), "task_plugin subsystem=registry event=plugin_rejected generation=%d plugin=%q reason=generation_admission_failed admission_reason=%q", generation.Number, key, taskPluginRoutingDebugReason(routingErrors[key]), ) } } } func (r *Registry) recordRebuildFailure(err error) { r.lastRebuildErr = err.Error() generation := r.generation.Load() var generationNumber uint64 if generation != nil { generationNumber = generation.Number } r.lastRebuild = RoutingRebuildOutcome{ Status: "failed", AttemptedAt: time.Now(), Generation: generationNumber, Error: err.Error(), } logger.LogDebug( context.Background(), "task_plugin subsystem=registry event=publish_failed retained_generation=%d retained_generation_active=true reason=%q", generationNumber, taskPluginRoutingDebugReason(err.Error()), ) } func taskPluginRoutingDebugReason(message string) string { lower := strings.ToLower(message) switch { case strings.Contains(lower, "channeltype"), strings.Contains(lower, "channel type"): return "channel_type_conflict" case strings.Contains(lower, "endpoint"): return "endpoint_conflict" case strings.Contains(lower, "inner gin"), strings.Contains(lower, "rebuilding public routes"): return "inner_router_build_failed" case strings.Contains(lower, "trusted prox"): return "trusted_proxy_configuration_failed" case strings.Contains(lower, "route"): return "route_conflict" case strings.Contains(lower, "nil generation"), strings.Contains(lower, "generation number"): return "invalid_prepared_generation" default: return "generation_rebuild_failed" } } func (r *Registry) Snapshot() RegistrySnapshot { r.mu.RLock() defer r.mu.RUnlock() snapshot := RegistrySnapshot{ Factory: make([]Meta, 0, len(r.factory)), Override: make([]Meta, 0, len(r.override)), DisabledFactory: make([]string, 0, len(r.disabledFactory)), } for _, plugin := range r.factory { snapshot.Factory = append(snapshot.Factory, cloneMeta(plugin.Meta)) } for _, plugin := range r.override { snapshot.Override = append(snapshot.Override, cloneMeta(plugin.Meta)) } for key := range r.disabledFactory { snapshot.DisabledFactory = append(snapshot.DisabledFactory, key) } sort.Slice(snapshot.Factory, func(i, j int) bool { return snapshot.Factory[i].Key < snapshot.Factory[j].Key }) sort.Slice(snapshot.Override, func(i, j int) bool { return snapshot.Override[i].Key < snapshot.Override[j].Key }) sort.Strings(snapshot.DisabledFactory) return snapshot } func cloneMeta(meta Meta) Meta { meta.ChannelTypes = append([]int(nil), meta.ChannelTypes...) meta.Models = append([]string(nil), meta.Models...) meta.AllowedHosts = append([]string(nil), meta.AllowedHosts...) meta.Routes = append([]Route(nil), meta.Routes...) for index := range meta.Routes { meta.Routes[index].Models = append([]string(nil), meta.Routes[index].Models...) } meta.Protocols = append([]ProtocolClaim(nil), meta.Protocols...) for index := range meta.Protocols { meta.Protocols[index].Models = append([]string(nil), meta.Protocols[index].Models...) meta.Protocols[index].Supports = append([]string(nil), meta.Protocols[index].Supports...) } if meta.Description != nil { meta.Description = maps.Clone(meta.Description) } if meta.UsageSchema != nil { usageSchema := make(map[string]UsageFieldSchema, len(meta.UsageSchema)) for key, field := range meta.UsageSchema { if field.Enum != nil { field.Enum = append([]string{}, field.Enum...) } if field.Description != nil { field.Description = maps.Clone(field.Description) } usageSchema[key] = field } meta.UsageSchema = usageSchema } meta.UsageExamples = cloneUsageExamples(meta.UsageExamples) return meta } func cloneUsageExamples(examples []UsageExample) []UsageExample { if examples == nil { return nil } cloned := make([]UsageExample, len(examples)) for index, example := range examples { cloned[index] = UsageExample{Label: example.Label} if example.Facts == nil { continue } facts := make(map[string]any, len(example.Facts)) maps.Copy(facts, example.Facts) cloned[index].Facts = facts } return cloned } func filterDisabledFactory(factory map[string]*LoadedPlugin, disabled map[string]struct{}) map[string]*LoadedPlugin { if len(disabled) == 0 { return factory } filtered := make(map[string]*LoadedPlugin, len(factory)) for key, plugin := range factory { if _, skip := disabled[key]; skip { continue } filtered[key] = plugin } return filtered } func clonePluginMap(source map[string]*LoadedPlugin) map[string]*LoadedPlugin { clone := make(map[string]*LoadedPlugin, len(source)) maps.Copy(clone, source) return clone } func samePluginMap(left, right map[string]*LoadedPlugin) bool { if len(left) != len(right) { return false } for key, plugin := range left { if right[key] != plugin { return false } } return true } func pluginMapKeys(plugins map[string]*LoadedPlugin) map[string]struct{} { keys := make(map[string]struct{}, len(plugins)) for key := range plugins { keys[key] = struct{}{} } return keys } func (r *Registry) resolveActiveOverrides( generation *RoutingGeneration, override map[string]*LoadedPlugin, enabled bool, ) map[string]*LoadedPlugin { active := make(map[string]*LoadedPlugin) if !enabled { return active } for _, plugin := range generation.plugins { desired, hasOverride := override[plugin.Meta.Key] if !hasOverride { continue } if plugin == desired || plugin == r.activeOverride[plugin.Meta.Key] { active[plugin.Meta.Key] = plugin } } return active } func decodeMeta(value any) (Meta, error) { object, ok := value.(map[string]any) if !ok { return Meta{}, fmt.Errorf("plugin meta must be an object") } for field := range object { switch field { case "apiVersion", "key", "name", "icon", "description", "version", "author", "channelTypes", "channelType", "compatibleChannelTypes", "models", "fetchMode", "allowedHosts", "routes", "protocols", "usageSchema", "usageExamples", "auth", "endpoints", "submitPaths", "actions": default: return Meta{}, fmt.Errorf("plugin meta has unknown field %q", field) } } meta := Meta{} var err error meta.APIVersion, err = integerMetaField(object, "apiVersion") if err != nil { return Meta{}, err } if meta.Key, err = stringMetaField(object, "key"); err != nil { return Meta{}, err } if meta.Name, err = stringMetaField(object, "name"); err != nil { return Meta{}, err } if meta.Icon, err = stringMetaField(object, "icon"); err != nil { return Meta{}, err } meta.Icon = strings.TrimSpace(meta.Icon) if meta.Description, err = localizedTextMetaField(object, "description", maxMetaDescriptionRunes); err != nil { return Meta{}, err } if meta.Version, err = stringMetaField(object, "version"); err != nil { return Meta{}, err } author, ok := object["author"].(map[string]any) if !ok { return Meta{}, fmt.Errorf("plugin meta author must be an object") } for field := range author { if field != "name" && field != "url" { return Meta{}, fmt.Errorf("plugin meta author has unknown field %q", field) } } if meta.Author.Name, err = stringMetaField(author, "name"); err != nil { return Meta{}, err } if rawURL, exists := author["url"]; exists { meta.Author.URL, ok = rawURL.(string) if !ok { return Meta{}, fmt.Errorf("plugin meta author field %q must be a string", "url") } } if _, exists := object["channelType"]; exists { return Meta{}, fmt.Errorf("plugin meta channelType is no longer supported; declare channelTypes instead") } if _, exists := object["compatibleChannelTypes"]; exists { return Meta{}, fmt.Errorf("plugin meta compatibleChannelTypes is no longer supported; declare channelTypes instead") } meta.ChannelTypes, err = integerSliceMetaField(object, "channelTypes") if err != nil { return Meta{}, err } if meta.FetchMode, err = stringMetaField(object, "fetchMode"); err != nil { return Meta{}, err } meta.Models, err = strictStringSlice(object, "models") if err != nil { return Meta{}, err } meta.AllowedHosts, err = strictStringSlice(object, "allowedHosts") if err != nil { return Meta{}, err } meta.Routes, err = decodeRoutes(object["routes"]) if err != nil { return Meta{}, err } if _, exists := object["endpoints"]; exists { return Meta{}, fmt.Errorf("plugin meta endpoints is no longer supported; declare protocols by name") } meta.Protocols, err = decodeProtocolClaims(object, "protocols") if err != nil { return Meta{}, err } if usageSchema, exists := object["usageSchema"]; exists { meta.UsageSchema, err = decodeUsageSchema(usageSchema) if err != nil { return Meta{}, err } } if usageExamples, exists := object["usageExamples"]; exists { meta.UsageExamples, err = decodeUsageExamples(usageExamples) if err != nil { return Meta{}, err } } for _, removedField := range []string{"submitPaths", "actions"} { if _, exists := object[removedField]; exists { return Meta{}, fmt.Errorf("plugin meta %s is no longer supported; declare routes instead", removedField) } } switch auth := object["auth"].(type) { case nil: case string: meta.Auth.Type = auth case map[string]any: for key := range auth { if key != "type" { return Meta{}, fmt.Errorf("plugin meta auth has unknown field %q", key) } } meta.Auth.Type, err = stringMetaField(auth, "type") if err != nil { return Meta{}, err } default: return Meta{}, fmt.Errorf("plugin meta auth must be a string or object") } meta.Auth.Type = strings.TrimSpace(meta.Auth.Type) if meta.Auth.Type == "vertex_oauth" { meta.Auth.Type = "oauth2_jwt" } if meta.Auth.Type != "" && meta.Auth.Type != "none" && meta.Auth.Type != "api_key" && meta.Auth.Type != "oauth2_jwt" { return Meta{}, fmt.Errorf("unsupported plugin auth type %q", meta.Auth.Type) } if meta.APIVersion != APIVersion1 { return Meta{}, fmt.Errorf("unsupported plugin apiVersion %d", meta.APIVersion) } if strings.TrimSpace(meta.Key) == "" || strings.TrimSpace(meta.Name) == "" || strings.TrimSpace(meta.Version) == "" { return Meta{}, fmt.Errorf("plugin meta key, name, and version are required") } if len(meta.Key) > 30 { return Meta{}, fmt.Errorf("plugin meta key must not exceed 30 characters") } return meta, nil } // ValidateV1Meta applies the metadata constraints published in // docs/plugin-api/v1.schema.json to administrator uploads. func ValidateV1Meta(meta Meta) error { meta = cloneMeta(meta) return normalizeV1Meta(&meta) } func normalizeV1Meta(meta *Meta) error { if meta.APIVersion != APIVersion1 { return fmt.Errorf("unsupported plugin apiVersion %d", meta.APIVersion) } if strings.TrimSpace(meta.Name) == "" { return fmt.Errorf("plugin meta name is required") } meta.Icon = strings.TrimSpace(meta.Icon) if meta.Icon != "" { if utf8.RuneCountInString(meta.Icon) > 128 { return fmt.Errorf("plugin meta icon must not exceed 128 characters") } for _, character := range meta.Icon { if unicode.IsControl(character) { return fmt.Errorf("plugin meta icon must not contain control characters") } } } if err := validateLocalizedText(meta.Description, "description", maxMetaDescriptionRunes); err != nil { return err } meta.Author.Name = strings.TrimSpace(meta.Author.Name) if meta.Author.Name == "" { return fmt.Errorf("plugin meta author name is required") } meta.Author.URL = strings.TrimSpace(meta.Author.URL) if meta.Author.URL != "" { parsedURL, err := url.Parse(meta.Author.URL) if err != nil || parsedURL.Host == "" || (parsedURL.Scheme != "http" && parsedURL.Scheme != "https") { return fmt.Errorf("plugin meta author url must be an absolute HTTP(S) URL") } } if !pluginKeyPattern.MatchString(meta.Key) { return fmt.Errorf("plugin meta key must match %s", pluginKeyPattern) } if len(meta.Key) > 30 { return fmt.Errorf("plugin meta key must not exceed 30 characters") } if !pluginVersionPattern.MatchString(meta.Version) { return fmt.Errorf("plugin meta version must be semver") } if meta.FetchMode != "per_task" && meta.FetchMode != "batch" { return fmt.Errorf("plugin meta fetchMode must be per_task or batch") } if len(meta.Models) == 0 { return fmt.Errorf("plugin meta models must contain at least one model") } seenChannelTypes := make(map[int]struct{}, len(meta.ChannelTypes)) for _, channelType := range meta.ChannelTypes { if channelType <= 0 { return fmt.Errorf("plugin meta channelTypes must contain positive channel types") } if channelType == constant.ChannelTypeTaskPlugin { return fmt.Errorf("plugin meta channelTypes must not contain the task plugin channel type") } if _, duplicate := seenChannelTypes[channelType]; duplicate { return fmt.Errorf("plugin meta channelTypes must be unique") } seenChannelTypes[channelType] = struct{}{} } models := make(map[string]struct{}, len(meta.Models)) seenFold := make(map[string]struct{}, len(meta.Models)) for _, model := range meta.Models { if strings.TrimSpace(model) == "" || strings.TrimSpace(model) != model { return fmt.Errorf("plugin meta models must contain non-empty canonical names") } folded := asciiFold(model) if _, exists := seenFold[folded]; exists { return fmt.Errorf("plugin meta models must be unique case-insensitively") } seenFold[folded] = struct{}{} models[model] = struct{}{} } hosts := make(map[string]struct{}, len(meta.AllowedHosts)) for _, host := range meta.AllowedHosts { if strings.TrimSpace(host) == "" || strings.ContainsAny(host, "/:?#") { return fmt.Errorf("plugin meta allowedHosts must contain hostnames without schemes, ports, or paths") } if _, exists := hosts[host]; exists { return fmt.Errorf("plugin meta allowedHosts must be unique") } hosts[host] = struct{}{} } routeKeys := make(map[string]struct{}, len(meta.Routes)) for index := range meta.Routes { if err := validateRoute(&meta.Routes[index]); err != nil { return err } for _, model := range meta.Routes[index].Models { if _, exists := models[model]; !exists { return fmt.Errorf("plugin route %s %s model %q is not declared in plugin meta models", meta.Routes[index].Method, meta.Routes[index].Path, model) } } shape, err := routePathShape(meta.Routes[index].Path) if err != nil { return err } key := meta.Routes[index].Method + " " + shape if _, exists := routeKeys[key]; exists { return fmt.Errorf("plugin meta routes contain duplicate route %s %s", meta.Routes[index].Method, meta.Routes[index].Path) } routeKeys[key] = struct{}{} } protocols := make(map[string]struct{}, len(meta.Protocols)) for index := range meta.Protocols { claim := &meta.Protocols[index] definition, known := HostProtocol(claim.Name) modes := definition.DefinedModes() if len(modes) > 0 { modeNames := make([]string, len(modes)) for modeIndex, mode := range modes { modeNames[modeIndex] = mode.Name } choosingFrom := quotedJoin(modeNames, ", ") if claim.Supports == nil { if claim.objectForm { return fmt.Errorf("plugin %s protocol %q must declare supports; add supports: [...] choosing from %s", meta.Key, claim.Name, choosingFrom) } return fmt.Errorf("plugin %s protocol %q must declare supports; replace the bare string with {name: %q, supports: [...]} choosing from %s", meta.Key, claim.Name, claim.Name, choosingFrom) } if len(claim.Supports) == 0 { return fmt.Errorf("plugin %s protocol %q supports must contain at least one of %s", meta.Key, claim.Name, choosingFrom) } seenSupports := make(map[string]struct{}, len(claim.Supports)) for _, support := range claim.Supports { if _, duplicate := seenSupports[support]; duplicate { return fmt.Errorf("plugin %s protocol %q supports must be unique", meta.Key, claim.Name) } seenSupports[support] = struct{}{} if !slices.Contains(modeNames, support) { if support == "retrieve" { return fmt.Errorf("plugin %s protocol %q has no mode %q; retrieval of a created response is always available and is never declared", meta.Key, claim.Name, support) } return fmt.Errorf("plugin %s protocol %q has no mode %q", meta.Key, claim.Name, support) } } claim.Supports = orderProtocolSupports(claim.Name, claim.Supports) } else if claim.Supports != nil { return fmt.Errorf("plugin %s protocol %q does not define modes; supports is not allowed", meta.Key, claim.Name) } if !known { return fmt.Errorf("plugin meta protocol %q is unknown", claim.Name) } if _, duplicate := protocols[claim.Name]; duplicate { return fmt.Errorf("plugin meta protocols must be unique") } protocols[claim.Name] = struct{}{} if err := validateModelScope(claim.Models, fmt.Sprintf("protocol %q", claim.Name)); err != nil { return err } for _, model := range claim.Models { if _, exists := models[model]; !exists { return fmt.Errorf("plugin protocol %q model %q is not declared in plugin meta models", claim.Name, model) } } } for name, field := range meta.UsageSchema { if strings.TrimSpace(name) == "" || strings.TrimSpace(name) != name { return fmt.Errorf("plugin meta usageSchema keys must be non-empty canonical names") } if err := validateUsageFieldSchema(name, field); err != nil { return err } } if err := validateUsageExamples(meta.UsageSchema, meta.UsageExamples); err != nil { return err } return nil } func decodeUsageSchema(value any) (map[string]UsageFieldSchema, error) { if value == nil { return nil, fmt.Errorf("plugin meta usageSchema must be an object") } object, ok := value.(map[string]any) if !ok { return nil, fmt.Errorf("plugin meta usageSchema must be an object") } schema := make(map[string]UsageFieldSchema, len(object)) for name, rawField := range object { fieldObject, ok := rawField.(map[string]any) if !ok { return nil, fmt.Errorf("plugin meta usageSchema field %q must be an object", name) } for key := range fieldObject { switch key { case "type", "unit", "enum", "description": default: return nil, fmt.Errorf("plugin meta usageSchema field %q has unknown property %q", name, key) } } field := UsageFieldSchema{} var err error if field.Type, err = stringMetaField(fieldObject, "type"); err != nil { return nil, err } if field.Unit, err = stringMetaField(fieldObject, "unit"); err != nil { return nil, err } if field.Description, err = localizedTextMetaField(fieldObject, "description", maxUsageFieldDescriptionRunes); err != nil { return nil, err } if _, exists := fieldObject["enum"]; exists { if field.Enum, err = strictStringSlice(fieldObject, "enum"); err != nil { return nil, err } } if err = validateUsageFieldSchema(name, field); err != nil { return nil, err } schema[name] = field } return schema, nil } func validateUsageFieldSchema(name string, field UsageFieldSchema) error { if err := validateLocalizedText(field.Description, fmt.Sprintf("usageSchema field %q description", name), maxUsageFieldDescriptionRunes); err != nil { return err } if field.Enum != nil { if field.Type != "" || field.Unit != "" { return fmt.Errorf("plugin meta usageSchema field %q cannot combine enum with type or unit", name) } if len(field.Enum) == 0 { return fmt.Errorf("plugin meta usageSchema field %q enum must contain at least one value", name) } values := make(map[string]struct{}, len(field.Enum)) for _, value := range field.Enum { if _, exists := values[value]; exists { return fmt.Errorf("plugin meta usageSchema field %q enum values must be unique", name) } values[value] = struct{}{} } return nil } if field.Type == "boolean" { if field.Unit != "" { return fmt.Errorf("plugin meta usageSchema field %q cannot combine boolean with unit", name) } return nil } if field.Type != "number" { return fmt.Errorf("plugin meta usageSchema field %q type must be number or boolean", name) } if field.Unit != "second" && field.Unit != "count" && field.Unit != "token" && field.Unit != "credit" { return fmt.Errorf("plugin meta usageSchema field %q unit must be second, count, token, or credit", name) } return nil } const maxUsageExamples = 16 const maxUsageExampleLabelRunes = 48 func decodeUsageExamples(value any) ([]UsageExample, error) { if value == nil { return nil, fmt.Errorf("plugin meta usageExamples must be an array") } items, ok := value.([]any) if !ok { return nil, fmt.Errorf("plugin meta usageExamples must be an array") } if len(items) > maxUsageExamples { return nil, fmt.Errorf("plugin meta usageExamples must not exceed %d entries", maxUsageExamples) } examples := make([]UsageExample, 0, len(items)) for index, item := range items { object, ok := item.(map[string]any) if !ok { return nil, fmt.Errorf("plugin meta usageExamples[%d] must be an object", index) } for key := range object { if key != "label" && key != "facts" { return nil, fmt.Errorf("plugin meta usageExamples[%d] has unknown field %q", index, key) } } label, err := stringMetaField(object, "label") if err != nil { return nil, fmt.Errorf("plugin meta usageExamples[%d] %w", index, err) } rawFacts, exists := object["facts"] if !exists || rawFacts == nil { return nil, fmt.Errorf("plugin meta usageExamples[%d] facts must be an object", index) } facts, ok := rawFacts.(map[string]any) if !ok { return nil, fmt.Errorf("plugin meta usageExamples[%d] facts must be an object", index) } examples = append(examples, UsageExample{Label: label, Facts: facts}) } return examples, nil } func usageSchemaHasTokenUnit(schema map[string]UsageFieldSchema) bool { for _, field := range schema { if field.Type == "number" && field.Unit == "token" { return true } } return false } func validateUsageExamples(schema map[string]UsageFieldSchema, examples []UsageExample) error { if len(examples) == 0 { if usageSchemaHasTokenUnit(schema) { return fmt.Errorf("plugin meta usageExamples is required when usageSchema declares a token unit") } return nil } if len(schema) == 0 { return fmt.Errorf("plugin meta usageExamples requires usageSchema") } if len(examples) > maxUsageExamples { return fmt.Errorf("plugin meta usageExamples must not exceed %d entries", maxUsageExamples) } for index := range examples { label := strings.TrimSpace(examples[index].Label) if label == "" { return fmt.Errorf("plugin meta usageExamples[%d] label is required", index) } if utf8.RuneCountInString(label) > maxUsageExampleLabelRunes { return fmt.Errorf("plugin meta usageExamples[%d] label must not exceed %d characters", index, maxUsageExampleLabelRunes) } examples[index].Label = label if examples[index].Facts == nil { return fmt.Errorf("plugin meta usageExamples[%d] facts must be an object", index) } for key := range schema { if _, exists := examples[index].Facts[key]; !exists { return fmt.Errorf("plugin meta usageExamples[%d] facts missing key %q", index, key) } } for key, value := range examples[index].Facts { field, declared := schema[key] if !declared { return fmt.Errorf("plugin meta usageExamples[%d] facts has undeclared key %q", index, key) } if err := validateUsageExampleValue(value, field); err != nil { return fmt.Errorf("plugin meta usageExamples[%d] facts field %q %s", index, key, err.Error()) } } } return nil } func validateUsageExampleValue(value any, field UsageFieldSchema) error { if len(field.Enum) > 0 { text, ok := value.(string) if !ok { return fmt.Errorf("enum is not an allowed value") } if slices.Contains(field.Enum, text) { return nil } return fmt.Errorf("enum is not an allowed value") } if field.Type == "boolean" { if _, ok := value.(bool); !ok { return fmt.Errorf("must be a boolean") } return nil } number, ok := usageExampleNumber(value) if !ok { return fmt.Errorf("must be a finite non-negative number") } if math.IsNaN(number) || math.IsInf(number, 0) || number < 0 { return fmt.Errorf("must be a finite non-negative number") } limit := float64(relaycommon.MaxTaskDurationSeconds) if field.Unit == "count" { limit = float64(dto.MaxImageN) } else if field.Unit == "token" || field.Unit == "credit" { limit = float64(common.MaxQuota) } if number > limit { return fmt.Errorf("exceeds the host limit") } return nil } func usageExampleNumber(value any) (float64, bool) { switch number := value.(type) { case float64: return number, true case int64: return float64(number), true case int: return float64(number), true default: return 0, false } } func decodeRoutes(value any) ([]Route, error) { if value == nil { return []Route{}, nil } items, ok := value.([]any) if !ok { return nil, fmt.Errorf("plugin meta routes must be an array") } routes := make([]Route, 0, len(items)) for index, item := range items { object, ok := item.(map[string]any) if !ok { return nil, fmt.Errorf("plugin meta route %d must be an object", index) } if _, exists := object["renderer"]; exists { return nil, fmt.Errorf("plugin meta route %d field renderer is no longer supported", index) } for key := range object { switch key { case "method", "path", "type", "action", "decode", "render", "taskIdParam", "models": default: return nil, fmt.Errorf("plugin meta route %d has unknown field %q", index, key) } } route := Route{} var err error if route.Method, err = stringMetaField(object, "method"); err != nil { return nil, err } if route.Path, err = stringMetaField(object, "path"); err != nil { return nil, err } routeType, err := stringMetaField(object, "type") if err != nil { return nil, err } route.Type = RouteType(routeType) if route.Action, err = stringMetaField(object, "action"); err != nil { return nil, err } if route.Decode, err = stringMetaField(object, "decode"); err != nil { return nil, err } if route.Render, err = stringMetaField(object, "render"); err != nil { return nil, err } if route.TaskIDParam, err = stringMetaField(object, "taskIdParam"); err != nil { return nil, err } if _, exists := object["models"]; exists { if route.Models, err = strictStringSlice(object, "models"); err != nil { return nil, err } if len(route.Models) == 0 { return nil, fmt.Errorf("plugin meta route %d models must contain at least one model", index) } } routes = append(routes, route) } return routes, nil } // decodeProtocolClaims accepts both protocol entry shapes: a bare protocol // name string (binds every meta.models entry) and an object {name, models, // supports}. An absent key is empty; a present null or non-array is rejected. // The supports key is decoded only when present so an absent key stays nil. func decodeProtocolClaims(object map[string]any, name string) ([]ProtocolClaim, error) { value, exists := object[name] if !exists { return []ProtocolClaim{}, nil } items, ok := value.([]any) if !ok { return nil, fmt.Errorf("plugin meta %s must be an array", name) } claims := make([]ProtocolClaim, 0, len(items)) for index, item := range items { switch entry := item.(type) { case string: claims = append(claims, ProtocolClaim{Name: entry}) case map[string]any: for key := range entry { switch key { case "name", "models", "supports": default: return nil, fmt.Errorf("plugin meta protocol %d has unknown field %q", index, key) } } claim := ProtocolClaim{objectForm: true} var err error if claim.Name, err = stringMetaField(entry, "name"); err != nil { return nil, err } if _, exists := entry["models"]; exists { if claim.Models, err = strictStringSlice(entry, "models"); err != nil { return nil, err } if len(claim.Models) == 0 { return nil, fmt.Errorf("plugin meta protocol %d models must contain at least one model", index) } } if _, exists := entry["supports"]; exists { if claim.Supports, err = strictStringSlice(entry, "supports"); err != nil { return nil, err } claim.Supports = orderProtocolSupports(claim.Name, claim.Supports) } claims = append(claims, claim) default: return nil, fmt.Errorf("plugin meta protocol %d must be a string or an object", index) } } return claims, nil } func integerMetaField(object map[string]any, name string) (int, error) { value, exists := object[name] if !exists { return 0, nil } switch number := value.(type) { case int64: converted := int(number) if int64(converted) != number { return 0, fmt.Errorf("plugin meta %s is outside the supported integer range", name) } return converted, nil case float64: if math.IsNaN(number) || math.IsInf(number, 0) || math.Trunc(number) != number { return 0, fmt.Errorf("plugin meta %s must be an integer", name) } converted := int(number) if float64(converted) != number { return 0, fmt.Errorf("plugin meta %s is outside the supported integer range", name) } return converted, nil default: return 0, fmt.Errorf("plugin meta %s must be an integer", name) } } func integerSliceMetaField(object map[string]any, name string) ([]int, error) { value, exists := object[name] if !exists { return nil, nil } items, ok := value.([]any) if !ok { return nil, fmt.Errorf("plugin meta %s must be an array of integers", name) } numbers := make([]int, 0, len(items)) for index, item := range items { element := map[string]any{name: item} number, err := integerMetaField(element, name) if err != nil { return nil, fmt.Errorf("plugin meta %s element %d must be an integer", name, index+1) } numbers = append(numbers, number) } return numbers, nil } func stringMetaField(object map[string]any, name string) (string, error) { value, exists := object[name] if !exists { return "", nil } text, ok := value.(string) if !ok { return "", fmt.Errorf("plugin meta %s must be a string", name) } return text, nil } func localizedTextMetaField(object map[string]any, name string, maxRunes int) (LocalizedText, error) { value, exists := object[name] if !exists { return nil, nil } var text LocalizedText switch typed := value.(type) { case string: text = LocalizedText{"en": typed} case map[string]any: text = make(LocalizedText, len(typed)) for locale, raw := range typed { item, ok := raw.(string) if !ok { return nil, fmt.Errorf("plugin meta %s locale %q must be a string", name, locale) } text[locale] = item } default: return nil, fmt.Errorf("plugin meta %s must be a string or object", name) } if err := validateLocalizedText(text, name, maxRunes); err != nil { return nil, err } return text, nil } func validateLocalizedText(text LocalizedText, name string, maxRunes int) error { if text == nil { return nil } if len(text) > maxLocalizedTextLocales { return fmt.Errorf("plugin meta %s must not exceed %d locales", name, maxLocalizedTextLocales) } canonical := make(map[string]string, len(text)) for locale, value := range text { if !localeTagPattern.MatchString(locale) { return fmt.Errorf("plugin meta %s has invalid locale %q", name, locale) } canonicalLocale := canonicalLocaleTag(locale) if _, duplicate := canonical[canonicalLocale]; duplicate { return fmt.Errorf("plugin meta %s has duplicate locale %q", name, canonicalLocale) } trimmed := strings.TrimSpace(value) if trimmed == "" { return fmt.Errorf("plugin meta %s value for %q must be a non-empty string", name, locale) } for _, character := range trimmed { if unicode.IsControl(character) { return fmt.Errorf("plugin meta %s value for %q must not contain control characters", name, locale) } } if utf8.RuneCountInString(trimmed) > maxRunes { return fmt.Errorf("plugin meta %s must not exceed %d characters", name, maxRunes) } canonical[canonicalLocale] = trimmed } if strings.TrimSpace(canonical["en"]) == "" { return fmt.Errorf("plugin meta %s must include a non-empty \"en\" value", name) } for locale := range text { delete(text, locale) } for locale, value := range canonical { text[locale] = value } return nil } // canonicalLocaleTag applies BCP-47 case conventions so lookups can use // exact matching: language lowercase, 2-letter region uppercase, 4-letter // script title case (zh-tw -> zh-TW, EN -> en, zh-hans -> zh-Hans). func canonicalLocaleTag(tag string) string { parts := strings.Split(tag, "-") parts[0] = strings.ToLower(parts[0]) for index := 1; index < len(parts); index++ { switch len(parts[index]) { case 2: parts[index] = strings.ToUpper(parts[index]) case 4: lowered := strings.ToLower(parts[index]) parts[index] = strings.ToUpper(lowered[:1]) + lowered[1:] default: parts[index] = strings.ToLower(parts[index]) } } return strings.Join(parts, "-") } func quotedJoin(items []string, sep string) string { parts := make([]string, len(items)) for index, item := range items { parts[index] = fmt.Sprintf("%q", item) } return strings.Join(parts, sep) } func strictStringSlice(object map[string]any, name string) ([]string, error) { value, exists := object[name] if !exists { return []string{}, nil } items, ok := value.([]any) if !ok { return nil, fmt.Errorf("plugin meta %s must be an array of strings", name) } result := make([]string, 0, len(items)) for _, item := range items { text, ok := item.(string) if !ok { return nil, fmt.Errorf("plugin meta %s must be an array of strings", name) } result = append(result, text) } return result, nil }