feat: add plugin system (#1034)

* feat: add plugin system

* fix db docker build

* fix hammerhead readme, add strava subscription news to docs

* fixes and sdk improvements

* fix: reduce Meilisearch load, debounce federation sync (#1012)

* optimize meili trail index

* several fixes

---------

Co-authored-by: Flomp <Flomp@users.noreply.github.com>

* Bump svelte from 5.55.5 to 5.56.0 in /docs (#1032)

Bumps [svelte](https://github.com/sveltejs/svelte/tree/HEAD/packages/svelte) from 5.55.5 to 5.56.0.
- [Release notes](https://github.com/sveltejs/svelte/releases)
- [Changelog](https://github.com/sveltejs/svelte/blob/main/packages/svelte/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/svelte/commits/svelte@5.56.0/packages/svelte)

---
updated-dependencies:
- dependency-name: svelte
  dependency-version: 5.56.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Flomp <Flomp@users.noreply.github.com>

* Release v0.19.2 (#1035)

* chore: release v0.19.2

* add changelog

---------

Co-authored-by: Flomp <26000991+Flomp@users.noreply.github.com>
Co-authored-by: Christian Beutel <>

* speed up plugin sync and several small fixes

* concepts for security improvements and process stability

* improve concept

* security concept implemented

* remove insecure TLS

* worker concept implemented

* fixes and cleanup

* fixes

* docu

* mermaid, namings

* WASM plugin host improvements, plugin logging

* fix db migration

* Improve plugin config and category mapping UI

* fixes

* further fixes

* remove manual test sync

* fix db migration and strava mapping

* type added, UI improvements

* fix plugin card toggle clickable area

* optimize synch status card layout

* plugin type 'trails' instead of 'integration'

* session auth validation in UI

* fix komoot date and waypoints

* improve category mapping

* fix send to hammerhead: trail name

* plugin setup error handling improved

* fix review findings

* re-mapping added

* rename remote_category

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Flomp <Flomp@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Flomp <26000991+Flomp@users.noreply.github.com>
This commit is contained in:
slothful-vassal
2026-06-22 15:00:44 +02:00
committed by GitHub
parent 2e3d8537b8
commit 485ec53f6d
196 changed files with 20947 additions and 5454 deletions

63
plugins/sdk/README.md Normal file
View File

@@ -0,0 +1,63 @@
# Wanderer Plugin SDK for Go
TinyGo-compatible helpers for Wanderer WASM plugins.
```go
import "github.com/open-wanderer/wanderer/plugins/sdk"
```
The SDK contains only plugin-side protocol types and host-function helpers. It
does not depend on Wanderer core or PocketBase.
Common protocol types:
- `ListInput`, `ListOutput`, `TrailImport`, `Track`, `Waypoint`, `Photo`
- `RefreshSessionInput`, `RefreshSessionOutput`
- `TrailSendInput`, `TrailSendPlan`
- `HostRequestSpec`, `HostResponse`, `PluginError`
Provider HTTP requests use connector targets. Plugins provide a connector name,
a relative path, and ordered query parameters; the host owns the final base URL,
path scope, redirects, TLS, and private-network policy. Public external media
URLs remain available only through `MediaSource{Type: "url"}`.
Host HTTP request bodies support JSON, `application/x-www-form-urlencoded`, and
multipart. Use `PostJSON` for JSON and `PostForm` for ordered form fields. Any
request body, including a login form POST, is governed by manifest
`permissions.uploads.maxBytes` and `permissions.uploads.contentTypes`; in this
contract "uploads" means plugin-to-provider request bodies, not only media/file
uploads.
Set `HostRequestSpec.FollowRedirects` to `sdk.Bool(false)` when a plugin needs
to inspect a redirect response itself, for example to collect `Location` and
`Set-Cookie` during a provider login flow. `HostResponse.HeaderValues` is the
only response-header representation and preserves all values. Prefer
`FirstHeader` for scalar headers and `HeaderValuesFor` for headers that can
appear more than once.
Plugins can emit host-visible logs with `LogDebug`, `LogInfo`, `LogWarn`, and
`LogError`. Log levels are strict (`debug`, `info`, `warn`, `error`) and
messages must be non-empty. Use logs for short diagnostics and timing markers;
they are best-effort and should not be part of plugin control flow.
Small sync helpers are included for the repeated mechanics that every provider
needs:
- `StringField` / `StringOption`
- `IntState`
- `IntOption`
- `KnownIDs`
- `SyncLimit`
- `NextPageState`
Additional TinyGo-compatible helper packages:
```go
import sdkgpx "github.com/open-wanderer/wanderer/plugins/sdk/gpx"
import "github.com/open-wanderer/wanderer/plugins/sdk/polyline"
```
- `gpx` writes simple GPX 1.1 track documents from provider track points.
- `polyline` decodes Google-style encoded polylines and provides small helpers
for coordinate scale normalization, coordinate swap detection, and mapping
shorter elevation arrays onto track points.

View File

@@ -0,0 +1,19 @@
package main
import (
"fmt"
"os"
"github.com/open-wanderer/wanderer/plugins/sdk/manifestcheck"
)
func main() {
path := "plugin.json"
if len(os.Args) > 1 {
path = os.Args[1]
}
if err := manifestcheck.PrintFile(os.Stdout, path); err != nil {
fmt.Fprintf(os.Stderr, "manifestcheck: %v\n", err)
os.Exit(1)
}
}

5
plugins/sdk/go.mod Normal file
View File

@@ -0,0 +1,5 @@
module github.com/open-wanderer/wanderer/plugins/sdk
go 1.25.0
require github.com/extism/go-pdk v1.1.3

2
plugins/sdk/go.sum Normal file
View File

@@ -0,0 +1,2 @@
github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ=
github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4=

58
plugins/sdk/gpx/gpx.go Normal file
View File

@@ -0,0 +1,58 @@
package gpx
import (
"bytes"
"encoding/xml"
"fmt"
"strconv"
"time"
)
type Point struct {
Lat float64
Lon float64
Elevation *float64
Time *time.Time
}
func Track(creator string, name string, points []Point) ([]byte, error) {
if len(points) == 0 {
return nil, fmt.Errorf("track has no points")
}
if creator == "" {
creator = "wanderer plugin"
}
var buf bytes.Buffer
buf.WriteString(xml.Header)
buf.WriteString(`<gpx version="1.1" creator="`)
_ = xml.EscapeText(&buf, []byte(creator))
buf.WriteString(`" xmlns="http://www.topografix.com/GPX/1/1">`)
buf.WriteString("<trk>")
buf.WriteString("<name>")
_ = xml.EscapeText(&buf, []byte(name))
buf.WriteString("</name>")
buf.WriteString("<trkseg>")
for _, point := range points {
buf.WriteString(`<trkpt lat="`)
buf.WriteString(strconv.FormatFloat(point.Lat, 'f', 8, 64))
buf.WriteString(`" lon="`)
buf.WriteString(strconv.FormatFloat(point.Lon, 'f', 8, 64))
buf.WriteString(`">`)
if point.Elevation != nil {
buf.WriteString("<ele>")
buf.WriteString(strconv.FormatFloat(*point.Elevation, 'f', 2, 64))
buf.WriteString("</ele>")
}
if point.Time != nil {
buf.WriteString("<time>")
buf.WriteString(point.Time.UTC().Format(time.RFC3339))
buf.WriteString("</time>")
}
buf.WriteString("</trkpt>")
}
buf.WriteString("</trkseg>")
buf.WriteString("</trk>")
buf.WriteString("</gpx>")
return buf.Bytes(), nil
}

View File

@@ -0,0 +1,39 @@
package gpx
import (
"strings"
"testing"
"time"
)
func TestTrackEscapesFields(t *testing.T) {
elevation := 123.456
timestamp := time.Date(2026, 6, 1, 10, 30, 0, 0, time.UTC)
data, err := Track("creator & test", "A & B", []Point{{
Lat: 46.1,
Lon: 8.2,
Elevation: &elevation,
Time: &timestamp,
}})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
gpx := string(data)
for _, want := range []string{
`creator="creator &amp; test"`,
"<name>A &amp; B</name>",
`lat="46.10000000" lon="8.20000000"`,
"<ele>123.46</ele>",
"<time>2026-06-01T10:30:00Z</time>",
} {
if !strings.Contains(gpx, want) {
t.Fatalf("expected %q in %s", want, gpx)
}
}
}
func TestTrackRejectsEmptyPoints(t *testing.T) {
if _, err := Track("", "empty", nil); err == nil {
t.Fatal("expected error")
}
}

154
plugins/sdk/host_http.go Normal file
View File

@@ -0,0 +1,154 @@
//go:build tinygo
package sdk
import (
"encoding/base64"
"encoding/json"
"fmt"
"strings"
"github.com/extism/go-pdk"
)
//go:wasmimport wanderer http_request
func wandererHTTPRequest(uint64) uint64
//go:wasmimport wanderer log
func wandererLog(uint64)
func Log(level LogLevel, message string) {
entry := HostLogEntry{
Level: level,
Message: message,
}
memory, err := pdk.AllocateJSON(entry)
if err != nil {
return
}
defer memory.Free()
wandererLog(memory.Offset())
}
func LogDebug(message string) {
Log(LogLevelDebug, message)
}
func LogInfo(message string) {
Log(LogLevelInfo, message)
}
func LogWarn(message string) {
Log(LogLevelWarn, message)
}
func LogError(message string) {
Log(LogLevelError, message)
}
func HostRequest(spec HostRequestSpec) (HostResponse, []byte, error) {
requestMemory, err := pdk.AllocateJSON(spec)
if err != nil {
return HostResponse{}, nil, err
}
defer requestMemory.Free()
responsePointer := wandererHTTPRequest(requestMemory.Offset())
if responsePointer == 0 {
return HostResponse{}, nil, fmt.Errorf("host http request returned no response")
}
responseMemory := pdk.FindMemory(responsePointer)
var response HostResponse
if err := json.Unmarshal(responseMemory.ReadBytes(), &response); err != nil {
return HostResponse{}, nil, err
}
if response.Error != nil {
return response, nil, fmt.Errorf("%s: %s", response.Error.Code, response.Error.Message)
}
body, err := base64.StdEncoding.DecodeString(response.BodyBase64)
if err != nil {
return response, nil, err
}
return response, body, nil
}
func ConnectorRequest(method string, connector string, path string, query []QueryParam, headers map[string]string, expect ResponseExpect) (HostResponse, []byte, error) {
return HostRequest(HostRequestSpec{
Method: method,
Target: RequestTarget{
Type: "connector",
Connector: connector,
Path: path,
Query: query,
},
Headers: headers,
Expect: expect,
})
}
func Get(connector string, path string, query []QueryParam, headers map[string]string, expect ResponseExpect) (HostResponse, []byte, error) {
return ConnectorRequest("GET", connector, path, query, headers, expect)
}
func PostJSON(connector string, path string, query []QueryParam, headers map[string]string, body any, expect ResponseExpect) (HostResponse, []byte, error) {
return HostRequest(HostRequestSpec{
Method: "POST",
Target: RequestTarget{
Type: "connector",
Connector: connector,
Path: path,
Query: query,
},
Headers: headers,
Body: &HostRequestBody{
Type: HostRequestBodyTypeJSON,
JSON: body,
},
Expect: expect,
})
}
func PostForm(connector string, path string, query []QueryParam, headers map[string]string, form []FormField, expect ResponseExpect) (HostResponse, []byte, error) {
return HostRequest(HostRequestSpec{
Method: "POST",
Target: RequestTarget{
Type: "connector",
Connector: connector,
Path: path,
Query: query,
},
Headers: headers,
Body: &HostRequestBody{
Type: HostRequestBodyTypeForm,
Form: form,
},
Expect: expect,
})
}
func (r HostResponse) FirstHeader(name string) string {
values := r.HeaderValuesFor(name)
if len(values) == 0 {
return ""
}
return values[0]
}
func (r HostResponse) HeaderValuesFor(name string) []string {
if r.HeaderValues == nil {
return nil
}
if values, ok := r.HeaderValues[name]; ok {
return values
}
for key, values := range r.HeaderValues {
if strings.EqualFold(key, name) {
return values
}
}
return nil
}
func Bool(value bool) *bool {
return &value
}

View File

@@ -0,0 +1,26 @@
package manifestcheck
import (
"encoding/json"
"fmt"
"io"
"os"
)
func PrintFile(w io.Writer, path string) error {
data, err := os.ReadFile(path)
if err != nil {
return err
}
var manifest map[string]any
if err := json.Unmarshal(data, &manifest); err != nil {
return err
}
encoded, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
return err
}
_, err = fmt.Fprintln(w, string(encoded))
return err
}

View File

@@ -0,0 +1,125 @@
package polyline
import (
"fmt"
"math"
)
func Decode(encoded string, precision float64) ([][2]float64, error) {
if precision == 0 {
return nil, fmt.Errorf("precision must not be zero")
}
var coords [][2]float64
index := 0
lat := 0
lon := 0
for index < len(encoded) {
dlat, next, err := decodeValue(encoded, index)
if err != nil {
return nil, err
}
index = next
dlon, next, err := decodeValue(encoded, index)
if err != nil {
return nil, err
}
index = next
lat += dlat
lon += dlon
coords = append(coords, [2]float64{float64(lat) / precision, float64(lon) / precision})
}
return coords, nil
}
func DecodeValues(encoded string, precision float64) ([]float64, error) {
if precision == 0 {
return nil, fmt.Errorf("precision must not be zero")
}
var values []float64
index := 0
value := 0
for index < len(encoded) {
delta, next, err := decodeValue(encoded, index)
if err != nil {
return nil, err
}
index = next
value += delta
values = append(values, float64(value)/precision)
}
return values, nil
}
func NormalizeCoordinateScale(coords [][2]float64) {
if len(coords) == 0 {
return
}
maxLat := 0.0
maxLon := 0.0
for _, coord := range coords {
if abs := math.Abs(coord[0]); abs > maxLat {
maxLat = abs
}
if abs := math.Abs(coord[1]); abs > maxLon {
maxLon = abs
}
}
for (maxLat > 90 || maxLon > 180) && maxLat > 0 && maxLon > 0 {
for i := range coords {
coords[i][0] /= 10
coords[i][1] /= 10
}
maxLat /= 10
maxLon /= 10
}
}
func ShouldSwapCoordinates(coords [][2]float64) bool {
validAsLat := 0
validAsLon := 0
for _, coord := range coords {
if validLatLon(coord[0], coord[1]) {
validAsLat++
}
if validLatLon(coord[1], coord[0]) {
validAsLon++
}
}
return validAsLon > validAsLat
}
func ProportionalIndex(i int, sourceLen int, targetLen int) int {
if targetLen <= 1 || sourceLen <= 1 {
return 0
}
j := int(math.Round(float64(i) * float64(targetLen-1) / float64(sourceLen-1)))
if j < 0 {
return 0
}
if j >= targetLen {
return targetLen - 1
}
return j
}
func validLatLon(lat float64, lon float64) bool {
return lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180
}
func decodeValue(encoded string, index int) (int, int, error) {
result := 0
shift := uint(0)
for {
if index >= len(encoded) {
return 0, index, fmt.Errorf("invalid polyline encoding")
}
b := int(encoded[index]) - 63
index++
result |= (b & 0x1F) << shift
shift += 5
if b < 0x20 {
break
}
}
return (result >> 1) ^ (-(result & 1)), index, nil
}

View File

@@ -0,0 +1,40 @@
package polyline
import "testing"
func TestDecode(t *testing.T) {
points, err := Decode("_p~iF~ps|U_ulLnnqC_mqNvxq`@", 1e5)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(points) != 3 {
t.Fatalf("expected 3 points, got %d", len(points))
}
if points[0][0] != 38.5 || points[0][1] != -120.2 {
t.Fatalf("unexpected first point: %#v", points[0])
}
}
func TestNormalizeCoordinateScale(t *testing.T) {
points := [][2]float64{{385, -1202}}
NormalizeCoordinateScale(points)
if points[0][0] != 38.5 || points[0][1] != -120.2 {
t.Fatalf("expected normalized point, got %#v", points[0])
}
}
func TestShouldSwapCoordinates(t *testing.T) {
coords := [][2]float64{{120.2, 38.5}, {121.0, 39.0}}
if !ShouldSwapCoordinates(coords) {
t.Fatal("expected coordinates to be detected as swapped")
}
}
func TestProportionalIndex(t *testing.T) {
if got := ProportionalIndex(2, 5, 3); got != 1 {
t.Fatalf("got %d, want 1", got)
}
if got := ProportionalIndex(4, 5, 3); got != 2 {
t.Fatalf("got %d, want 2", got)
}
}

83
plugins/sdk/sync.go Normal file
View File

@@ -0,0 +1,83 @@
package sdk
import (
"encoding/json"
"strconv"
"strings"
)
func StringField(values map[string]any, key string) string {
value, _ := values[key].(string)
return strings.TrimSpace(value)
}
func StringOption(options map[string]any, key string) string {
return StringField(options, key)
}
func IntOption(options map[string]any, key string, fallback int) int {
return intValue(options, key, fallback)
}
func BoolOption(options map[string]any, key string, fallback bool) bool {
return boolValue(options, key, fallback)
}
func IntState(state map[string]any, key string, fallback int) int {
return intValue(state, key, fallback)
}
func intValue(values map[string]any, key string, fallback int) int {
switch value := values[key].(type) {
case float64:
return int(value)
case int:
return value
case json.Number:
parsed, err := value.Int64()
if err == nil {
return int(parsed)
}
case string:
parsed, err := strconv.Atoi(value)
if err == nil {
return parsed
}
}
return fallback
}
func boolValue(values map[string]any, key string, fallback bool) bool {
switch value := values[key].(type) {
case bool:
return value
case string:
parsed, err := strconv.ParseBool(strings.TrimSpace(value))
if err == nil {
return parsed
}
}
return fallback
}
func KnownIDs(ids []string) map[string]bool {
known := make(map[string]bool, len(ids))
for _, id := range ids {
known[id] = true
}
return known
}
func SyncLimit(input ListInput) int {
if input.Limits.MaxItems > 0 {
return input.Limits.MaxItems
}
return 10
}
func NextPageState(nextPage int, hasMore bool) map[string]any {
if !hasMore {
return nil
}
return map[string]any{"page": nextPage}
}

209
plugins/sdk/types.go Normal file
View File

@@ -0,0 +1,209 @@
package sdk
const (
HostRequestBodyTypeJSON = "json"
HostRequestBodyTypeForm = "form"
HostRequestBodyTypeMultipart = "multipart"
MultipartSourceTrail = "trail"
MultipartSourceTrailGPX = "trail.gpx"
AuthHeaderAuthorization = "Authorization"
AuthSchemeBearer = "Bearer"
)
type HostRequestSpec struct {
Method string `json:"method"`
Target RequestTarget `json:"target"`
Auth string `json:"auth,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
Body *HostRequestBody `json:"body,omitempty"`
Expect ResponseExpect `json:"expect,omitempty"`
FollowRedirects *bool `json:"followRedirects,omitempty"`
}
type RequestTarget struct {
Type string `json:"type"`
Connector string `json:"connector,omitempty"`
Path string `json:"path,omitempty"`
Query []QueryParam `json:"query,omitempty"`
}
type QueryParam struct {
Name string `json:"name"`
Value string `json:"value"`
}
type HostRequestBody struct {
Type string `json:"type"`
JSON any `json:"json,omitempty"`
Form []FormField `json:"form,omitempty"`
Parts []MultipartPart `json:"parts,omitempty"`
}
type FormField struct {
Name string `json:"name"`
Value string `json:"value"`
}
type MultipartPart struct {
Name string `json:"name"`
Source string `json:"source,omitempty"`
Filename string `json:"filename,omitempty"`
ContentType string `json:"contentType,omitempty"`
JSON any `json:"json,omitempty"`
}
type ResponseExpect struct {
ContentTypes []string `json:"contentTypes,omitempty"`
MaxBytes int64 `json:"maxBytes,omitempty"`
}
type HostResponse struct {
Status int `json:"status"`
HeaderValues map[string][]string `json:"headerValues,omitempty"`
BodyBase64 string `json:"bodyBase64,omitempty"`
Error *PluginError `json:"error,omitempty"`
}
type PluginError struct {
Code string `json:"code"`
Message string `json:"message,omitempty"`
}
type LogLevel string
const (
LogLevelDebug LogLevel = "debug"
LogLevelInfo LogLevel = "info"
LogLevelWarn LogLevel = "warn"
LogLevelError LogLevel = "error"
)
type HostLogEntry struct {
Level LogLevel `json:"level"`
Message string `json:"message"`
}
type InstanceRef struct {
ID string `json:"id"`
PluginID string `json:"pluginId"`
}
type RefreshSessionInput struct {
Instance InstanceRef `json:"instance"`
Auth map[string]any `json:"auth,omitempty"`
Config map[string]any `json:"config,omitempty"`
}
type RefreshSessionOutput struct {
Token string `json:"token"`
Scheme string `json:"scheme,omitempty"`
ExpiresAt string `json:"expiresAt,omitempty"`
}
type SyncLimits struct {
MaxItems int `json:"maxItems,omitempty"`
}
type ListInput struct {
Instance InstanceRef `json:"instance"`
Auth map[string]any `json:"auth,omitempty"`
State map[string]any `json:"state,omitempty"`
Options map[string]any `json:"options,omitempty"`
Limits SyncLimits `json:"limits,omitempty"`
}
type ListOutput struct {
Items []TrailSummary `json:"items"`
State map[string]any `json:"state,omitempty"`
HasMore bool `json:"hasMore"`
Error *PluginError `json:"error,omitempty"`
}
type DetailInput struct {
Instance InstanceRef `json:"instance"`
Auth map[string]any `json:"auth,omitempty"`
Options map[string]any `json:"options,omitempty"`
Summary TrailSummary `json:"summary"`
}
type DetailOutput struct {
Item TrailImport `json:"item"`
Error *PluginError `json:"error,omitempty"`
}
type TrailSummary struct {
Source TrailImportSource `json:"source"`
Kind string `json:"kind,omitempty"`
}
type TrailImport struct {
Source TrailImportSource `json:"source"`
Kind string `json:"kind,omitempty"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
StartedAt string `json:"startedAt,omitempty"`
ActivityType string `json:"activityType,omitempty"`
Privacy *string `json:"privacy,omitempty"`
Track Track `json:"track"`
Waypoints []Waypoint `json:"waypoints,omitempty"`
Photos []Photo `json:"photos,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
}
type TrailImportSource struct {
Provider string `json:"provider"`
ExternalID string `json:"externalId"`
URL string `json:"url,omitempty"`
}
type Track struct {
Format string `json:"format"`
ContentBase64 string `json:"contentBase64"`
}
type Waypoint struct {
ExternalID string `json:"externalId,omitempty"`
Name string `json:"name,omitempty"`
Description string `json:"description,omitempty"`
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
Ele *float64 `json:"ele,omitempty"`
Icon string `json:"icon,omitempty"`
Photos []Photo `json:"photos,omitempty"`
}
type Photo struct {
ExternalID string `json:"externalId,omitempty"`
Filename string `json:"filename,omitempty"`
ContentType string `json:"contentType,omitempty"`
Lat *float64 `json:"lat,omitempty"`
Lon *float64 `json:"lon,omitempty"`
Source MediaSource `json:"source"`
}
type MediaSource struct {
Type string `json:"type"`
URL string `json:"url,omitempty"`
MediaRef *MediaRef `json:"mediaRef,omitempty"`
}
type MediaRef struct {
Connector string `json:"connector"`
Auth string `json:"auth,omitempty"`
Path string `json:"path,omitempty"`
Query []QueryParam `json:"query,omitempty"`
AssetID string `json:"assetId,omitempty"`
}
type TrailSendInput struct {
Instance InstanceRef `json:"instance"`
Auth map[string]any `json:"auth,omitempty"`
Config map[string]any `json:"config,omitempty"`
Name string `json:"name,omitempty"`
Trail Track `json:"trail"`
}
type TrailSendPlan struct {
Request HostRequestSpec `json:"request"`
}