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

View File

@@ -0,0 +1,38 @@
package pluginsystem
const (
AuthFieldAccessToken = "accessToken"
AuthFieldRefreshToken = "refreshToken"
AuthFieldClientSecret = "clientSecret"
AuthFieldOAuthState = "oauthState"
AuthFieldOAuthCodeVerifier = "oauthCodeVerifier"
AuthFieldOAuthRedirectURI = "oauthRedirectURI"
)
func InternalAuthSecretFields() []string {
return []string{
AuthFieldAccessToken,
AuthFieldRefreshToken,
AuthFieldClientSecret,
AuthFieldOAuthState,
AuthFieldOAuthCodeVerifier,
}
}
func InternalOAuthTransientFields() []string {
return []string{
AuthFieldOAuthState,
AuthFieldOAuthCodeVerifier,
AuthFieldOAuthRedirectURI,
}
}
func PluginInputAuthBlockedFields() []string {
return []string{
AuthFieldRefreshToken,
AuthFieldClientSecret,
AuthFieldOAuthState,
AuthFieldOAuthCodeVerifier,
AuthFieldOAuthRedirectURI,
}
}

View File

@@ -0,0 +1,349 @@
package pluginsystem
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"github.com/pocketbase/pocketbase/core"
)
type AuthInjectionInput struct {
App core.App
Runtime Runtime
Session RuntimeSession
Plugin LocalPlugin
Instance *core.Record
Auth map[string]any
Config map[string]any
Spec *HostRequestSpec
Policy RequestPolicyContext
}
func InjectRequestAuthForContext(manifest Manifest, auth map[string]any, contextName string, req *http.Request) error {
if contextName == "" {
return nil
}
if err := ValidateAuthReference(manifest, contextName); err != nil {
return err
}
authContext, ok := manifest.Auth.Contexts[contextName]
if !ok {
return fmt.Errorf("plugin requested unknown auth context")
}
switch authContext.Type {
case AuthTypeOAuth2:
token := StringFromAny(auth[AuthFieldAccessToken])
if token == "" {
return fmt.Errorf("oauth access token is missing")
}
scheme := StringFromAny(auth[AuthFieldTokenType])
if scheme == "" {
scheme = AuthSchemeBearer
}
req.Header.Set(AuthHeaderAuthorization, scheme+" "+token)
case AuthTypeAPIKey:
secret := StringFromAny(auth[authContext.SecretField])
if secret == "" {
return fmt.Errorf("api key is missing")
}
name := authContext.Name
if name == "" {
name = authContext.SecretField
}
if authContext.Placement == AuthPlacementQuery {
req.URL.RawQuery = setRawQueryParamOrdered(req.URL.RawQuery, name, secret)
} else {
req.Header.Set(name, secret)
}
case AuthTypeBearer:
secret := StringFromAny(auth[authContext.SecretField])
if secret == "" {
return fmt.Errorf("bearer token is missing")
}
req.Header.Set(AuthHeaderAuthorization, AuthSchemeBearer+" "+secret)
default:
return fmt.Errorf("auth context %q is not supported for media requests", contextName)
}
return nil
}
func InjectHostRequestAuthFromPolicy(manifest Manifest, auth map[string]any, spec *HostRequestSpec) error {
if spec == nil || spec.Auth == "" {
return nil
}
if err := ValidateAuthReference(manifest, spec.Auth); err != nil {
return err
}
authContext, ok := manifest.Auth.Contexts[spec.Auth]
if !ok {
return fmt.Errorf("plugin requested unknown auth context")
}
switch authContext.Type {
case AuthTypeOAuth2:
token := StringFromAny(auth[AuthFieldAccessToken])
if token == "" {
return fmt.Errorf("oauth access token is missing")
}
scheme := StringFromAny(auth[AuthFieldTokenType])
if scheme == "" {
scheme = AuthSchemeBearer
}
setAuthHeader(spec, scheme+" "+token)
case AuthTypeAPIKey:
return injectAPIKeyAuth(authContext, auth, spec)
case AuthTypeBearer:
return injectBearerAuth(authContext, auth, spec)
case AuthTypeSession:
return fmt.Errorf("session auth requires handler-managed injection")
default:
return fmt.Errorf("auth context is not supported for host requests")
}
return nil
}
type pluginSessionResponse struct {
Token string `json:"token"`
Scheme string `json:"scheme,omitempty"`
Expires string `json:"expiresAt,omitempty"`
}
// ValidateAuthContext checks that a manifest auth context contains enough data
// for the host to own OAuth/API key/session injection safely.
func ValidateAuthContext(name string, context AuthContext) error {
switch context.Type {
case AuthTypeOAuth2:
if context.AuthorizationURL == "" || context.TokenURL == "" {
return fmt.Errorf("oauth2 auth context %s requires authorizationUrl and tokenUrl", name)
}
if _, err := url.ParseRequestURI(context.AuthorizationURL); err != nil {
return fmt.Errorf("auth context %s authorizationUrl: %w", name, err)
}
if _, err := url.ParseRequestURI(context.TokenURL); err != nil {
return fmt.Errorf("auth context %s tokenUrl: %w", name, err)
}
if context.Refresh == nil || context.Refresh.Mode != AuthRefreshModeHost {
return fmt.Errorf("oauth2 auth context %s must use host refresh", name)
}
case AuthTypeAPIKey, AuthTypeBearer:
if context.SecretField == "" {
return fmt.Errorf("%s auth context %s requires secretField", context.Type, name)
}
case AuthTypeSession:
if context.Refresh == nil || context.Refresh.Mode != AuthRefreshModePlugin || context.Refresh.Function == "" {
return fmt.Errorf("session auth context %s requires plugin refresh function", name)
}
if len(context.SecretFields) == 0 {
return fmt.Errorf("session auth context %s requires secretFields", name)
}
default:
return fmt.Errorf("auth context %s has unsupported type %q", name, context.Type)
}
return nil
}
// InjectHostRequestAuth resolves the auth reference from a HostRequestSpec and
// mutates the request with the provider-specific header/query/session token.
func InjectHostRequestAuth(ctx context.Context, input AuthInjectionInput) error {
if input.Spec == nil {
return fmt.Errorf("host request spec is required")
}
if input.Spec.Auth == "" {
return nil
}
if err := ValidateAuthReference(input.Plugin.Manifest, input.Spec.Auth); err != nil {
return err
}
authContext, ok := input.Plugin.Manifest.Auth.Contexts[input.Spec.Auth]
if !ok {
return fmt.Errorf("plugin requested unknown auth context")
}
switch authContext.Type {
case AuthTypeOAuth2:
return injectOAuthAuth(ctx, input, input.Spec.Auth)
case AuthTypeAPIKey:
return injectAPIKeyAuth(authContext, input.Auth, input.Spec)
case AuthTypeBearer:
return injectBearerAuth(authContext, input.Auth, input.Spec)
case AuthTypeSession:
return injectSessionAuth(ctx, input, authContext)
default:
return fmt.Errorf("auth context is not supported for route sending")
}
}
func injectOAuthAuth(ctx context.Context, input AuthInjectionInput, contextName string) error {
if input.Instance == nil {
return fmt.Errorf("plugin instance is required")
}
auth := input.Auth
if OAuthNeedsRefresh(auth) {
refreshed, err := RefreshOAuthToken(ctx, input.App, input.Plugin, input.Instance, auth, contextName)
if err != nil {
return fmt.Errorf("oauth token refresh failed: %w", err)
}
auth = refreshed
}
token := StringFromAny(auth[AuthFieldAccessToken])
if token == "" {
return fmt.Errorf("oauth access token is missing")
}
scheme := StringFromAny(auth[AuthFieldTokenType])
if scheme == "" {
scheme = AuthSchemeBearer
}
setAuthHeader(input.Spec, scheme+" "+token)
return nil
}
func injectAPIKeyAuth(authContext AuthContext, auth map[string]any, spec *HostRequestSpec) error {
secret := StringFromAny(auth[authContext.SecretField])
if secret == "" {
return fmt.Errorf("api key is missing")
}
if authContext.Placement == AuthPlacementQuery {
name := authContext.Name
if name == "" {
name = authContext.SecretField
}
query := make([]QueryParam, 0, len(spec.Target.Query)+1)
for _, param := range spec.Target.Query {
if param.Name != name {
query = append(query, param)
}
}
query = append(query, QueryParam{Name: name, Value: secret})
spec.Target.Query = query
return nil
}
name := authContext.Name
if name == "" {
name = AuthHeaderAuthorization
}
if spec.Headers == nil {
spec.Headers = map[string]string{}
}
spec.Headers[name] = secret
return nil
}
func injectBearerAuth(authContext AuthContext, auth map[string]any, spec *HostRequestSpec) error {
secret := StringFromAny(auth[authContext.SecretField])
if secret == "" {
return fmt.Errorf("bearer token is missing")
}
setAuthHeader(spec, AuthSchemeBearer+" "+secret)
return nil
}
func injectSessionAuth(ctx context.Context, input AuthInjectionInput, authContext AuthContext) error {
if authContext.Refresh == nil || authContext.Refresh.Mode != AuthRefreshModePlugin {
return fmt.Errorf("session auth context is not supported")
}
if input.Instance == nil {
return fmt.Errorf("plugin instance is required")
}
pluginInput := map[string]any{
"instance": InstanceRef{
ID: input.Instance.Id,
PluginID: input.Instance.GetString("plugin_id"),
},
"auth": AuthForPluginRefresh(input.Auth, authContext),
"config": input.Config,
}
inputBytes, err := json.Marshal(pluginInput)
if err != nil {
return err
}
var output []byte
if input.Session != nil {
output, err = input.Session.Call(ctx, authContext.Refresh.Function, inputBytes)
} else {
output, err = input.Runtime.Call(ctx, input.Plugin, authContext.Refresh.Function, inputBytes, input.Policy)
}
if err != nil {
return err
}
var session pluginSessionResponse
if err := validatePluginSessionRefreshOutput(output, &session); err != nil {
return err
}
scheme := session.Scheme
if scheme == "" {
scheme = AuthSchemeBearer
}
setAuthHeader(input.Spec, scheme+" "+session.Token)
return nil
}
func ValidatePluginSessionRefreshOutput(output []byte) error {
var session pluginSessionResponse
return validatePluginSessionRefreshOutput(output, &session)
}
func validatePluginSessionRefreshOutput(output []byte, session *pluginSessionResponse) error {
if err := json.Unmarshal(output, session); err != nil {
return fmt.Errorf("plugin returned an invalid session: %w", err)
}
if session.Token == "" {
return fmt.Errorf("plugin returned an empty session token")
}
return nil
}
func setAuthHeader(spec *HostRequestSpec, value string) {
if spec.Headers == nil {
spec.Headers = map[string]string{}
}
spec.Headers[AuthHeaderAuthorization] = value
}
func setRawQueryParamOrdered(rawQuery string, name string, value string) string {
encoded := url.QueryEscape(name) + "=" + url.QueryEscape(value)
if rawQuery == "" {
return encoded
}
parts := strings.Split(rawQuery, "&")
kept := make([]string, 0, len(parts)+1)
for _, part := range parts {
if part == "" {
continue
}
rawName := part
if idx := strings.Index(rawName, "="); idx >= 0 {
rawName = rawName[:idx]
}
decodedName, err := url.QueryUnescape(rawName)
if err == nil && decodedName == name {
continue
}
kept = append(kept, part)
}
kept = append(kept, encoded)
return strings.Join(kept, "&")
}
func AuthForPluginRefresh(auth map[string]any, authContext AuthContext) map[string]any {
filtered := map[string]any{}
for _, field := range authContext.Fields {
if value, ok := auth[field]; ok {
filtered[field] = value
}
}
for _, field := range authContext.SecretFields {
if value, ok := auth[field]; ok {
filtered[field] = value
}
}
if authContext.SecretField != "" {
if value, ok := auth[authContext.SecretField]; ok {
filtered[authContext.SecretField] = value
}
}
return filtered
}

View File

@@ -0,0 +1,288 @@
package pluginsystem
import (
"context"
"net/http"
"testing"
)
func TestValidateAuthContext(t *testing.T) {
tests := []struct {
name string
context AuthContext
wantErr bool
}{
{
name: "oauth2",
context: AuthContext{
Type: AuthTypeOAuth2,
AuthorizationURL: "https://example.com/oauth/authorize",
TokenURL: "https://example.com/oauth/token",
Refresh: &AuthRefresh{Mode: AuthRefreshModeHost},
},
},
{
name: "missing bearer secret",
context: AuthContext{Type: AuthTypeBearer},
wantErr: true,
},
{
name: "session",
context: AuthContext{
Type: AuthTypeSession,
SecretFields: []string{"email", "password"},
Refresh: &AuthRefresh{Mode: AuthRefreshModePlugin, Function: "refresh_session_v1"},
},
},
{
name: "unsupported",
context: AuthContext{Type: "mtls"},
wantErr: true,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
err := ValidateAuthContext("default", test.context)
if test.wantErr && err == nil {
t.Fatal("expected error")
}
if !test.wantErr && err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
}
}
func TestInjectHostRequestAuthWithBearer(t *testing.T) {
spec := HostRequestSpec{Auth: "account"}
err := InjectHostRequestAuth(context.Background(), AuthInjectionInput{
Plugin: LocalPlugin{Manifest: Manifest{
Auth: AuthManifest{Contexts: map[string]AuthContext{
"account": {
Type: AuthTypeBearer,
SecretField: "token",
},
}},
Permissions: PermissionManifest{Auth: []string{"account"}},
}},
Auth: map[string]any{"token": "abc123"},
Spec: &spec,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := spec.Headers[AuthHeaderAuthorization]; got != AuthSchemeBearer+" abc123" {
t.Fatalf("unexpected authorization header: %q", got)
}
}
func TestInjectHostRequestAuthWithAPIKeyQuery(t *testing.T) {
spec := HostRequestSpec{
Auth: "account",
Target: RequestTarget{
Type: "connector",
Connector: "api",
Path: "/upload",
Query: []QueryParam{{Name: "existing", Value: "true"}},
},
}
err := InjectHostRequestAuth(context.Background(), AuthInjectionInput{
Plugin: LocalPlugin{Manifest: Manifest{
Auth: AuthManifest{Contexts: map[string]AuthContext{
"account": {
Type: AuthTypeAPIKey,
SecretField: "apiKey",
Placement: AuthPlacementQuery,
Name: "key",
},
}},
Permissions: PermissionManifest{Auth: []string{"account"}},
}},
Auth: map[string]any{"apiKey": "secret"},
Spec: &spec,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(spec.Target.Query) != 2 || spec.Target.Query[1].Name != "key" || spec.Target.Query[1].Value != "secret" {
t.Fatalf("unexpected query: %#v", spec.Target.Query)
}
}
func TestInjectHostRequestAuthFromPolicyWithBearer(t *testing.T) {
spec := HostRequestSpec{
Auth: "account",
Headers: map[string]string{AuthHeaderAuthorization: "plugin supplied"},
}
manifest := Manifest{
Auth: AuthManifest{Contexts: map[string]AuthContext{
"account": {Type: AuthTypeBearer, SecretField: "token"},
}},
Permissions: PermissionManifest{Auth: []string{"account"}},
}
err := InjectHostRequestAuthFromPolicy(manifest, map[string]any{"token": "host-secret"}, &spec)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := spec.Headers[AuthHeaderAuthorization]; got != AuthSchemeBearer+" host-secret" {
t.Fatalf("unexpected authorization header: %q", got)
}
}
func TestInjectHostRequestAuthFromPolicyFailsWithEmptyAuth(t *testing.T) {
spec := HostRequestSpec{
Auth: "account",
Headers: map[string]string{AuthHeaderAuthorization: "plugin supplied"},
}
manifest := Manifest{
Auth: AuthManifest{Contexts: map[string]AuthContext{
"account": {Type: AuthTypeBearer, SecretField: "token"},
}},
Permissions: PermissionManifest{Auth: []string{"account"}},
}
err := InjectHostRequestAuthFromPolicy(manifest, map[string]any{}, &spec)
if err == nil {
t.Fatal("expected error")
}
if err.Error() != "bearer token is missing" {
t.Fatalf("unexpected error: %v", err)
}
if got := spec.Headers[AuthHeaderAuthorization]; got != "plugin supplied" {
t.Fatalf("unexpected authorization header mutation: %q", got)
}
}
func TestInjectHostRequestAuthFromPolicyRejectsSessionAuth(t *testing.T) {
spec := HostRequestSpec{Auth: "account"}
manifest := Manifest{
Auth: AuthManifest{Contexts: map[string]AuthContext{
"account": {
Type: AuthTypeSession,
SecretFields: []string{"password"},
Refresh: &AuthRefresh{Mode: AuthRefreshModePlugin, Function: "refresh_session_v1"},
},
}},
Permissions: PermissionManifest{Auth: []string{"account"}},
}
err := InjectHostRequestAuthFromPolicy(manifest, map[string]any{"password": "secret"}, &spec)
if err == nil {
t.Fatal("expected error")
}
if err.Error() != "session auth requires handler-managed injection" {
t.Fatalf("unexpected error: %v", err)
}
}
func TestInjectHostRequestAuthFromPolicyWithAPIKeyQuery(t *testing.T) {
spec := HostRequestSpec{
Auth: "account",
Target: RequestTarget{
Type: "connector",
Path: "/assets",
Query: []QueryParam{{Name: "api_key", Value: "plugin"}},
},
}
manifest := Manifest{
Auth: AuthManifest{Contexts: map[string]AuthContext{
"account": {
Type: AuthTypeAPIKey,
SecretField: "apiKey",
Placement: AuthPlacementQuery,
Name: "api_key",
},
}},
Permissions: PermissionManifest{Auth: []string{"account"}},
}
err := InjectHostRequestAuthFromPolicy(manifest, map[string]any{"apiKey": "host-secret"}, &spec)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(spec.Target.Query) != 1 || spec.Target.Query[0].Value != "host-secret" {
t.Fatalf("unexpected query: %#v", spec.Target.Query)
}
}
func TestInjectHostRequestAuthRequiresSpec(t *testing.T) {
err := InjectHostRequestAuth(context.Background(), AuthInjectionInput{})
if err == nil {
t.Fatal("expected error")
}
if err.Error() != "host request spec is required" {
t.Fatalf("unexpected error: %v", err)
}
}
func TestInjectHostRequestAuthValidatesPermission(t *testing.T) {
spec := HostRequestSpec{Auth: "account"}
err := InjectHostRequestAuth(context.Background(), AuthInjectionInput{
Plugin: LocalPlugin{Manifest: Manifest{
Auth: AuthManifest{Contexts: map[string]AuthContext{
"account": {Type: AuthTypeBearer, SecretField: "token"},
}},
}},
Auth: map[string]any{"token": "abc123"},
Spec: &spec,
})
if err == nil {
t.Fatal("expected error")
}
if err.Error() != `auth context "account" is not permitted` {
t.Fatalf("unexpected error: %v", err)
}
}
func TestInjectRequestAuthForContextPreservesQueryOrder(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, "https://example.test/media?z=last&api_key=plugin&a=first", nil)
if err != nil {
t.Fatal(err)
}
manifest := Manifest{
Auth: AuthManifest{Contexts: map[string]AuthContext{
"account": {
Type: AuthTypeAPIKey,
SecretField: "apiKey",
Placement: AuthPlacementQuery,
Name: "api_key",
},
}},
Permissions: PermissionManifest{Auth: []string{"account"}},
}
err = InjectRequestAuthForContext(manifest, map[string]any{"apiKey": "host-secret"}, "account", req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if req.URL.RawQuery != "z=last&a=first&api_key=host-secret" {
t.Fatalf("unexpected raw query: %q", req.URL.RawQuery)
}
}
func TestAuthForPluginRefresh(t *testing.T) {
filtered := AuthForPluginRefresh(map[string]any{
"email": "user@example.com",
"password": "secret",
"accessToken": "token",
}, AuthContext{
Fields: []string{"email", "password"},
SecretFields: []string{"password"},
})
if len(filtered) != 2 {
t.Fatalf("unexpected filtered auth: %#v", filtered)
}
if filtered["email"] != "user@example.com" || filtered["password"] != "secret" {
t.Fatalf("unexpected filtered auth: %#v", filtered)
}
if _, ok := filtered["accessToken"]; ok {
t.Fatalf("unexpected access token in plugin refresh auth: %#v", filtered)
}
}

View File

@@ -0,0 +1,410 @@
package pluginsystem
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log"
"mime"
"mime/multipart"
"net/http"
"net/url"
"strings"
"pocketbase/util"
extism "github.com/extism/go-sdk"
)
type hostHTTPResponse struct {
Status int `json:"status"`
HeaderValues map[string][]string `json:"headerValues,omitempty"`
BodyBase64 string `json:"bodyBase64,omitempty"`
Error *PluginError `json:"error,omitempty"`
}
type HostRequestOptions struct {
Trail []byte
}
type HostResponse struct {
Status int
HeaderValues map[string][]string
Body []byte
}
var newConnectorHTTPClient = util.ConnectorHTTPClient
const maxHostLogPayloadBytes = 8 * 1024
// extismHostFunctions exposes the host APIs that WASM plugins may call. Each
// function must delegate to the same policy-controlled host implementation that
// backend handlers use.
func extismHostFunctions(manifest Manifest, policy RequestPolicyContext) []extism.HostFunction {
httpFn := extism.NewHostFunctionWithStack(
"http_request",
func(ctx context.Context, plugin *extism.CurrentPlugin, stack []uint64) {
requestBytes, err := plugin.ReadBytes(stack[0])
if err != nil {
writeHostHTTPResponse(ctx, plugin, stack, hostHTTPResponse{
Error: &PluginError{Code: "invalid_request", Message: err.Error()},
})
return
}
response := executeHostHTTPRequest(ctx, manifest, policy, requestBytes)
writeHostHTTPResponse(ctx, plugin, stack, response)
},
[]extism.ValueType{extism.ValueTypePTR},
[]extism.ValueType{extism.ValueTypePTR},
)
httpFn.SetNamespace("wanderer")
logFn := extism.NewHostFunctionWithStack(
"log",
func(ctx context.Context, plugin *extism.CurrentPlugin, stack []uint64) {
message, err := readBoundedHostLogPayload(plugin, stack[0])
if err != nil {
plugin.Log(extism.LogLevelError, "read host log message: "+err.Error())
return
}
entry, err := parseHostLogEntry(message)
if err != nil {
plugin.Log(extism.LogLevelError, "invalid host log message: "+err.Error())
return
}
log.Printf("plugin log [%s]: %s", entry.Level, entry.Message)
_ = ctx
},
[]extism.ValueType{extism.ValueTypePTR},
nil,
)
logFn.SetNamespace("wanderer")
return []extism.HostFunction{httpFn, logFn}
}
func readBoundedHostLogPayload(plugin *extism.CurrentPlugin, offset uint64) ([]byte, error) {
length, err := plugin.Length(offset)
if err != nil {
return nil, err
}
if length > maxHostLogPayloadBytes {
return nil, fmt.Errorf("log message exceeds maximum size")
}
return plugin.ReadBytes(offset)
}
func parseHostLogEntry(message []byte) (HostLogEntry, error) {
if len(message) > maxHostLogPayloadBytes {
return HostLogEntry{}, fmt.Errorf("log message exceeds maximum size")
}
var entry HostLogEntry
if err := json.Unmarshal(message, &entry); err != nil {
return HostLogEntry{}, fmt.Errorf("decode log entry: %w", err)
}
level, err := normalizeHostLogLevel(entry.Level)
if err != nil {
return HostLogEntry{}, err
}
entry.Level = level
entry.Message = sanitizeHostLogMessage(entry.Message)
if entry.Message == "" {
return HostLogEntry{}, fmt.Errorf("log message is required")
}
return entry, nil
}
func sanitizeHostLogMessage(message string) string {
return strings.TrimSpace(strings.Map(func(r rune) rune {
if r < 0x20 || r == 0x7f {
return ' '
}
return r
}, message))
}
func normalizeHostLogLevel(level string) (string, error) {
switch strings.ToLower(strings.TrimSpace(level)) {
case "debug":
return "debug", nil
case "info":
return "info", nil
case "warn":
return "warn", nil
case "error":
return "error", nil
default:
return "", fmt.Errorf("unsupported log level %q", level)
}
}
// executeHostHTTPRequest turns a raw plugin http_request payload into the
// hostHTTPResponse that the plugin reads back. It is the single source of truth
// for the request/response contract shared by the in-process runtime
// (extismHostFunctions) and the worker process (handleHostHTTPRequest), so the
// two paths cannot drift on error codes or response shape.
func executeHostHTTPRequest(ctx context.Context, manifest Manifest, policy RequestPolicyContext, requestBytes []byte) hostHTTPResponse {
var spec HostRequestSpec
if err := json.Unmarshal(requestBytes, &spec); err != nil {
return hostHTTPResponse{
Error: &PluginError{Code: "invalid_request", Message: "invalid host request: " + err.Error()},
}
}
executed, err := ExecuteHostRequest(ctx, manifest, policy, spec, HostRequestOptions{})
if err != nil {
return hostHTTPResponse{
Error: &PluginError{Code: "provider_unavailable", Message: err.Error()},
}
}
return hostHTTPResponse{
Status: executed.Status,
HeaderValues: executed.HeaderValues,
BodyBase64: base64.StdEncoding.EncodeToString(executed.Body),
}
}
func writeHostHTTPResponse(ctx context.Context, plugin *extism.CurrentPlugin, stack []uint64, response hostHTTPResponse) {
responseBytes, err := json.Marshal(response)
if err != nil {
responseBytes, _ = json.Marshal(hostHTTPResponse{
Error: &PluginError{Code: "internal_error", Message: err.Error()},
})
}
offset, err := plugin.WriteBytes(responseBytes)
if err != nil {
plugin.Log(extism.LogLevelError, "write host http response: "+err.Error())
stack[0] = 0
return
}
stack[0] = offset
_ = ctx
}
// ExecuteHostRequest is the single network chokepoint for plugin-controlled
// HTTP. It validates manifest policy, builds optional request bodies, enforces
// upload/response limits, follows only permitted redirects, and returns the
// bounded provider response.
func ExecuteHostRequest(ctx context.Context, manifest Manifest, policy RequestPolicyContext, spec HostRequestSpec, options HostRequestOptions) (HostResponse, error) {
if err := InjectHostRequestAuthFromPolicy(manifest, policy.HostAuth, &spec); err != nil {
return HostResponse{}, err
}
resolved, err := ValidateAndResolveHostRequestSpec(manifest, spec, policy)
if err != nil {
return HostResponse{}, err
}
body, contentType, bodySize, err := hostRequestBody(spec, options)
if err != nil {
return HostResponse{}, err
}
if err := validateHostRequestUpload(manifest, spec, contentType, bodySize); err != nil {
return HostResponse{}, err
}
req, err := http.NewRequestWithContext(ctx, spec.Method, resolved.URL.String(), body)
if err != nil {
return HostResponse{}, err
}
for key, value := range spec.Headers {
req.Header.Set(key, value)
}
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
if req.Header.Get("Accept") == "" {
req.Header.Set("Accept", "application/json")
}
client, err := newConnectorHTTPClient(util.ConnectorHTTPPolicy{
BaseURL: resolved.Connector.BaseURL,
AllowPrivate: resolved.Connector.AllowPrivate,
TLSMode: resolved.Connector.TLS.Mode,
TLSCABundle: resolved.Connector.TLS.CABundle,
}, func(req *http.Request, via []*http.Request) error {
if spec.FollowRedirects != nil && !*spec.FollowRedirects {
return http.ErrUseLastResponse
}
if len(via) >= 10 {
return fmt.Errorf("too many redirects")
}
previous := resolved.URL
if len(via) > 0 {
previous = via[len(via)-1].URL
}
return ValidateConnectorRedirect(resolved.Connector, previous, req.URL)
})
if err != nil {
return HostResponse{}, err
}
resp, err := client.Do(req)
if err != nil {
return HostResponse{}, err
}
defer resp.Body.Close()
if err := validateHostHTTPResponse(manifest, spec, resp); err != nil {
return HostResponse{}, err
}
maxBytes := effectiveResponseMaxBytes(manifest, spec)
limit := maxBytes
if limit <= 0 {
limit = 1 << 20
}
bodyBytes, err := io.ReadAll(io.LimitReader(resp.Body, limit+1))
if err != nil {
return HostResponse{}, err
}
if maxBytes > 0 && int64(len(bodyBytes)) > maxBytes {
return HostResponse{}, fmt.Errorf("provider response exceeds maximum size")
}
if maxBytes <= 0 && int64(len(bodyBytes)) > limit {
return HostResponse{}, fmt.Errorf("provider response exceeds default maximum size")
}
headerValues := map[string][]string{}
for key, values := range resp.Header {
if len(values) > 0 {
headerValues[key] = append([]string{}, values...)
}
}
return HostResponse{
Status: resp.StatusCode,
HeaderValues: headerValues,
Body: bodyBytes,
}, nil
}
func hostRequestBody(spec HostRequestSpec, options HostRequestOptions) (io.Reader, string, int64, error) {
if spec.Body == nil {
return nil, "", 0, nil
}
switch spec.Body.Type {
case HostRequestBodyTypeJSON:
body, err := json.Marshal(spec.Body.JSON)
if err != nil {
return nil, "", 0, err
}
return bytes.NewReader(body), "application/json", int64(len(body)), nil
case HostRequestBodyTypeForm:
body, err := formURLEncodedBody(spec.Body.Form)
if err != nil {
return nil, "", 0, err
}
return strings.NewReader(body), "application/x-www-form-urlencoded", int64(len(body)), nil
case HostRequestBodyTypeMultipart:
var body bytes.Buffer
writer := multipart.NewWriter(&body)
for _, part := range spec.Body.Parts {
if part.Source == MultipartSourceTrail || part.Source == MultipartSourceTrailGPX {
if len(options.Trail) == 0 {
return nil, "", 0, fmt.Errorf("multipart part %q requires trail content", part.Name)
}
filename := part.Filename
if filename == "" {
filename = MultipartTrailFilename
}
partWriter, err := writer.CreateFormFile(part.Name, filename)
if err != nil {
return nil, "", 0, err
}
if _, err := partWriter.Write(options.Trail); err != nil {
return nil, "", 0, err
}
continue
}
if part.JSON != nil {
data, err := json.Marshal(part.JSON)
if err != nil {
return nil, "", 0, err
}
if err := writer.WriteField(part.Name, string(data)); err != nil {
return nil, "", 0, err
}
}
}
if err := writer.Close(); err != nil {
return nil, "", 0, err
}
return &body, writer.FormDataContentType(), int64(body.Len()), nil
default:
return nil, "", 0, fmt.Errorf("unsupported host request body type %q", spec.Body.Type)
}
}
func formURLEncodedBody(fields []FormField) (string, error) {
encoded := make([]string, 0, len(fields))
for _, field := range fields {
if field.Name == "" {
return "", fmt.Errorf("form field name must not be empty")
}
if hasControl(field.Name) || hasControl(field.Value) {
return "", fmt.Errorf("form fields must not contain control characters")
}
encoded = append(encoded, url.QueryEscape(field.Name)+"="+url.QueryEscape(field.Value))
}
return strings.Join(encoded, "&"), nil
}
func validateHostRequestUpload(manifest Manifest, spec HostRequestSpec, contentType string, bodySize int64) error {
if spec.Body == nil {
return nil
}
if manifest.Permissions.Uploads.MaxBytes > 0 && bodySize > manifest.Permissions.Uploads.MaxBytes {
return fmt.Errorf("host request upload exceeds manifest upload limit")
}
if contentType == "" || len(manifest.Permissions.Uploads.ContentTypes) == 0 {
return nil
}
mediaType, _, err := mime.ParseMediaType(contentType)
if err != nil {
return fmt.Errorf("host request upload has invalid content type")
}
for _, allowed := range manifest.Permissions.Uploads.ContentTypes {
if strings.EqualFold(mediaType, allowed) {
return nil
}
}
return fmt.Errorf("host request upload content type %q is not allowed", mediaType)
}
func validateHostHTTPResponse(manifest Manifest, spec HostRequestSpec, resp *http.Response) error {
allowedContentTypes := effectiveResponseContentTypes(manifest, spec)
if resp.StatusCode >= 200 && resp.StatusCode < 300 && len(allowedContentTypes) > 0 {
contentType := resp.Header.Get("Content-Type")
mediaType, _, err := mime.ParseMediaType(contentType)
if err != nil || mediaType == "" {
return fmt.Errorf("provider response has invalid content type")
}
allowed := false
for _, expected := range allowedContentTypes {
if strings.EqualFold(mediaType, expected) {
allowed = true
break
}
}
if !allowed {
return fmt.Errorf("provider response content type %q is not allowed", mediaType)
}
}
maxBytes := effectiveResponseMaxBytes(manifest, spec)
if maxBytes > 0 && resp.ContentLength > maxBytes {
return fmt.Errorf("provider response exceeds maximum size")
}
return nil
}
func effectiveResponseContentTypes(manifest Manifest, spec HostRequestSpec) []string {
if len(spec.Expect.ContentTypes) > 0 {
return spec.Expect.ContentTypes
}
return manifest.Permissions.Downloads.ContentTypes
}
func effectiveResponseMaxBytes(manifest Manifest, spec HostRequestSpec) int64 {
if spec.Expect.MaxBytes > 0 {
return spec.Expect.MaxBytes
}
return manifest.Permissions.Downloads.MaxBytes
}

View File

@@ -0,0 +1,407 @@
package pluginsystem
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"pocketbase/util"
)
func TestParseHostLogEntry(t *testing.T) {
payload, err := json.Marshal(HostLogEntry{Level: "warn", Message: " slow request "})
if err != nil {
t.Fatal(err)
}
entry, err := parseHostLogEntry(payload)
if err != nil {
t.Fatal(err)
}
if entry.Level != "warn" || entry.Message != "slow request" {
t.Fatalf("unexpected structured entry: %#v", entry)
}
if _, err := parseHostLogEntry([]byte(" plain message ")); err == nil {
t.Fatal("expected plain log message to fail")
}
if _, err := parseHostLogEntry([]byte(`{"level":"verbose","message":"hello"}`)); err == nil {
t.Fatal("expected unsupported log level to fail")
}
if _, err := parseHostLogEntry([]byte(`{"level":"info","message":" "}`)); err == nil {
t.Fatal("expected empty log message to fail")
}
}
func TestParseHostLogEntrySanitizesMessage(t *testing.T) {
entry, err := parseHostLogEntry([]byte(`{"level":"info","message":"first\nsecond\rthird\tfourth"}`))
if err != nil {
t.Fatal(err)
}
if entry.Message != "first second third fourth" {
t.Fatalf("unexpected sanitized message: %q", entry.Message)
}
}
func TestParseHostLogEntryRejectsOversizedPayload(t *testing.T) {
payload := []byte(`{"level":"info","message":"` + strings.Repeat("x", maxHostLogPayloadBytes) + `"}`)
if _, err := parseHostLogEntry(payload); err == nil {
t.Fatal("expected oversized log payload to fail")
}
}
func TestExecuteHostRequestRejectsRedirectToUndeclaredHost(t *testing.T) {
useUnsafeTestHTTPClient(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "https://evil.example.test/v1/upload", http.StatusFound)
}))
defer server.Close()
_, err := ExecuteHostRequest(context.Background(), testHostManifest(t, server.URL), testHostPolicy(t, server.URL), HostRequestSpec{
Method: "GET",
Target: RequestTarget{
Type: "connector",
Connector: "api",
Path: "/v1",
},
}, HostRequestOptions{})
if err == nil {
t.Fatal("expected redirect policy error")
}
}
func TestExecuteHostRequestRejectsRedirectOutsidePathScope(t *testing.T) {
useUnsafeTestHTTPClient(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/admin", http.StatusFound)
}))
defer server.Close()
_, err := ExecuteHostRequest(context.Background(), testHostManifest(t, server.URL), testHostPolicy(t, server.URL), HostRequestSpec{
Method: "GET",
Target: RequestTarget{Type: "connector", Connector: "api", Path: "/v1"},
}, HostRequestOptions{})
if err == nil {
t.Fatal("expected redirect policy error")
}
}
func TestExecuteHostRequestEnforcesResponseLimit(t *testing.T) {
useUnsafeTestHTTPClient(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"too":"large"}`))
}))
defer server.Close()
_, err := ExecuteHostRequest(context.Background(), testHostManifest(t, server.URL), testHostPolicy(t, server.URL), HostRequestSpec{
Method: "GET",
Target: RequestTarget{Type: "connector", Connector: "api", Path: "/v1"},
Expect: ResponseExpect{
ContentTypes: []string{"application/json"},
MaxBytes: 4,
},
}, HostRequestOptions{})
if err == nil {
t.Fatal("expected maxBytes error")
}
}
func TestExecuteHostRequestAllowsErrorResponseWithoutContentType(t *testing.T) {
useUnsafeTestHTTPClient(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`missing credentials`))
}))
defer server.Close()
resp, err := ExecuteHostRequest(context.Background(), testHostManifest(t, server.URL), testHostPolicy(t, server.URL), HostRequestSpec{
Method: "GET",
Target: RequestTarget{Type: "connector", Connector: "api", Path: "/v1"},
Expect: ResponseExpect{
ContentTypes: []string{"application/json"},
MaxBytes: 1024,
},
}, HostRequestOptions{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.Status != http.StatusUnauthorized || string(resp.Body) != "missing credentials" {
t.Fatalf("unexpected response: %#v body=%q", resp, string(resp.Body))
}
}
func TestExecuteHostRequestInjectsAPIKeyQueryBeforeBuildingURL(t *testing.T) {
useUnsafeTestHTTPClient(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.URL.Query().Get("api_key"); got != "host-secret" {
t.Fatalf("api_key = %q, want host-secret; raw query %q", got, r.URL.RawQuery)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":true}`))
}))
defer server.Close()
manifest := testHostManifest(t, server.URL)
manifest.Auth = AuthManifest{Contexts: map[string]AuthContext{
"account": {
Type: AuthTypeAPIKey,
SecretField: "apiKey",
Placement: AuthPlacementQuery,
Name: "api_key",
},
}}
manifest.Permissions.Auth = []string{"account"}
manifest.Permissions.Network.Connectors[0].Auth = []string{"account"}
policy := testHostPolicy(t, server.URL).WithHostAuth(map[string]any{"apiKey": "host-secret"})
policy.Connectors["api"] = ResolvedConnectorTarget{
Name: "api",
Type: ConnectorTypePublicAPI,
BaseURL: policy.Connectors["api"].BaseURL,
BasePath: "/",
AllowPrivate: true,
AllowedPathPrefixes: []string{"/v1"},
Auth: []string{"account"},
}
resp, err := ExecuteHostRequest(context.Background(), manifest, policy, HostRequestSpec{
Method: "GET",
Auth: "account",
Target: RequestTarget{
Type: "connector",
Connector: "api",
Path: "/v1",
Query: []QueryParam{{Name: "existing", Value: "1"}},
},
Expect: ResponseExpect{
ContentTypes: []string{"application/json"},
MaxBytes: 1024,
},
}, HostRequestOptions{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.Status != http.StatusOK {
t.Fatalf("unexpected status %d", resp.Status)
}
}
func TestExecuteHostRequestBuildsMultipartTrailSend(t *testing.T) {
useUnsafeTestHTTPClient(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if mediaType := strings.Split(r.Header.Get("Content-Type"), ";")[0]; mediaType != "multipart/form-data" {
t.Fatalf("unexpected content type %q", r.Header.Get("Content-Type"))
}
file, header, err := r.FormFile("file")
if err != nil {
t.Fatalf("expected file part: %v", err)
}
defer file.Close()
if header.Filename != "My Route.gpx" {
t.Fatalf("unexpected filename %q", header.Filename)
}
data, _ := io.ReadAll(file)
if string(data) != "<gpx />" {
t.Fatalf("unexpected trail body %q", string(data))
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":true}`))
}))
defer server.Close()
resp, err := ExecuteHostRequest(context.Background(), testHostManifest(t, server.URL), testHostPolicy(t, server.URL), HostRequestSpec{
Method: "POST",
Target: RequestTarget{Type: "connector", Connector: "api", Path: "/v1/upload"},
Body: &HostRequestBody{
Type: HostRequestBodyTypeMultipart,
Parts: []MultipartPart{{
Name: "file",
Source: MultipartSourceTrail,
Filename: "My Route.gpx",
}},
},
Expect: ResponseExpect{
ContentTypes: []string{"application/json"},
MaxBytes: 1024,
},
}, HostRequestOptions{Trail: []byte("<gpx />")})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.Status != http.StatusOK {
t.Fatalf("unexpected status %d", resp.Status)
}
}
func TestExecuteHostRequestBuildsFormURLEncodedBody(t *testing.T) {
useUnsafeTestHTTPClient(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Content-Type"); got != "application/x-www-form-urlencoded" {
t.Fatalf("unexpected content type %q", got)
}
if err := r.ParseForm(); err != nil {
t.Fatalf("parse form: %v", err)
}
if got := r.Form.Get("person[login_identity]"); got != "user@example.test" {
t.Fatalf("login_identity = %q", got)
}
if got := r.Form.Get("person[password]"); got != "secret" {
t.Fatalf("password = %q", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":true}`))
}))
defer server.Close()
manifest := testHostManifest(t, server.URL)
manifest.Permissions.Uploads.ContentTypes = append(manifest.Permissions.Uploads.ContentTypes, "application/x-www-form-urlencoded")
resp, err := ExecuteHostRequest(context.Background(), manifest, testHostPolicy(t, server.URL), HostRequestSpec{
Method: "POST",
Target: RequestTarget{Type: "connector", Connector: "api", Path: "/v1/login"},
Body: &HostRequestBody{
Type: HostRequestBodyTypeForm,
Form: []FormField{
{Name: "person[login_identity]", Value: "user@example.test"},
{Name: "person[password]", Value: "secret"},
},
},
Expect: ResponseExpect{
ContentTypes: []string{"application/json"},
MaxBytes: 1024,
},
}, HostRequestOptions{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.Status != http.StatusOK {
t.Fatalf("unexpected status %d", resp.Status)
}
}
func TestExecuteHostRequestCanReturnRedirectResponse(t *testing.T) {
useUnsafeTestHTTPClient(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/v1/next", http.StatusFound)
}))
defer server.Close()
followRedirects := false
resp, err := ExecuteHostRequest(context.Background(), testHostManifest(t, server.URL), testHostPolicy(t, server.URL), HostRequestSpec{
Method: "GET",
Target: RequestTarget{Type: "connector", Connector: "api", Path: "/v1/start"},
FollowRedirects: &followRedirects,
}, HostRequestOptions{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.Status != http.StatusFound {
t.Fatalf("unexpected status %d", resp.Status)
}
if got := resp.HeaderValues["Location"]; len(got) != 1 || got[0] != "/v1/next" {
t.Fatalf("Location = %#v", got)
}
}
func TestExecuteHostRequestReturnsMultiValueHeaders(t *testing.T) {
useUnsafeTestHTTPClient(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Set-Cookie", "session=abc; Path=/")
w.Header().Add("Set-Cookie", "device=full; Path=/")
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":true}`))
}))
defer server.Close()
resp, err := ExecuteHostRequest(context.Background(), testHostManifest(t, server.URL), testHostPolicy(t, server.URL), HostRequestSpec{
Method: "GET",
Target: RequestTarget{Type: "connector", Connector: "api", Path: "/v1"},
Expect: ResponseExpect{
ContentTypes: []string{"application/json"},
MaxBytes: 1024,
},
}, HostRequestOptions{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := resp.HeaderValues["Set-Cookie"]; len(got) != 2 || got[0] != "session=abc; Path=/" || got[1] != "device=full; Path=/" {
t.Fatalf("Set-Cookie values = %#v", got)
}
}
func useUnsafeTestHTTPClient(t *testing.T) {
t.Helper()
original := newConnectorHTTPClient
newConnectorHTTPClient = func(policy util.ConnectorHTTPPolicy, checkRedirect func(req *http.Request, via []*http.Request) error) (*http.Client, error) {
return &http.Client{
Timeout: 60 * time.Second,
CheckRedirect: checkRedirect,
}, nil
}
t.Cleanup(func() {
newConnectorHTTPClient = original
})
}
func testHostManifest(t *testing.T, rawURL string) Manifest {
t.Helper()
return Manifest{
ManifestVersion: ManifestVersion,
ID: "test",
Type: PluginTypeTrails,
Name: "Test",
Version: "0.1.0",
Runtime: RuntimeManifest{
Type: RuntimeWASM,
Entrypoint: "plugin.wasm",
},
Capabilities: []CapabilityManifest{{
Name: "test",
Version: "v1",
Export: "test_v1",
}},
Permissions: PermissionManifest{
Network: NetworkPermissions{
Connectors: []ConnectorTargetPermission{{
Name: "api",
Type: ConnectorTypePublicAPI,
FixedBaseURL: rawURL,
AllowedPathPrefixes: []string{"/v1"},
}},
},
Downloads: DownloadPermissions{
MaxBytes: 1024,
ContentTypes: []string{"application/json"},
},
Uploads: UploadPermissions{
MaxBytes: 1024,
ContentTypes: []string{"multipart/form-data"},
},
},
}
}
func testHostPolicy(t *testing.T, rawURL string) RequestPolicyContext {
t.Helper()
parsed, err := url.Parse(rawURL)
if err != nil {
t.Fatal(err)
}
parsed.Path = ""
return RequestPolicyContext{Connectors: map[string]ResolvedConnectorTarget{
"api": {
Name: "api",
Type: ConnectorTypePublicAPI,
BaseURL: parsed.String(),
BasePath: "/",
AllowPrivate: true,
AllowedPathPrefixes: []string{"/v1"},
},
}}
}

View File

@@ -0,0 +1,75 @@
package pluginsystem
import "time"
type InstanceRef struct {
ID string `json:"id"`
PluginID string `json:"pluginId"`
}
type TrailImport struct {
Source TrailImportSource `json:"source"`
Kind string `json:"kind,omitempty"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
StartedAt *time.Time `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 TrailSummary struct {
Source TrailImportSource `json:"source"`
Kind string `json:"kind,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"`
Time *time.Time `json:"time,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"`
TakenAt *time.Time `json:"takenAt,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"`
ExpiresAt *time.Time `json:"expiresAt,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"`
}

View File

@@ -0,0 +1,98 @@
package pluginsystem
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
)
// LoadInstalledPlugin resolves one plugin from the installed_plugins cache. If
// the cache record is missing or stale, it falls back to the local plugin
// directory so newly copied bundles can still be discovered.
func LoadInstalledPlugin(app core.App, dir string, pluginID string) (LocalPlugin, error) {
if pluginID == "" {
return LocalPlugin{}, fmt.Errorf("plugin id is required")
}
record, _ := app.FindFirstRecordByFilter(
"installed_plugins",
"plugin_id={:plugin_id}",
dbx.Params{"plugin_id": pluginID},
)
if record != nil {
plugin, err := localPluginFromRecord(record)
if err == nil {
return plugin, nil
}
}
if dir == "" {
dir = PluginDir()
}
plugins, err := LoadLocalPlugins(dir)
if err != nil {
return LocalPlugin{}, err
}
for _, plugin := range plugins {
if plugin.Manifest.ID == pluginID {
return plugin, nil
}
}
return LocalPlugin{}, fmt.Errorf("unknown plugin")
}
// LoadInstalledPlugins returns the cached installed plugin manifests used by
// request hot paths, with disk discovery as a bootstrap fallback.
func LoadInstalledPlugins(app core.App, dir string) ([]LocalPlugin, error) {
records, err := app.FindRecordsByFilter("installed_plugins", "", "", -1, 0)
if err != nil {
return nil, err
}
plugins := make([]LocalPlugin, 0, len(records))
for _, record := range records {
plugin, err := localPluginFromRecord(record)
if err != nil {
continue
}
plugins = append(plugins, plugin)
}
if len(plugins) > 0 {
return plugins, nil
}
if dir == "" {
dir = PluginDir()
}
return LoadLocalPlugins(dir)
}
func localPluginFromRecord(record *core.Record) (LocalPlugin, error) {
var manifest Manifest
if err := record.UnmarshalJSONField("manifest", &manifest); err != nil {
return LocalPlugin{}, err
}
if err := ValidateManifest(manifest); err != nil {
return LocalPlugin{}, err
}
dir := strings.TrimSpace(record.GetString("path"))
if dir == "" {
return LocalPlugin{}, fmt.Errorf("installed plugin path is empty")
}
entrypoint := filepath.Clean(manifest.Runtime.Entrypoint)
if filepath.IsAbs(entrypoint) || entrypoint == ".." || strings.HasPrefix(entrypoint, ".."+string(filepath.Separator)) {
return LocalPlugin{}, fmt.Errorf("runtime entrypoint must be relative to plugin directory")
}
wasmPath := filepath.Join(dir, entrypoint)
if _, err := os.Stat(wasmPath); err != nil {
return LocalPlugin{}, fmt.Errorf("runtime entrypoint: %w", err)
}
return LocalPlugin{
Manifest: manifest,
Dir: dir,
WASMPath: wasmPath,
}, nil
}

70
db/pluginsystem/json.go Normal file
View File

@@ -0,0 +1,70 @@
package pluginsystem
import (
"encoding/json"
"github.com/pocketbase/pocketbase/core"
)
// JSONMapFromRecord reads a PocketBase JSON field into a map. Invalid, empty,
// or null values are treated as an empty object because plugin config/state/auth
// fields should be tolerant of partially edited records.
func JSONMapFromRecord(record *core.Record, field string) map[string]any {
if record == nil {
return map[string]any{}
}
value := record.GetString(field)
if value == "" {
return map[string]any{}
}
var result map[string]any
if err := json.Unmarshal([]byte(value), &result); err != nil || result == nil {
return map[string]any{}
}
return result
}
// DeepMergeConfig recursively overlays src onto dst and clones JSON-like values
// so caller-owned config maps cannot be mutated through shared references.
func DeepMergeConfig(dst map[string]any, src map[string]any) {
DeepMergeConfigWithReplaceKeys(dst, src, nil)
}
// DeepMergeConfigWithReplaceKeys behaves like DeepMergeConfig, but map values
// whose key is listed in replaceKeys replace the destination map instead of
// being recursively merged.
func DeepMergeConfigWithReplaceKeys(dst map[string]any, src map[string]any, replaceKeys map[string]bool) {
for key, value := range src {
srcMap, srcIsMap := value.(map[string]any)
dstMap, dstIsMap := dst[key].(map[string]any)
if srcIsMap && dstIsMap {
if replaceKeys[key] {
dst[key] = CloneJSONMap(srcMap)
continue
}
DeepMergeConfigWithReplaceKeys(dstMap, srcMap, replaceKeys)
continue
}
dst[key] = CloneJSONValue(value)
}
}
func CloneJSONMap(values map[string]any) map[string]any {
cloned := make(map[string]any, len(values))
for key, value := range values {
cloned[key] = CloneJSONValue(value)
}
return cloned
}
func CloneJSONValue(value any) any {
data, err := json.Marshal(value)
if err != nil {
return value
}
var cloned any
if err := json.Unmarshal(data, &cloned); err != nil {
return value
}
return cloned
}

View File

@@ -0,0 +1,81 @@
package pluginsystem
import "testing"
func TestMergePluginConfigEmptyCategoryMappingOverridesDefaultMap(t *testing.T) {
dst := map[string]any{
"host": map[string]any{
"categoryMapping": map[string]any{
"hike": "hiking",
"bike": "biking",
},
"privacy": "public",
},
}
src := map[string]any{
"host": map[string]any{
"categoryMapping": map[string]any{},
},
}
MergePluginConfig(dst, src)
host := dst["host"].(map[string]any)
mapping := host["categoryMapping"].(map[string]any)
if len(mapping) != 0 {
t.Fatalf("expected empty category mapping override, got %#v", mapping)
}
if host["privacy"] != "public" {
t.Fatalf("expected sibling defaults to remain, got %#v", host)
}
}
func TestMergePluginConfigCategoryMappingReplacesDefaultMap(t *testing.T) {
dst := map[string]any{
"host": map[string]any{
"categoryMapping": map[string]any{
"hike": "hiking",
"bike": "biking",
},
},
}
src := map[string]any{
"host": map[string]any{
"categoryMapping": map[string]any{
"hike": "custom",
},
},
}
MergePluginConfig(dst, src)
mapping := dst["host"].(map[string]any)["categoryMapping"].(map[string]any)
if len(mapping) != 1 || mapping["hike"] != "custom" {
t.Fatalf("expected category mapping to replace defaults, got %#v", mapping)
}
}
func TestDeepMergeConfigNonEmptyMapStillMergesByDefault(t *testing.T) {
dst := map[string]any{
"host": map[string]any{
"categoryMapping": map[string]any{
"hike": "hiking",
"bike": "biking",
},
},
}
src := map[string]any{
"host": map[string]any{
"categoryMapping": map[string]any{
"hike": "custom",
},
},
}
DeepMergeConfig(dst, src)
mapping := dst["host"].(map[string]any)["categoryMapping"].(map[string]any)
if mapping["hike"] != "custom" || mapping["bike"] != "biking" {
t.Fatalf("expected generic merge to keep sibling defaults, got %#v", mapping)
}
}

419
db/pluginsystem/manager.go Normal file
View File

@@ -0,0 +1,419 @@
package pluginsystem
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"hash/fnv"
"os"
"path/filepath"
"strings"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
)
// Manager coordinates local plugin discovery with the installed_plugins cache.
// It is intentionally small: request hot paths should read cached manifests,
// while list/cron entrypoints refresh the cache from data/plugins first.
type Manager struct {
App core.App
Dir string
}
// PluginInfo is the UI-facing view of an installed plugin. It combines the
// static manifest with runtime availability and embedded icon data.
type PluginInfo struct {
ID string `json:"id"`
Type string `json:"type"`
Name string `json:"name"`
DisplayName string `json:"displayName,omitempty"`
Description string `json:"description,omitempty"`
Icon string `json:"icon,omitempty"`
IconDark string `json:"iconDark,omitempty"`
Version string `json:"version"`
Runtime string `json:"runtime"`
Path string `json:"path"`
Capabilities []string `json:"capabilities"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
Manifest Manifest `json:"manifest"`
}
// NewManager creates a manager for the configured plugin directory. Tests can
// pass a custom dir; production callers use the resolved runtime plugin
// directory.
func NewManager(app core.App, dir string) *Manager {
if dir == "" {
dir = PluginDir()
}
return &Manager{App: app, Dir: dir}
}
// ListLocalPlugins returns installed plugins in the shape consumed by the
// settings UI. It reads from installed_plugins first so listing does not need to
// parse every manifest from disk after the cache has been refreshed.
func (m *Manager) ListLocalPlugins(context.Context) ([]PluginInfo, error) {
plugins, err := LoadInstalledPlugins(m.App, m.Dir)
if err != nil {
return nil, err
}
infos := make([]PluginInfo, 0, len(plugins))
infoByPath := map[string]int{}
for _, plugin := range plugins {
status := "available"
record, _ := m.App.FindFirstRecordByFilter(
"installed_plugins",
"plugin_id={:plugin_id}",
dbx.Params{"plugin_id": plugin.Manifest.ID},
)
if record != nil && record.GetString("status") != "" {
status = record.GetString("status")
}
errorMessage := ""
if record != nil {
errorMessage = record.GetString("error")
}
icon, iconDark := pluginIcons(plugin)
infos = append(infos, PluginInfo{
ID: plugin.Manifest.ID,
Type: plugin.Manifest.Type,
Name: plugin.Manifest.Name,
DisplayName: stringMetadata(plugin.Manifest.Metadata, "displayName"),
Description: plugin.Manifest.Description,
Icon: icon,
IconDark: iconDark,
Version: plugin.Manifest.Version,
Runtime: plugin.Manifest.Runtime.Type,
Path: plugin.Dir,
Capabilities: capabilityNames(plugin.Manifest.Capabilities),
Status: status,
Error: errorMessage,
Manifest: plugin.Manifest,
})
infoByPath[filepath.Clean(plugin.Dir)] = len(infos) - 1
}
_, issues, err := DiscoverLocalPlugins(m.Dir)
if err != nil {
return nil, err
}
for _, issue := range issues {
if index, ok := infoByPath[filepath.Clean(issue.Dir)]; ok {
infos[index].Status = "error"
infos[index].Error = issue.Error
continue
}
infos = append(infos, PluginInfo{
ID: issue.ID,
Type: PluginTypeTrails,
Name: issue.Name,
Path: issue.Dir,
Status: "error",
Error: issue.Error,
Runtime: RuntimeWASM,
Manifest: Manifest{
ID: issue.ID,
Type: PluginTypeTrails,
Name: issue.Name,
Runtime: RuntimeManifest{
Type: RuntimeWASM,
},
},
})
}
return infos, nil
}
// pluginIcons embeds optional light/dark icon files from the plugin bundle as
// data URLs so the frontend does not need direct filesystem access.
func pluginIcons(plugin LocalPlugin) (string, string) {
icons, _ := plugin.Manifest.Metadata["icons"].(map[string]any)
return pluginIcon(plugin.Dir, stringMetadata(icons, "light")), pluginIcon(plugin.Dir, stringMetadata(icons, "dark"))
}
func stringMetadata(values map[string]any, key string) string {
value, _ := values[key].(string)
return value
}
func pluginIcon(pluginDir string, iconPath string) string {
iconPath = strings.TrimSpace(iconPath)
if iconPath == "" {
return ""
}
cleanPath := filepath.Clean(iconPath)
if filepath.IsAbs(cleanPath) || cleanPath == ".." || strings.HasPrefix(cleanPath, ".."+string(filepath.Separator)) {
return ""
}
fullPath := filepath.Join(pluginDir, cleanPath)
data, err := os.ReadFile(fullPath)
if err != nil {
return ""
}
contentType := "image/svg+xml"
switch strings.ToLower(filepath.Ext(fullPath)) {
case ".png":
contentType = "image/png"
case ".jpg", ".jpeg":
contentType = "image/jpeg"
case ".webp":
contentType = "image/webp"
}
return "data:" + contentType + ";base64," + base64.StdEncoding.EncodeToString(data)
}
// SyncInstalledPlugins scans the runtime plugin directory and upserts
// installed_plugins records.
// This keeps the manifest snapshot available even when later code paths should
// avoid repeated disk IO.
func (m *Manager) SyncInstalledPlugins(ctx context.Context) error {
plugins, issues, err := DiscoverLocalPlugins(m.Dir)
if err != nil {
return err
}
collection, err := m.App.FindCollectionByNameOrId("installed_plugins")
if err != nil {
return err
}
activePaths := activePluginPaths(plugins, issues)
if err := m.deleteStaleInstalledPlugins(ctx, activePaths); err != nil {
return err
}
for _, issue := range issues {
if err := ctx.Err(); err != nil {
return err
}
m.App.Logger().Warn("plugin setup error", "plugin", issue.ID, "path", issue.Dir, "error", issue.Error)
if err := m.savePluginIssue(collection, issue); err != nil {
return err
}
}
for _, plugin := range plugins {
if err := ctx.Err(); err != nil {
return err
}
record, err := m.findPluginRecord(collection, plugin)
if err != nil {
return err
}
record.Set("plugin_id", plugin.Manifest.ID)
record.Set("name", plugin.Manifest.Name)
record.Set("type", plugin.Manifest.Type)
record.Set("version", plugin.Manifest.Version)
record.Set("runtime", plugin.Manifest.Runtime.Type)
record.Set("path", plugin.Dir)
record.Set("status", "available")
record.Set("error", "")
manifestJSON, err := marshalManifest(plugin.Manifest)
if err != nil {
return fmt.Errorf("encode installed plugin %s manifest: %w", plugin.Manifest.ID, err)
}
record.Set("manifest", manifestJSON)
record.Set("config", mergeDefaultConfig(defaultConfig(plugin.Manifest), JSONMapFromRecord(record, "config")))
if err := m.App.Save(record); err != nil {
return fmt.Errorf("save installed plugin %s: %w", plugin.Manifest.ID, err)
}
}
return nil
}
func activePluginPaths(plugins []LocalPlugin, issues []LocalPluginIssue) map[string]bool {
paths := make(map[string]bool, len(plugins)+len(issues))
for _, plugin := range plugins {
if plugin.Dir != "" {
paths[filepath.Clean(plugin.Dir)] = true
}
}
for _, issue := range issues {
if issue.Dir != "" {
paths[filepath.Clean(issue.Dir)] = true
}
}
return paths
}
func (m *Manager) deleteStaleInstalledPlugins(ctx context.Context, activePaths map[string]bool) error {
records, err := m.App.FindRecordsByFilter("installed_plugins", "", "", -1, 0)
if err != nil {
return err
}
for _, record := range records {
if err := ctx.Err(); err != nil {
return err
}
path := strings.TrimSpace(record.GetString("path"))
if path != "" && activePaths[filepath.Clean(path)] {
continue
}
if err := m.App.Delete(record); err != nil {
return fmt.Errorf("delete stale installed plugin %s: %w", record.GetString("plugin_id"), err)
}
}
return nil
}
func (m *Manager) findPluginRecord(collection *core.Collection, plugin LocalPlugin) (*core.Record, error) {
recordByID, _ := m.App.FindFirstRecordByFilter(
"installed_plugins",
"plugin_id={:plugin_id}",
dbx.Params{"plugin_id": plugin.Manifest.ID},
)
var recordByPath *core.Record
if plugin.Dir != "" {
recordByPath, _ = m.App.FindFirstRecordByFilter(
"installed_plugins",
"path={:path}",
dbx.Params{"path": plugin.Dir},
)
}
if recordByID != nil && recordByPath != nil && recordByID.Id != recordByPath.Id {
if err := m.App.Delete(recordByPath); err != nil {
return nil, fmt.Errorf("delete superseded installed plugin %s: %w", recordByPath.GetString("plugin_id"), err)
}
}
if recordByID != nil {
return recordByID, nil
}
if recordByPath != nil {
return recordByPath, nil
}
return core.NewRecord(collection), nil
}
func (m *Manager) savePluginIssue(collection *core.Collection, issue LocalPluginIssue) error {
recordID := pluginIssueRecordID(issue)
record, _ := m.App.FindFirstRecordByFilter(
"installed_plugins",
"plugin_id={:plugin_id}",
dbx.Params{"plugin_id": recordID},
)
if record == nil && issue.Dir != "" {
record, _ = m.App.FindFirstRecordByFilter(
"installed_plugins",
"path={:path}",
dbx.Params{"path": issue.Dir},
)
}
if record == nil {
record = core.NewRecord(collection)
record.Set("plugin_id", recordID)
}
record.Set("name", issue.Name)
record.Set("type", PluginTypeTrails)
record.Set("version", "unknown")
record.Set("runtime", RuntimeWASM)
record.Set("path", issue.Dir)
record.Set("manifest", map[string]any{
"id": record.GetString("plugin_id"),
"type": PluginTypeTrails,
"name": issue.Name,
})
record.Set("status", "error")
record.Set("error", issue.Error)
if err := m.App.Save(record); err != nil {
return fmt.Errorf("save plugin setup error %s: %w", issue.ID, err)
}
return nil
}
func pluginIssueRecordID(issue LocalPluginIssue) string {
originalID := strings.TrimSpace(issue.ID)
id := strings.ToLower(originalID)
var builder strings.Builder
for _, r := range id {
switch {
case r >= 'a' && r <= 'z':
builder.WriteRune(r)
case r >= '0' && r <= '9':
builder.WriteRune(r)
case r == '_' || r == '-':
builder.WriteRune(r)
default:
builder.WriteRune('-')
}
}
result := strings.Trim(builder.String(), "-_")
if result == "" {
result = "plugin-setup-error"
}
if originalID != result || !pluginIDPattern.MatchString(result) {
result = strings.Trim(result, "-_")
if result == "" {
result = "plugin-setup-error"
}
result = result + "-" + pluginIssueHash(issue)
}
if len(result) > 128 {
hash := pluginIssueHash(issue)
prefixLength := 128 - len(hash) - 1
result = strings.Trim(result[:prefixLength], "-_") + "-" + hash
}
if pluginIDPattern.MatchString(result) {
return result
}
return "plugin-setup-error-" + pluginIssueHash(issue)
}
func pluginIssueHash(issue LocalPluginIssue) string {
hash := fnv.New32a()
_, _ = hash.Write([]byte(issue.Dir))
_, _ = hash.Write([]byte{0})
_, _ = hash.Write([]byte(issue.ID))
return fmt.Sprintf("%08x", hash.Sum32())
}
func marshalManifest(manifest Manifest) (map[string]any, error) {
data, err := json.Marshal(manifest)
if err != nil {
return nil, err
}
var result map[string]any
if err := json.Unmarshal(data, &result); err != nil {
return nil, err
}
return result, nil
}
func defaultConfig(manifest Manifest) map[string]any {
hostConfig, _ := CloneJSONValue(manifest.HostConfig).(map[string]any)
if hostConfig == nil {
hostConfig = map[string]any{}
}
config := map[string]any{
"host": hostConfig,
}
pluginConfig := map[string]any{}
for _, field := range manifest.ConfigSchema {
if field.Key == "" || field.Default == nil {
continue
}
pluginConfig[field.Key] = CloneJSONValue(field.Default)
}
config["plugin"] = pluginConfig
return config
}
func mergeDefaultConfig(defaults map[string]any, current map[string]any) map[string]any {
if len(defaults) == 0 {
return current
}
merged := CloneJSONMap(defaults)
MergePluginConfig(merged, current)
return merged
}
func MergePluginConfig(dst map[string]any, src map[string]any) {
DeepMergeConfigWithReplaceKeys(dst, src, map[string]bool{
"categoryMapping": true,
})
}
func capabilityNames(capabilities []CapabilityManifest) []string {
names := make([]string, 0, len(capabilities))
for _, capability := range capabilities {
names = append(names, capability.Name+"."+capability.Version)
}
return names
}

View File

@@ -0,0 +1,21 @@
package pluginsystem
import "testing"
func TestPluginIssueRecordID(t *testing.T) {
valid := pluginIssueRecordID(LocalPluginIssue{ID: "komoot", Dir: "/plugins/komoot"})
if valid != "komoot" {
t.Fatalf("pluginIssueRecordID(valid) = %q, want komoot", valid)
}
first := pluginIssueRecordID(LocalPluginIssue{ID: "@@@", Dir: "/plugins/@@@"})
second := pluginIssueRecordID(LocalPluginIssue{ID: "***", Dir: "/plugins/***"})
if first == second {
t.Fatalf("invalid plugin issue ids collided: %q", first)
}
for _, got := range []string{first, second} {
if !pluginIDPattern.MatchString(got) {
t.Fatalf("pluginIssueRecordID() = %q, not a valid plugin id", got)
}
}
}

305
db/pluginsystem/manifest.go Normal file
View File

@@ -0,0 +1,305 @@
package pluginsystem
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
)
const (
DefaultPluginDir = "/data/plugins"
)
var pluginIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`)
var ErrUnsupportedPluginType = errors.New("unsupported plugin type")
type LocalPlugin struct {
Manifest Manifest `json:"manifest"`
Dir string `json:"dir"`
WASMPath string `json:"wasmPath"`
}
// PluginDir resolves the runtime plugin directory. Production containers mount
// plugins at /data/plugins; source checkouts usually stage them at data/plugins
// and may start PocketBase either from the repo root or from db/.
func PluginDir() string {
for _, candidate := range []string{
DefaultPluginDir,
"data/plugins",
filepath.Join("..", "data", "plugins"),
} {
if info, err := os.Stat(candidate); err == nil && info.IsDir() {
return candidate
}
}
return DefaultPluginDir
}
// LoadLocalPlugins reads direct child directories from the plugin directory and
// returns every valid bundle. Invalid direct children are ignored by this
// compatibility helper; callers that need UI-visible errors should use
// DiscoverLocalPlugins.
func LoadLocalPlugins(dir string) ([]LocalPlugin, error) {
plugins, _, err := DiscoverLocalPlugins(dir)
return plugins, err
}
type LocalPluginIssue struct {
ID string
Name string
Dir string
Error string
}
// DiscoverLocalPlugins reads direct child directories from the plugin directory
// and returns valid bundles plus per-directory load issues.
func DiscoverLocalPlugins(dir string) ([]LocalPlugin, []LocalPluginIssue, error) {
if dir == "" {
dir = PluginDir()
}
if _, err := os.Stat(dir); err != nil {
if os.IsNotExist(err) {
return []LocalPlugin{}, nil, nil
}
return nil, nil, err
}
plugins := make([]LocalPlugin, 0)
issues := make([]LocalPluginIssue, 0)
seen := map[string]bool{}
entries, err := os.ReadDir(dir)
if err != nil {
return nil, nil, err
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
pluginDir := filepath.Join(dir, entry.Name())
plugin, err := LoadLocalPlugin(pluginDir)
if err != nil {
if errors.Is(err, ErrUnsupportedPluginType) {
continue
}
issues = append(issues, LocalPluginIssue{
ID: entry.Name(),
Name: entry.Name(),
Dir: pluginDir,
Error: fmt.Sprintf("%s: %v", entry.Name(), err),
})
continue
}
if seen[plugin.Manifest.ID] {
continue
}
seen[plugin.Manifest.ID] = true
plugins = append(plugins, *plugin)
}
return plugins, issues, nil
}
// LoadLocalPlugin reads one plugin bundle, validates its manifest, and resolves
// the WASM entrypoint relative to the plugin directory.
func LoadLocalPlugin(dir string) (*LocalPlugin, error) {
manifestPath := filepath.Join(dir, "plugin.json")
data, err := os.ReadFile(manifestPath)
if err != nil {
return nil, err
}
var manifest Manifest
if err := json.Unmarshal(data, &manifest); err != nil {
return nil, fmt.Errorf("parse plugin.json: %w", err)
}
if err := ValidateManifest(manifest); err != nil {
return nil, err
}
entrypoint := filepath.Clean(manifest.Runtime.Entrypoint)
if filepath.IsAbs(entrypoint) || strings.HasPrefix(entrypoint, ".."+string(filepath.Separator)) || entrypoint == ".." {
return nil, fmt.Errorf("runtime entrypoint must be relative to plugin directory")
}
wasmPath := filepath.Join(dir, entrypoint)
if _, err := os.Stat(wasmPath); err != nil {
return nil, fmt.Errorf("runtime entrypoint: %w", err)
}
return &LocalPlugin{
Manifest: manifest,
Dir: dir,
WASMPath: wasmPath,
}, nil
}
// ValidateManifest checks the static contract that is trusted by install,
// runtime policy enforcement, auth handling, and the UI.
func ValidateManifest(manifest Manifest) error {
if manifest.ManifestVersion == "" {
return fmt.Errorf("manifestVersion is required")
}
if majorVersion(manifest.ManifestVersion) != majorVersion(ManifestVersion) {
return fmt.Errorf("unsupported manifestVersion %q", manifest.ManifestVersion)
}
if !pluginIDPattern.MatchString(manifest.ID) {
return fmt.Errorf("id must match %s", pluginIDPattern.String())
}
if manifest.Type != PluginTypeTrails {
return fmt.Errorf("%w: type must be %q", ErrUnsupportedPluginType, PluginTypeTrails)
}
if strings.TrimSpace(manifest.Name) == "" {
return fmt.Errorf("name is required")
}
if strings.TrimSpace(manifest.Version) == "" {
return fmt.Errorf("version is required")
}
if manifest.Runtime.Type != RuntimeWASM {
return fmt.Errorf("runtime.type must be %q", RuntimeWASM)
}
if strings.TrimSpace(manifest.Runtime.Entrypoint) == "" {
return fmt.Errorf("runtime.entrypoint is required")
}
if len(manifest.Capabilities) == 0 {
return fmt.Errorf("at least one capability is required")
}
if err := validateCapabilities(manifest.Capabilities); err != nil {
return err
}
if err := validateAuth(manifest.Auth); err != nil {
return err
}
if err := validatePermissions(manifest.Permissions, manifest.Auth); err != nil {
return err
}
return nil
}
func validateCapabilities(capabilities []CapabilityManifest) error {
seen := map[string]bool{}
for _, capability := range capabilities {
if strings.TrimSpace(capability.Name) == "" {
return fmt.Errorf("capability name is required")
}
if strings.TrimSpace(capability.Version) == "" {
return fmt.Errorf("capability %s version is required", capability.Name)
}
if strings.TrimSpace(capability.Export) == "" {
return fmt.Errorf("capability %s export is required", capability.Name)
}
key := capability.Name + "." + capability.Version
if seen[key] {
return fmt.Errorf("duplicate capability %s", key)
}
seen[key] = true
}
return nil
}
func validateAuth(auth AuthManifest) error {
for name, context := range auth.Contexts {
if strings.TrimSpace(name) == "" {
return fmt.Errorf("auth context name is required")
}
if err := ValidateAuthContext(name, context); err != nil {
return err
}
}
return nil
}
func validatePermissions(permissions PermissionManifest, auth AuthManifest) error {
authContexts := map[string]bool{}
for name := range auth.Contexts {
authContexts[name] = true
}
for _, authRef := range permissions.Auth {
if !authContexts[authRef] {
return fmt.Errorf("permission references unknown auth context %q", authRef)
}
}
if err := validateConnectors(permissions.Network.Connectors, authContexts); err != nil {
return err
}
for _, host := range permissions.Network.Redirects.Hosts {
if err := validateHost(host); err != nil {
return err
}
}
if permissions.Network.Redirects.Mode != "" && permissions.Network.Redirects.Mode != "declared_hosts_only" {
return fmt.Errorf("unsupported redirect mode %q", permissions.Network.Redirects.Mode)
}
if permissions.Downloads.MaxBytes < 0 || permissions.Uploads.MaxBytes < 0 {
return fmt.Errorf("maxBytes must not be negative")
}
return nil
}
func validateConnectors(connectors []ConnectorTargetPermission, authContexts map[string]bool) error {
seen := map[string]bool{}
for _, connector := range connectors {
if strings.TrimSpace(connector.Name) == "" {
return fmt.Errorf("connector name is required")
}
if seen[connector.Name] {
return fmt.Errorf("duplicate connector %q", connector.Name)
}
seen[connector.Name] = true
switch connector.Type {
case ConnectorTypePublicAPI:
if strings.TrimSpace(connector.FixedBaseURL) == "" {
return fmt.Errorf("public_api connector %q requires fixedBaseURL", connector.Name)
}
if strings.TrimSpace(connector.ConfigKey) != "" {
return fmt.Errorf("public_api connector %q must not declare configKey", connector.Name)
}
if _, _, err := NormalizeConnectorBase(connector.FixedBaseURL, ""); err != nil {
return fmt.Errorf("connector %q fixedBaseURL: %w", connector.Name, err)
}
case ConnectorTypeConfigured:
if strings.TrimSpace(connector.ConfigKey) == "" {
return fmt.Errorf("configured connector %q requires configKey", connector.Name)
}
if strings.TrimSpace(connector.FixedBaseURL) != "" {
return fmt.Errorf("configured connector %q must not declare fixedBaseURL", connector.Name)
}
default:
return fmt.Errorf("connector %q has unsupported type %q", connector.Name, connector.Type)
}
for _, authRef := range connector.Auth {
if !authContexts[authRef] {
return fmt.Errorf("connector %q references unknown auth context %q", connector.Name, authRef)
}
}
for _, prefix := range connector.AllowedPathPrefixes {
if _, err := CanonicalURLPath(prefix); err != nil {
return fmt.Errorf("connector %q path prefix %q: %w", connector.Name, prefix, err)
}
}
}
return nil
}
func validateHost(host string) error {
host = strings.TrimSpace(host)
if host == "" {
return fmt.Errorf("network host must not be empty")
}
if strings.Contains(host, "://") || strings.Contains(host, "/") {
return fmt.Errorf("network host %q must be a hostname, not a URL", host)
}
return nil
}
func majorVersion(version string) string {
for i, r := range version {
if r == '.' {
return version[:i]
}
}
return version
}

View File

@@ -0,0 +1,222 @@
package pluginsystem
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestValidateManifestAcceptsHammerheadShape(t *testing.T) {
manifest := hammerheadManifestForTest()
if err := ValidateManifest(manifest); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestValidateManifestRejectsUnknownAuthPermission(t *testing.T) {
manifest := hammerheadManifestForTest()
manifest.Permissions.Auth = []string{"missing"}
if err := ValidateManifest(manifest); err == nil {
t.Fatal("expected error")
}
}
func TestLoadLocalPluginRequiresRelativeEntrypoint(t *testing.T) {
dir := t.TempDir()
manifest := hammerheadManifestForTest()
manifest.Runtime.Entrypoint = "/tmp/plugin.wasm"
writeManifest(t, dir, manifest)
if _, err := LoadLocalPlugin(dir); err == nil {
t.Fatal("expected error")
}
}
func TestLoadLocalPluginsSkipsMissingPluginDir(t *testing.T) {
plugins, err := LoadLocalPlugins(filepath.Join(t.TempDir(), "missing"))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(plugins) != 0 {
t.Fatalf("got %d plugins, want 0", len(plugins))
}
}
func TestLoadLocalPluginsFindsDirectChildPlugins(t *testing.T) {
root := t.TempDir()
writePluginDir(t, root, "hammerhead")
writePluginDir(t, root, "komoot")
plugins, err := LoadLocalPlugins(root)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(plugins) != 2 {
t.Fatalf("got %d plugins, want 2", len(plugins))
}
}
func TestDiscoverLocalPluginsReportsMissingManifest(t *testing.T) {
root := t.TempDir()
brokenDir := filepath.Join(root, "komoot")
if err := os.MkdirAll(brokenDir, 0o700); err != nil {
t.Fatalf("mkdir plugin dir: %v", err)
}
plugins, issues, err := DiscoverLocalPlugins(root)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(plugins) != 0 {
t.Fatalf("got %d plugins, want 0", len(plugins))
}
if len(issues) != 1 {
t.Fatalf("got %d issues, want 1", len(issues))
}
if issues[0].ID != "komoot" || issues[0].Name != "komoot" || issues[0].Dir != brokenDir {
t.Fatalf("unexpected issue: %#v", issues[0])
}
if issues[0].Error == "" || !strings.Contains(issues[0].Error, "plugin.json") {
t.Fatalf("expected useful plugin.json error, got %#v", issues[0])
}
}
func TestLoadLocalPluginsIgnoresMissingManifestForCompatibility(t *testing.T) {
root := t.TempDir()
if err := os.MkdirAll(filepath.Join(root, "komoot"), 0o700); err != nil {
t.Fatalf("mkdir plugin dir: %v", err)
}
plugins, err := LoadLocalPlugins(root)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(plugins) != 0 {
t.Fatalf("got %d plugins, want 0", len(plugins))
}
}
func TestLoadLocalPluginsSkipsUnsupportedPluginTypes(t *testing.T) {
root := t.TempDir()
writePluginDir(t, root, "hammerhead")
assetsDir := filepath.Join(root, "immich")
if err := os.MkdirAll(assetsDir, 0o700); err != nil {
t.Fatalf("mkdir plugin dir: %v", err)
}
manifest := hammerheadManifestForTest()
manifest.ID = "immich"
manifest.Name = "Immich"
manifest.Type = "assets"
writeManifest(t, assetsDir, manifest)
if err := os.WriteFile(filepath.Join(assetsDir, "plugin.wasm"), []byte("wasm"), 0o600); err != nil {
t.Fatalf("write wasm: %v", err)
}
plugins, err := LoadLocalPlugins(root)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(plugins) != 1 {
t.Fatalf("got %d plugins, want 1", len(plugins))
}
if plugins[0].Manifest.ID != "hammerhead" {
t.Fatalf("got plugin %q, want hammerhead", plugins[0].Manifest.ID)
}
}
func TestLoadLocalPluginsDoesNotSearchRecursively(t *testing.T) {
root := t.TempDir()
writePluginDir(t, filepath.Join(root, "nested"), "hammerhead")
plugins, err := LoadLocalPlugins(root)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(plugins) != 0 {
t.Fatalf("got %d plugins, want 0", len(plugins))
}
}
func hammerheadManifestForTest() Manifest {
return Manifest{
ManifestVersion: ManifestVersion,
ID: "hammerhead",
Type: PluginTypeTrails,
Name: "Hammerhead",
Version: "0.1.0",
Runtime: RuntimeManifest{
Type: RuntimeWASM,
Entrypoint: "plugin.wasm",
},
Capabilities: []CapabilityManifest{
{Name: "prepare_trail_send", Version: "v1", Export: "prepare_trail_send_v1"},
},
Auth: AuthManifest{
Contexts: map[string]AuthContext{
"provider_session": {
Type: AuthTypeSession,
SecretFields: []string{"email", "password"},
Refresh: &AuthRefresh{
Mode: AuthRefreshModePlugin,
Function: "refresh_session_v1",
},
},
},
},
Permissions: PermissionManifest{
Network: NetworkPermissions{
Connectors: []ConnectorTargetPermission{{
Name: "api",
Type: ConnectorTypePublicAPI,
FixedBaseURL: "https://dashboard.hammerhead.io",
AllowedPathPrefixes: []string{"/v1"},
Auth: []string{"provider_session"},
}},
},
Auth: []string{"provider_session"},
Uploads: UploadPermissions{
MaxBytes: 10 << 20,
ContentTypes: []string{"application/gpx+xml", "application/xml"},
},
},
}
}
func writeManifest(t *testing.T, dir string, manifest Manifest) {
t.Helper()
data := []byte(`{
"manifestVersion": "1.0",
"id": "` + manifest.ID + `",
"type": "` + manifest.Type + `",
"name": "` + manifest.Name + `",
"version": "` + manifest.Version + `",
"runtime": {
"type": "` + manifest.Runtime.Type + `",
"entrypoint": "` + manifest.Runtime.Entrypoint + `"
},
"capabilities": [
{"name": "prepare_trail_send", "version": "v1", "export": "prepare_trail_send_v1"}
]
}`)
if err := os.WriteFile(filepath.Join(dir, "plugin.json"), data, 0o600); err != nil {
t.Fatalf("write manifest: %v", err)
}
}
func writePluginDir(t *testing.T, root string, id string) {
t.Helper()
dir := filepath.Join(root, id)
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatalf("mkdir plugin dir: %v", err)
}
manifest := hammerheadManifestForTest()
manifest.ID = id
manifest.Name = id
writeManifest(t, dir, manifest)
if err := os.WriteFile(filepath.Join(dir, "plugin.wasm"), []byte("wasm"), 0o600); err != nil {
t.Fatalf("write wasm: %v", err)
}
}

335
db/pluginsystem/oauth.go Normal file
View File

@@ -0,0 +1,335 @@
package pluginsystem
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"slices"
"strings"
"time"
"github.com/pocketbase/pocketbase/core"
)
const (
AuthFieldOAuthContext = "oauthContext"
AuthFieldTokenType = "tokenType"
AuthFieldExpiresAt = "expiresAt"
AuthFieldScope = "scope"
)
type OAuthTokenResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token,omitempty"`
TokenType string `json:"token_type,omitempty"`
ExpiresIn int `json:"expires_in,omitempty"`
Scope string `json:"scope,omitempty"`
Raw json.RawMessage `json:"-"`
}
// OAuthContext selects the OAuth auth context declared by a plugin. When the UI
// does not request a specific context, the first context by name is used.
func OAuthContext(plugin LocalPlugin, requested string) (string, AuthContext, error) {
names := make([]string, 0, len(plugin.Manifest.Auth.Contexts))
for name := range plugin.Manifest.Auth.Contexts {
names = append(names, name)
}
slices.Sort(names)
for _, name := range names {
context := plugin.Manifest.Auth.Contexts[name]
if requested != "" && requested != name {
continue
}
if context.Type == AuthTypeOAuth2 {
return name, context, nil
}
}
return "", AuthContext{}, fmt.Errorf("plugin has no oauth auth context")
}
// ValidateOAuthRedirectURI accepts only the frontend plugin OAuth callback and,
// when ORIGIN is configured, requires the same external origin.
func ValidateOAuthRedirectURI(raw string) error {
redirectURL, err := url.Parse(raw)
if err != nil {
return err
}
if redirectURL.Scheme != "http" && redirectURL.Scheme != "https" {
return fmt.Errorf("redirect uri scheme must be http or https")
}
if redirectURL.Host == "" {
return fmt.Errorf("redirect uri must be absolute")
}
if redirectURL.Path != "/settings/plugins/oauth/callback" {
return fmt.Errorf("redirect uri path is not allowed")
}
if origin := strings.TrimRight(os.Getenv("ORIGIN"), "/"); origin != "" {
originURL, err := url.Parse(origin)
if err != nil {
return err
}
if !strings.EqualFold(redirectURL.Scheme, originURL.Scheme) || !strings.EqualFold(redirectURL.Host, originURL.Host) {
return fmt.Errorf("redirect uri origin does not match ORIGIN")
}
}
return nil
}
func NewOAuthState(size int) string {
return randomURLToken(size)
}
func NewOAuthCodeVerifier(size int) string {
return randomURLToken(size)
}
func PKCEChallenge(verifier string) string {
hash := sha256.Sum256([]byte(verifier))
return base64.RawURLEncoding.EncodeToString(hash[:])
}
// ExchangeOAuthToken performs the host-owned OAuth token exchange or refresh.
// The token endpoint must be allowed by the plugin manifest network policy.
func ExchangeOAuthToken(ctx context.Context, manifest Manifest, authContext AuthContext, auth map[string]any, values map[string]string) (*OAuthTokenResponse, error) {
tokenURL, err := url.Parse(authContext.TokenURL)
if err != nil {
return nil, err
}
if tokenURL.Scheme != "http" && tokenURL.Scheme != "https" {
return nil, fmt.Errorf("oauth token url scheme must be http or https")
}
if !OAuthTokenURLAllowed(manifest, tokenURL) {
return nil, fmt.Errorf("oauth token host %q is not allowed by manifest permissions", tokenURL.Hostname())
}
clientID := StringFromAny(auth["clientId"])
clientSecret := StringFromAny(auth[AuthFieldClientSecret])
if clientID == "" {
return nil, fmt.Errorf("clientId is required")
}
bodyValues := url.Values{}
for key, value := range values {
if value != "" {
bodyValues.Set(key, value)
}
}
bodyValues.Set("client_id", clientID)
if authContext.TokenAuth == "" || authContext.TokenAuth == TokenAuthClientSecretPost {
if clientSecret != "" {
bodyValues.Set("client_secret", clientSecret)
}
}
var body []byte
contentType := "application/x-www-form-urlencoded"
if authContext.TokenRequestFormat == TokenRequestFormatJSON {
jsonBody := map[string]string{}
for key, value := range bodyValues {
if len(value) > 0 {
jsonBody[key] = value[0]
}
}
var err error
body, err = json.Marshal(jsonBody)
if err != nil {
return nil, err
}
contentType = "application/json"
} else {
body = []byte(bodyValues.Encode())
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL.String(), bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", contentType)
req.Header.Set("Accept", "application/json")
if authContext.TokenAuth == TokenAuthClientSecretBasic && clientSecret != "" {
req.SetBasicAuth(clientID, clientSecret)
}
client := &http.Client{
Timeout: 30 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("%s: %s", resp.Status, strings.TrimSpace(string(respBody)))
}
var token OAuthTokenResponse
token.Raw = append([]byte{}, respBody...)
if err := json.Unmarshal(respBody, &token); err != nil {
return nil, err
}
if token.AccessToken == "" {
return nil, fmt.Errorf("oauth token response has no access_token")
}
return &token, nil
}
func OAuthTokenURLAllowed(manifest Manifest, tokenURL *url.URL) bool {
for _, connector := range manifest.Permissions.Network.Connectors {
if connector.Type != ConnectorTypePublicAPI {
continue
}
baseURL, basePath, err := NormalizeConnectorBase(connector.FixedBaseURL, "")
if err != nil {
continue
}
target := ResolvedConnectorTarget{
Name: connector.Name,
Type: connector.Type,
BaseURL: baseURL,
BasePath: basePath,
AllowedPathPrefixes: connector.AllowedPathPrefixes,
}
if err := ValidateConnectorURL(target, tokenURL); err == nil {
return true
}
}
return false
}
// RefreshOAuthToken uses the stored refresh token, persists the refreshed auth
// map, and keeps the plugin instance configured when refresh succeeds.
func RefreshOAuthToken(ctx context.Context, app core.App, plugin LocalPlugin, instance *core.Record, auth map[string]any, contextName string) (map[string]any, error) {
_, authContext, err := OAuthContext(plugin, contextName)
if err != nil {
return auth, err
}
grantType := "refresh_token"
if authContext.Refresh != nil && authContext.Refresh.GrantType != "" {
grantType = authContext.Refresh.GrantType
}
refreshToken := StringFromAny(auth[AuthFieldRefreshToken])
if refreshToken == "" {
return auth, fmt.Errorf("refreshToken is missing")
}
token, err := ExchangeOAuthToken(ctx, plugin.Manifest, authContext, auth, map[string]string{
"grant_type": grantType,
"refresh_token": refreshToken,
})
if err != nil {
return auth, err
}
if token.RefreshToken == "" {
token.RefreshToken = refreshToken
}
StoreOAuthToken(auth, contextName, token)
instance.Set("auth", auth)
instance.Set("status", "configured")
if err := app.Save(instance); err != nil {
return auth, err
}
return auth, nil
}
// StoreOAuthToken normalizes provider token responses into the plugin instance
// auth map used by host injection and future refreshes.
func StoreOAuthToken(auth map[string]any, contextName string, token *OAuthTokenResponse) {
auth[AuthFieldOAuthContext] = contextName
auth[AuthFieldAccessToken] = token.AccessToken
if token.RefreshToken != "" {
auth[AuthFieldRefreshToken] = token.RefreshToken
}
if token.TokenType != "" {
auth[AuthFieldTokenType] = token.TokenType
}
if token.Scope != "" {
auth[AuthFieldScope] = token.Scope
}
if token.ExpiresIn > 0 {
auth[AuthFieldExpiresAt] = time.Now().Add(time.Duration(token.ExpiresIn) * time.Second).UTC().Format(time.RFC3339)
}
}
// ClearOAuthToken removes persisted OAuth token material and transient OAuth
// flow fields from an auth map.
func ClearOAuthToken(auth map[string]any) {
for _, key := range []string{
AuthFieldAccessToken,
AuthFieldRefreshToken,
AuthFieldTokenType,
AuthFieldExpiresAt,
AuthFieldScope,
AuthFieldOAuthState,
AuthFieldOAuthCodeVerifier,
AuthFieldOAuthRedirectURI,
} {
delete(auth, key)
}
}
// PluginInputAuth returns the auth payload visible to plugin exports. OAuth
// token material is intentionally removed because provider requests should go
// through host auth injection instead.
func PluginInputAuth(plugin LocalPlugin, auth map[string]any) map[string]any {
out := map[string]any{}
for key, value := range auth {
out[key] = value
}
for _, context := range plugin.Manifest.Auth.Contexts {
if context.Type == AuthTypeOAuth2 {
for _, key := range PluginInputAuthBlockedFields() {
delete(out, key)
}
}
}
return out
}
// RefreshOAuthAuthIfNeeded refreshes host-managed OAuth before a sync run if no
// access token exists or the current token is close to expiry.
func RefreshOAuthAuthIfNeeded(ctx context.Context, app core.App, plugin LocalPlugin, instance *core.Record, auth map[string]any) (map[string]any, error) {
for name, authContext := range plugin.Manifest.Auth.Contexts {
if authContext.Type != AuthTypeOAuth2 {
continue
}
if StringFromAny(auth[AuthFieldAccessToken]) == "" || OAuthNeedsRefresh(auth) {
return RefreshOAuthToken(ctx, app, plugin, instance, auth, name)
}
}
return auth, nil
}
func OAuthNeedsRefresh(auth map[string]any) bool {
expiresAt := StringFromAny(auth[AuthFieldExpiresAt])
if expiresAt == "" {
return false
}
parsed, err := time.Parse(time.RFC3339, expiresAt)
if err != nil {
return false
}
return time.Until(parsed) < time.Minute
}
func StringFromAny(value any) string {
text, _ := value.(string)
return strings.TrimSpace(text)
}
func randomURLToken(size int) string {
data := make([]byte, size)
if _, err := rand.Read(data); err != nil {
panic(err)
}
return base64.RawURLEncoding.EncodeToString(data)
}

417
db/pluginsystem/policy.go Normal file
View File

@@ -0,0 +1,417 @@
package pluginsystem
import (
"fmt"
"net/url"
"path"
"slices"
"strings"
)
const (
ConnectorTypePublicAPI = "public_api"
ConnectorTypeConfigured = "configured"
TLSModeSystem = "system"
TLSModeCustomCA = "customCA"
)
type RequestPolicyContext struct {
Connectors map[string]ResolvedConnectorTarget
HostAuth map[string]any
}
func (p RequestPolicyContext) WithHostAuth(auth map[string]any) RequestPolicyContext {
p.HostAuth = auth
return p
}
type ResolvedConnectorTarget struct {
Name string
Type string
BaseURL string
BasePath string
AllowPrivate bool
TLS ConnectorTLSConfig
StorageOrigins map[string]ResolvedConnectorOrigin
AllowedPathPrefixes []string
Auth []string
SupportsMediaAuth bool
SupportsStorageRedirects bool
SupportsCustomTLS bool
}
type ConnectorTLSConfig struct {
Mode string
CABundle []byte
}
type ResolvedConnectorOrigin struct {
Name string
BaseURL string
BasePath string
AllowPrivate bool
TLS ConnectorTLSConfig
}
type ResolvedRequestTarget struct {
URL *url.URL
Connector ResolvedConnectorTarget
}
// ValidateHostRequestSpec checks the static manifest policy before the host
// performs any plugin-controlled HTTP request. Provider traffic must use a
// connector target; plugins no longer hand the host absolute API URLs.
func ValidateHostRequestSpec(manifest Manifest, spec HostRequestSpec, policy RequestPolicyContext) error {
_, err := ValidateAndResolveHostRequestSpec(manifest, spec, policy)
return err
}
func ValidateAndResolveHostRequestSpec(manifest Manifest, spec HostRequestSpec, policy RequestPolicyContext) (*ResolvedRequestTarget, error) {
if strings.TrimSpace(spec.Method) == "" {
return nil, fmt.Errorf("method is required")
}
resolved, err := ResolveRequestTarget(manifest, spec.Target, policy)
if err != nil {
return nil, err
}
if spec.Auth != "" {
if err := ValidateAuthReference(manifest, spec.Auth); err != nil {
return nil, err
}
if len(resolved.Connector.Auth) > 0 && !slices.Contains(resolved.Connector.Auth, spec.Auth) {
return nil, fmt.Errorf("auth context %q is not permitted for connector %q", spec.Auth, resolved.Connector.Name)
}
}
if err := validateExpectedResponse(spec.Expect, manifest.Permissions.Downloads); err != nil {
return nil, err
}
return resolved, nil
}
func ValidateAuthReference(manifest Manifest, auth string) error {
if _, ok := manifest.Auth.Contexts[auth]; !ok {
return fmt.Errorf("auth context %q is not declared", auth)
}
if !slices.Contains(manifest.Permissions.Auth, auth) {
return fmt.Errorf("auth context %q is not permitted", auth)
}
return nil
}
func ResolveRequestTarget(manifest Manifest, target RequestTarget, policy RequestPolicyContext) (*ResolvedRequestTarget, error) {
if target.Type != "connector" {
return nil, fmt.Errorf("request target type must be connector")
}
connector, ok := policy.Connectors[target.Connector]
if !ok {
return nil, fmt.Errorf("connector %q is not configured", target.Connector)
}
manifestConnector, ok := manifestConnector(manifest, target.Connector)
if !ok {
return nil, fmt.Errorf("connector %q is not declared by manifest", target.Connector)
}
connector.AllowedPathPrefixes = canonicalConnectorPrefixes(manifestConnector.AllowedPathPrefixes)
connector.Auth = manifestConnector.Auth
connector.SupportsMediaAuth = manifestConnector.SupportsMediaAuth
connector.SupportsStorageRedirects = manifestConnector.SupportsStorageRedirects
connector.SupportsCustomTLS = manifestConnector.SupportsCustomTLS
built, err := BuildConnectorURL(connector, target.Path, target.Query)
if err != nil {
return nil, err
}
if err := ValidateConnectorURL(connector, built); err != nil {
return nil, err
}
return &ResolvedRequestTarget{URL: built, Connector: connector}, nil
}
func manifestConnector(manifest Manifest, name string) (ConnectorTargetPermission, bool) {
for _, connector := range manifest.Permissions.Network.Connectors {
if connector.Name == name {
return connector, true
}
}
return ConnectorTargetPermission{}, false
}
func BuildConnectorURL(connector ResolvedConnectorTarget, relPath string, query []QueryParam) (*url.URL, error) {
base, err := url.Parse(connector.BaseURL)
if err != nil || base.Scheme == "" || base.Host == "" {
return nil, fmt.Errorf("connector %q has invalid baseURL", connector.Name)
}
if base.RawQuery != "" || base.Fragment != "" {
return nil, fmt.Errorf("connector %q baseURL must not include query or fragment", connector.Name)
}
if base.Scheme != "http" && base.Scheme != "https" {
return nil, fmt.Errorf("connector %q scheme must be http or https", connector.Name)
}
base.Path = ""
base.RawPath = ""
cleanBase, err := CanonicalURLPath(connector.BasePath)
if err != nil {
return nil, fmt.Errorf("connector %q basePath: %w", connector.Name, err)
}
cleanRel, err := CanonicalRelativeURLPath(relPath)
if err != nil {
return nil, err
}
fullPath := joinURLPaths(cleanBase, cleanRel)
if strings.HasSuffix(cleanRel, "/") && fullPath != "/" {
fullPath += "/"
}
base.Path = fullPath
encodedQuery := make([]string, 0, len(query))
for _, param := range query {
if hasControl(param.Name) || hasControl(param.Value) {
return nil, fmt.Errorf("query parameters must not contain control characters")
}
if param.Name == "" {
return nil, fmt.Errorf("query parameter name must not be empty")
}
encodedQuery = append(encodedQuery, url.QueryEscape(param.Name)+"="+url.QueryEscape(param.Value))
}
base.RawQuery = strings.Join(encodedQuery, "&")
return base, nil
}
func ValidateConnectorURL(connector ResolvedConnectorTarget, candidate *url.URL) error {
base, err := url.Parse(connector.BaseURL)
if err != nil {
return err
}
if !strings.EqualFold(candidate.Scheme, base.Scheme) {
return fmt.Errorf("connector request scheme escaped scope")
}
if !strings.EqualFold(candidate.Hostname(), base.Hostname()) {
return fmt.Errorf("connector request host escaped scope")
}
if effectivePort(candidate) != effectivePort(base) {
return fmt.Errorf("connector request port escaped scope")
}
candidatePath, err := CanonicalURLPath(candidate.EscapedPath())
if err != nil {
return err
}
basePath, err := CanonicalURLPath(connector.BasePath)
if err != nil {
return err
}
if !pathInPrefix(candidatePath, subtreePrefix(basePath)) {
return fmt.Errorf("connector request escaped base path")
}
prefixes := connector.AllowedPathPrefixes
if len(prefixes) == 0 {
prefixes = []string{"/"}
}
for _, prefix := range canonicalConnectorPrefixes(prefixes) {
fullPrefix := subtreePrefix(joinURLPaths(basePath, prefix))
if pathInPrefix(candidatePath, fullPrefix) {
return nil
}
}
return fmt.Errorf("connector request path is not allowed")
}
func ValidateConnectorRedirect(connector ResolvedConnectorTarget, initial *url.URL, redirected *url.URL) error {
if initial.Scheme == "https" && redirected.Scheme == "http" {
return fmt.Errorf("connector redirect downgrades https to http")
}
return ValidateConnectorURL(connector, redirected)
}
func ValidateConnectorStorageRedirect(connector ResolvedConnectorTarget, initial *url.URL, redirected *url.URL) error {
_, err := ConnectorStorageRedirectOrigin(connector, initial, redirected)
return err
}
func ConnectorStorageRedirectOrigin(connector ResolvedConnectorTarget, initial *url.URL, redirected *url.URL) (ResolvedConnectorOrigin, error) {
if !connector.SupportsStorageRedirects {
return ResolvedConnectorOrigin{}, fmt.Errorf("connector storage redirects are not supported")
}
if initial.Scheme == "https" && redirected.Scheme == "http" {
return ResolvedConnectorOrigin{}, fmt.Errorf("connector storage redirect downgrades https to http")
}
for _, origin := range connector.StorageOrigins {
target := ResolvedConnectorTarget{
Name: origin.Name,
BaseURL: origin.BaseURL,
BasePath: origin.BasePath,
AllowPrivate: origin.AllowPrivate,
TLS: origin.TLS,
AllowedPathPrefixes: []string{"/"},
}
if err := ValidateConnectorURL(target, redirected); err == nil {
return origin, nil
}
}
return ResolvedConnectorOrigin{}, fmt.Errorf("connector storage redirect target is not allowed")
}
func NormalizeConnectorBase(rawURL string, extraBasePath string) (string, string, error) {
parsed, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return "", "", fmt.Errorf("connector baseURL is invalid")
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return "", "", fmt.Errorf("connector baseURL scheme must be http or https")
}
if parsed.RawQuery != "" || parsed.Fragment != "" || parsed.User != nil {
return "", "", fmt.Errorf("connector baseURL must not include credentials, query, or fragment")
}
basePath := parsed.EscapedPath()
if extraBasePath != "" {
basePath = joinURLPaths(basePath, extraBasePath)
}
cleanPath, err := CanonicalURLPath(basePath)
if err != nil {
return "", "", err
}
parsed.Path = ""
parsed.RawPath = ""
return parsed.String(), cleanPath, nil
}
func CanonicalRelativeURLPath(rawPath string) (string, error) {
if strings.TrimSpace(rawPath) == "" {
return "/", nil
}
if strings.HasPrefix(rawPath, "http://") || strings.HasPrefix(rawPath, "https://") || strings.HasPrefix(rawPath, "//") {
return "", fmt.Errorf("connector path must be relative")
}
cleaned, err := CanonicalURLPath("/" + strings.TrimLeft(rawPath, "/"))
if err != nil {
return "", err
}
if strings.HasSuffix(rawPath, "/") && cleaned != "/" {
cleaned += "/"
}
return cleaned, nil
}
func CanonicalURLPath(rawPath string) (string, error) {
if rawPath == "" {
rawPath = "/"
}
if hasControl(rawPath) {
return "", fmt.Errorf("path must not contain control characters")
}
lower := strings.ToLower(rawPath)
if strings.Contains(lower, "%2f") || strings.Contains(lower, "%5c") {
return "", fmt.Errorf("encoded path separators are not allowed")
}
decoded, err := url.PathUnescape(rawPath)
if err != nil {
return "", fmt.Errorf("path has invalid escapes")
}
if strings.Contains(decoded, "\\") {
return "", fmt.Errorf("backslash is not allowed in URL paths")
}
if hasDangerousSecondEscape(decoded) {
return "", fmt.Errorf("ambiguous encoded path is not allowed")
}
cleaned := path.Clean("/" + strings.TrimLeft(decoded, "/"))
if cleaned == "." {
cleaned = "/"
}
return cleaned, nil
}
func canonicalConnectorPrefixes(prefixes []string) []string {
if len(prefixes) == 0 {
return nil
}
canonical := make([]string, 0, len(prefixes))
for _, prefix := range prefixes {
cleaned, err := CanonicalURLPath(prefix)
if err == nil {
canonical = append(canonical, cleaned)
}
}
return canonical
}
func joinURLPaths(left string, right string) string {
if left == "" {
left = "/"
}
if right == "" {
right = "/"
}
joined := path.Join(left, right)
if joined == "." {
return "/"
}
if !strings.HasPrefix(joined, "/") {
joined = "/" + joined
}
return joined
}
func subtreePrefix(prefix string) string {
if prefix == "/" {
return "/"
}
return strings.TrimRight(prefix, "/") + "/"
}
func pathInPrefix(candidate string, prefix string) bool {
if prefix == "/" {
return true
}
candidate = subtreePrefix(candidate)
return strings.HasPrefix(candidate, prefix)
}
func effectivePort(u *url.URL) string {
if port := u.Port(); port != "" {
return port
}
switch u.Scheme {
case "http":
return "80"
case "https":
return "443"
default:
return ""
}
}
func hasControl(value string) bool {
for _, r := range value {
if r < 0x20 || r == 0x7f {
return true
}
}
return false
}
func hasDangerousSecondEscape(value string) bool {
lower := strings.ToLower(value)
for _, marker := range []string{"%2f", "%5c", "%2e"} {
if strings.Contains(lower, marker) {
return true
}
}
return false
}
// validateExpectedResponse lets a plugin request stricter response checks for a
// specific call while preventing it from exceeding manifest download limits.
func validateExpectedResponse(expect ResponseExpect, permissions DownloadPermissions) error {
if expect.MaxBytes < 0 {
return fmt.Errorf("expect.maxBytes must not be negative")
}
if permissions.MaxBytes > 0 && expect.MaxBytes > permissions.MaxBytes {
return fmt.Errorf("expect.maxBytes exceeds manifest download limit")
}
for _, contentType := range expect.ContentTypes {
if len(permissions.ContentTypes) > 0 && !slices.Contains(permissions.ContentTypes, contentType) {
return fmt.Errorf("content type %q is not allowed by manifest permissions", contentType)
}
}
return nil
}

View File

@@ -0,0 +1,169 @@
package pluginsystem
import (
"net/url"
"testing"
)
func TestValidateHostRequestSpecAcceptsConnectorAndAuthReference(t *testing.T) {
manifest := hammerheadManifestForTest()
spec := HostRequestSpec{
Method: "POST",
Target: RequestTarget{
Type: "connector",
Connector: "api",
Path: "/v1/users/123/routes/import/file",
},
Auth: "provider_session",
Expect: ResponseExpect{
ContentTypes: []string{"application/json"},
MaxBytes: 1024,
},
}
manifest.Permissions.Downloads.ContentTypes = append(manifest.Permissions.Downloads.ContentTypes, "application/json")
manifest.Permissions.Downloads.MaxBytes = 2048
if err := ValidateHostRequestSpec(manifest, spec, testPolicy()); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestValidateHostRequestSpecRejectsUnknownConnector(t *testing.T) {
manifest := hammerheadManifestForTest()
spec := HostRequestSpec{
Method: "GET",
Target: RequestTarget{Type: "connector", Connector: "evil", Path: "/v1"},
}
if err := ValidateHostRequestSpec(manifest, spec, testPolicy()); err == nil {
t.Fatal("expected error")
}
}
func TestValidateHostRequestSpecRejectsPathScopeEscape(t *testing.T) {
manifest := hammerheadManifestForTest()
spec := HostRequestSpec{
Method: "GET",
Target: RequestTarget{Type: "connector", Connector: "api", Path: "/v1-evil"},
}
if err := ValidateHostRequestSpec(manifest, spec, testPolicy()); err == nil {
t.Fatal("expected error")
}
}
func TestValidateHostRequestSpecRejectsLimitExpansion(t *testing.T) {
manifest := hammerheadManifestForTest()
manifest.Permissions.Downloads.MaxBytes = 100
spec := HostRequestSpec{
Method: "GET",
Target: RequestTarget{Type: "connector", Connector: "api", Path: "/v1/users"},
Expect: ResponseExpect{MaxBytes: 101},
}
if err := ValidateHostRequestSpec(manifest, spec, testPolicy()); err == nil {
t.Fatal("expected error")
}
}
func TestBuildConnectorURLPreservesBasePathAndQueryOrder(t *testing.T) {
target := ResolvedConnectorTarget{
Name: "immich",
BaseURL: "https://photos.example.test:8443",
BasePath: "/immich",
AllowedPathPrefixes: []string{"/api"},
}
u, err := BuildConnectorURL(target, "/api/assets/1/original", []QueryParam{
{Name: "z", Value: "last"},
{Name: "key", Value: "a"},
{Name: "key", Value: "b"},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if u.String() != "https://photos.example.test:8443/immich/api/assets/1/original?z=last&key=a&key=b" {
t.Fatalf("unexpected url: %s", u.String())
}
if err := ValidateConnectorURL(target, u); err != nil {
t.Fatalf("unexpected scope error: %v", err)
}
}
func TestBuildConnectorURLPreservesTrailingSlash(t *testing.T) {
target := ResolvedConnectorTarget{
Name: "komoot",
BaseURL: "https://api.komoot.de",
BasePath: "/",
AllowedPathPrefixes: []string{"/v006"},
}
u, err := BuildConnectorURL(target, "/v006/account/email/user%40example.test/", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if u.String() != "https://api.komoot.de/v006/account/email/user@example.test/" {
t.Fatalf("unexpected url: %s", u.String())
}
}
func TestConnectorPathNormalizationRejectsAmbiguousEscapes(t *testing.T) {
for _, candidate := range []string{"/api%2fadmin", "/api/%252e%252e/admin", "/api/../admin"} {
t.Run(candidate, func(t *testing.T) {
target := ResolvedConnectorTarget{
Name: "api",
BaseURL: "https://example.test",
BasePath: "/",
AllowedPathPrefixes: []string{"/api"},
}
u, err := BuildConnectorURL(target, candidate, nil)
if err == nil {
err = ValidateConnectorURL(target, u)
}
if err == nil {
t.Fatal("expected scope error")
}
})
}
}
func TestConnectorStorageRedirectOriginReturnsMatchedOriginPolicy(t *testing.T) {
connector := ResolvedConnectorTarget{
Name: "immich",
BaseURL: "https://photos.example.test",
BasePath: "/immich",
SupportsStorageRedirects: true,
StorageOrigins: map[string]ResolvedConnectorOrigin{
"minio": {
Name: "minio",
BaseURL: "https://storage.example.test:9443",
BasePath: "/assets",
AllowPrivate: true,
TLS: ConnectorTLSConfig{Mode: TLSModeCustomCA, CABundle: []byte("ca")},
},
},
}
initial, _ := BuildConnectorURL(connector, "/api/assets/1/original", nil)
redirected, _ := url.Parse("https://storage.example.test:9443/assets/bucket/photo.jpg")
origin, err := ConnectorStorageRedirectOrigin(connector, initial, redirected)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if origin.Name != "minio" || !origin.AllowPrivate || origin.TLS.Mode != TLSModeCustomCA {
t.Fatalf("unexpected origin policy: %#v", origin)
}
}
func testPolicy() RequestPolicyContext {
return RequestPolicyContext{
Connectors: map[string]ResolvedConnectorTarget{
"api": {
Name: "api",
Type: ConnectorTypePublicAPI,
BaseURL: "https://dashboard.hammerhead.io",
BasePath: "/",
AllowedPathPrefixes: []string{"/v1"},
Auth: []string{"provider_session"},
},
},
}
}

212
db/pluginsystem/protocol.go Normal file
View File

@@ -0,0 +1,212 @@
package pluginsystem
const (
ManifestVersion = "1.0"
RuntimeWASM = "wasm"
PluginTypeTrails = "trails"
AuthTypeOAuth2 = "oauth2"
AuthTypeAPIKey = "api_key"
AuthTypeBearer = "bearer"
AuthTypeSession = "session"
AuthRefreshModeHost = "host"
AuthRefreshModePlugin = "plugin"
AuthPlacementQuery = "query"
AuthHeaderAuthorization = "Authorization"
AuthSchemeBearer = "Bearer"
TokenRequestFormatJSON = "json"
TokenAuthClientSecretPost = "client_secret_post"
TokenAuthClientSecretBasic = "client_secret_basic"
HostRequestBodyTypeJSON = "json"
HostRequestBodyTypeForm = "form"
HostRequestBodyTypeMultipart = "multipart"
MultipartSourceTrail = "trail"
MultipartSourceTrailGPX = "trail.gpx"
MultipartTrailFilename = "trail.gpx"
)
type Manifest struct {
ManifestVersion string `json:"manifestVersion"`
ID string `json:"id"`
Type string `json:"type"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Version string `json:"version"`
Runtime RuntimeManifest `json:"runtime"`
Capabilities []CapabilityManifest `json:"capabilities"`
Auth AuthManifest `json:"auth,omitempty"`
Permissions PermissionManifest `json:"permissions,omitempty"`
ConfigSchema []ConfigField `json:"configSchema,omitempty"`
HostConfig map[string]any `json:"hostConfig,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
}
type RuntimeManifest struct {
Type string `json:"type"`
Entrypoint string `json:"entrypoint"`
}
type CapabilityManifest struct {
Name string `json:"name"`
Version string `json:"version"`
Export string `json:"export"`
RequiredFunctions []string `json:"requiredHostFunctions,omitempty"`
Job string `json:"job,omitempty"`
}
type ConfigField struct {
Key string `json:"key"`
Type string `json:"type"`
Label string `json:"label,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
Description string `json:"description,omitempty"`
Descriptions map[string]string `json:"descriptions,omitempty"`
Options []ConfigFieldOption `json:"options,omitempty"`
Default any `json:"default,omitempty"`
Required bool `json:"required,omitempty"`
Hidden bool `json:"hidden,omitempty"`
}
type ConfigFieldOption struct {
Value string `json:"value"`
Label string `json:"label,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
}
type AuthManifest struct {
Contexts map[string]AuthContext `json:"contexts,omitempty"`
}
type AuthContext struct {
Type string `json:"type"`
Fields []string `json:"fields,omitempty"`
AuthorizationURL string `json:"authorizationUrl,omitempty"`
TokenURL string `json:"tokenUrl,omitempty"`
Scopes []string `json:"scopes,omitempty"`
ScopeSeparator string `json:"scopeSeparator,omitempty"`
PKCE bool `json:"pkce,omitempty"`
TokenRequestFormat string `json:"tokenRequestFormat,omitempty"`
TokenAuth string `json:"tokenAuth,omitempty"`
AuthorizationParams map[string]string `json:"authorizationParams,omitempty"`
Refresh *AuthRefresh `json:"refresh,omitempty"`
Placement string `json:"placement,omitempty"`
Name string `json:"name,omitempty"`
SecretField string `json:"secretField,omitempty"`
SecretFields []string `json:"secretFields,omitempty"`
}
type AuthRefresh struct {
Mode string `json:"mode"`
GrantType string `json:"grantType,omitempty"`
Function string `json:"function,omitempty"`
}
type PermissionManifest struct {
Network NetworkPermissions `json:"network,omitempty"`
Auth []string `json:"auth,omitempty"`
Downloads DownloadPermissions `json:"downloads,omitempty"`
Uploads UploadPermissions `json:"uploads,omitempty"`
}
type NetworkPermissions struct {
Connectors []ConnectorTargetPermission `json:"connectors,omitempty"`
Redirects RedirectPermissions `json:"redirects,omitempty"`
}
type ConnectorTargetPermission struct {
Name string `json:"name"`
Type string `json:"type"`
FixedBaseURL string `json:"fixedBaseURL,omitempty"`
ConfigKey string `json:"configKey,omitempty"`
AllowedPathPrefixes []string `json:"allowedPathPrefixes,omitempty"`
Auth []string `json:"auth,omitempty"`
SupportsMediaAuth bool `json:"supportsMediaAuth,omitempty"`
SupportsStorageRedirects bool `json:"supportsStorageRedirects,omitempty"`
SupportsCustomTLS bool `json:"supportsCustomTLS,omitempty"`
}
type RedirectPermissions struct {
Mode string `json:"mode,omitempty"`
Hosts []string `json:"hosts,omitempty"`
}
type DownloadPermissions struct {
MaxBytes int64 `json:"maxBytes,omitempty"`
ContentTypes []string `json:"contentTypes,omitempty"`
}
type UploadPermissions struct {
MaxBytes int64 `json:"maxBytes,omitempty"`
ContentTypes []string `json:"contentTypes,omitempty"`
}
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 TrackTransferPlan struct {
Format string `json:"format"`
Transfer HostRequestSpec `json:"transfer"`
}
type TrailSendPlan struct {
Request HostRequestSpec `json:"request"`
}
type PluginError struct {
Code string `json:"code"`
Message string `json:"message,omitempty"`
RetryAfterSeconds *int `json:"retryAfterSeconds,omitempty"`
}
type HostLogEntry struct {
Level string `json:"level"`
Message string `json:"message"`
}

View File

@@ -0,0 +1,64 @@
package pluginsystem
import (
"context"
"errors"
"fmt"
)
var ErrRuntimeUnavailable = errors.New("plugin runtime is not available")
type Runtime interface {
Call(ctx context.Context, plugin LocalPlugin, export string, input []byte, policy RequestPolicyContext) ([]byte, error)
OpenSession(ctx context.Context, plugin LocalPlugin, policy RequestPolicyContext) (RuntimeSession, error)
}
type RuntimeSession interface {
Call(ctx context.Context, export string, input []byte) ([]byte, error)
Close(ctx context.Context) error
}
type RuntimeRegistry struct {
wasm Runtime
}
// NewRuntimeRegistry wires available runtime implementations behind the common
// Runtime interface.
func NewRuntimeRegistry() *RuntimeRegistry {
return &RuntimeRegistry{
wasm: NewWorkerRuntime(),
}
}
// RuntimeFor selects the runtime declared by a plugin manifest.
func (r *RuntimeRegistry) RuntimeFor(plugin LocalPlugin) (Runtime, error) {
switch plugin.Manifest.Runtime.Type {
case RuntimeWASM:
return r.wasm, nil
default:
return nil, ErrRuntimeUnavailable
}
}
type UnavailableRuntime struct{}
func (UnavailableRuntime) Call(context.Context, LocalPlugin, string, []byte, RequestPolicyContext) ([]byte, error) {
return nil, ErrRuntimeUnavailable
}
func (UnavailableRuntime) OpenSession(context.Context, LocalPlugin, RequestPolicyContext) (RuntimeSession, error) {
return nil, ErrRuntimeUnavailable
}
type PluginCallError struct {
PluginID string
Export string
PluginError PluginError
}
func (e PluginCallError) Error() string {
if e.PluginError.Message == "" {
return fmt.Sprintf("call %s.%s: %s", e.PluginID, e.Export, e.PluginError.Code)
}
return fmt.Sprintf("call %s.%s: %s: %s", e.PluginID, e.Export, e.PluginError.Code, e.PluginError.Message)
}

89
db/pluginsystem/status.go Normal file
View File

@@ -0,0 +1,89 @@
package pluginsystem
import (
"errors"
"fmt"
"strings"
"time"
)
// PluginCapabilityError wraps a plugin-reported error returned inside a
// successful export response, so status mapping can treat it like runtime
// PluginCallError failures.
type PluginCapabilityError struct {
Err *PluginError
}
func (e PluginCapabilityError) Error() string {
if e.Err == nil {
return "plugin error"
}
if e.Err.Message == "" {
return fmt.Sprintf("plugin error %s", e.Err.Code)
}
return fmt.Sprintf("plugin error %s: %s", e.Err.Code, e.Err.Message)
}
// InstanceStatusUpdate contains the normalized status fields that are written
// back to plugin_instances after a failed sync.
type InstanceStatusUpdate struct {
Status string
Code string
Message string
RetryNotBefore *time.Time
}
// InstanceStatusForError converts sync/runtime errors into the persisted
// plugin_instances status fields used by the UI and cron backoff logic.
func InstanceStatusForError(err error, now time.Time) InstanceStatusUpdate {
var capabilityErr PluginCapabilityError
var callErr PluginCallError
if errors.As(err, &capabilityErr) && capabilityErr.Err != nil {
return InstanceStatusForPluginError(*capabilityErr.Err, now)
}
if errors.As(err, &callErr) {
return InstanceStatusForPluginError(callErr.PluginError, now)
}
return InstanceStatusUpdate{
Status: "error",
Code: "provider_unavailable",
Message: err.Error(),
}
}
// InstanceStatusForPluginError maps the stable plugin error codes from the ABI
// to host instance states. retryAfterSeconds wins over default retry windows.
func InstanceStatusForPluginError(pluginErr PluginError, now time.Time) InstanceStatusUpdate {
code := strings.TrimSpace(pluginErr.Code)
if code == "" {
code = "provider_unavailable"
}
message := strings.TrimSpace(pluginErr.Message)
if message == "" {
message = code
}
status := "error"
switch code {
case "auth_failed", "invalid_grant", "unauthorized":
status = "needs_reauth"
case "rate_limited":
status = "rate_limited"
case "provider_unavailable", "temporary_unavailable":
status = "unavailable"
}
update := InstanceStatusUpdate{
Status: status,
Code: code,
Message: message,
}
if pluginErr.RetryAfterSeconds != nil && *pluginErr.RetryAfterSeconds > 0 {
retryNotBefore := now.Add(time.Duration(*pluginErr.RetryAfterSeconds) * time.Second)
update.RetryNotBefore = &retryNotBefore
} else if code == "rate_limited" {
retryNotBefore := now.Add(time.Hour)
update.RetryNotBefore = &retryNotBefore
}
return update
}

View File

@@ -0,0 +1,59 @@
package pluginsystem
import (
"errors"
"testing"
"time"
)
func TestInstanceStatusForPluginCapabilityError(t *testing.T) {
now := time.Date(2026, 5, 31, 12, 0, 0, 0, time.UTC)
retryAfter := 120
update := InstanceStatusForError(PluginCapabilityError{Err: &PluginError{
Code: "rate_limited",
Message: "try later",
RetryAfterSeconds: &retryAfter,
}}, now)
if update.Status != "rate_limited" {
t.Fatalf("expected status rate_limited, got %q", update.Status)
}
if update.Code != "rate_limited" || update.Message != "try later" {
t.Fatalf("unexpected error fields: %#v", update)
}
if update.RetryNotBefore == nil || !update.RetryNotBefore.Equal(now.Add(120*time.Second)) {
t.Fatalf("unexpected retry time: %#v", update.RetryNotBefore)
}
}
func TestInstanceStatusForPluginCallError(t *testing.T) {
update := InstanceStatusForError(PluginCallError{
PluginID: "strava",
Export: "list_activities_v1",
PluginError: PluginError{
Code: "invalid_grant",
},
}, time.Now())
if update.Status != "needs_reauth" {
t.Fatalf("expected status needs_reauth, got %q", update.Status)
}
if update.Code != "invalid_grant" || update.Message != "invalid_grant" {
t.Fatalf("unexpected error fields: %#v", update)
}
if update.RetryNotBefore != nil {
t.Fatalf("did not expect retry time: %#v", update.RetryNotBefore)
}
}
func TestInstanceStatusForGenericError(t *testing.T) {
update := InstanceStatusForError(errors.New("network unavailable"), time.Now())
if update.Status != "error" {
t.Fatalf("expected status error, got %q", update.Status)
}
if update.Code != "provider_unavailable" || update.Message != "network unavailable" {
t.Fatalf("unexpected error fields: %#v", update)
}
}

507
db/pluginsystem/worker.go Normal file
View File

@@ -0,0 +1,507 @@
package pluginsystem
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"os"
"os/exec"
"runtime"
"strconv"
"strings"
"sync"
"time"
"golang.org/x/sync/semaphore"
)
const (
defaultWorkerExportTimeout = 2 * time.Minute
defaultWorkerSessionTimeout = 15 * time.Minute
defaultWorkerSlotAcquireTimeout = 30 * time.Second
defaultWorkerCapturedStderrBytes = 64 * 1024
)
var (
workerSlotsMu sync.Mutex
workerSlots *semaphore.Weighted
)
type WorkerRuntime struct {
Executable string
}
type RuntimeSessionFatalError struct {
Err error
}
func (e RuntimeSessionFatalError) Error() string {
return e.Err.Error()
}
func (e RuntimeSessionFatalError) Unwrap() error {
return e.Err
}
func IsRuntimeSessionFatalError(err error) bool {
var fatal RuntimeSessionFatalError
return errors.As(err, &fatal)
}
func NewWorkerRuntime() WorkerRuntime {
return WorkerRuntime{}
}
func (r WorkerRuntime) Call(ctx context.Context, plugin LocalPlugin, export string, input []byte, policy RequestPolicyContext) ([]byte, error) {
session, err := r.OpenSession(ctx, plugin, policy)
if err != nil {
return nil, err
}
defer func() {
_ = session.Close(context.Background())
}()
return session.Call(ctx, export, input)
}
func (r WorkerRuntime) OpenSession(ctx context.Context, plugin LocalPlugin, policy RequestPolicyContext) (RuntimeSession, error) {
slot, err := acquireWorkerSlot(ctx)
if err != nil {
return nil, err
}
releaseSlot := true
defer func() {
if releaseSlot {
slot.Release(1)
}
}()
executable := r.Executable
if executable == "" {
if configured := strings.TrimSpace(os.Getenv("WANDERER_PLUGIN_WORKER_BIN")); configured != "" {
executable = configured
} else {
var err error
executable, err = os.Executable()
if err != nil {
return nil, err
}
}
}
cmd := exec.Command(executable, "plugin-worker")
cmd.Env = childEnvWithout("EXTISM_ENABLE_WASI_OUTPUT")
stdin, err := cmd.StdinPipe()
if err != nil {
return nil, err
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, err
}
stderr := &boundedWorkerBuffer{limit: envInt("WANDERER_PLUGIN_WORKER_STDERR_BYTES", defaultWorkerCapturedStderrBytes)}
cmd.Stderr = stderr
if err := cmd.Start(); err != nil {
return nil, err
}
session := &workerRuntimeSession{
plugin: plugin,
policy: policy,
sessionID: newWorkerSessionID(plugin.Manifest.ID),
cmd: cmd,
stdin: stdin,
stdout: stdout,
stderr: stderr,
requestMaxBytes: envInt("WANDERER_PLUGIN_WORKER_REQUEST_BYTES", defaultWorkerRequestMaxBytes),
responseMaxBytes: envInt("WANDERER_PLUGIN_WORKER_RESPONSE_BYTES", defaultWorkerResponseMaxBytes),
exportTimeout: envDuration("WANDERER_PLUGIN_WORKER_EXPORT_TIMEOUT", defaultWorkerExportTimeout),
slot: slot,
}
session.sessionTimer = time.AfterFunc(envDuration("WANDERER_PLUGIN_WORKER_SESSION_TIMEOUT", defaultWorkerSessionTimeout), func() {
session.markFatal("worker session timeout")
session.kill()
})
releaseSlot = false
return session, nil
}
type workerRuntimeSession struct {
plugin LocalPlugin
policy RequestPolicyContext
sessionID string
cmd *exec.Cmd
stdin io.WriteCloser
stdout io.ReadCloser
stderr *boundedWorkerBuffer
requestMaxBytes int
responseMaxBytes int
exportTimeout time.Duration
sessionTimer *time.Timer
slot *semaphore.Weighted
mu sync.Mutex
callMu sync.Mutex
waitMu sync.Mutex
waited bool
waitErr error
closed bool
fatal bool
fatalMsg string
}
func (s *workerRuntimeSession) Call(ctx context.Context, export string, input []byte) ([]byte, error) {
s.callMu.Lock()
defer s.callMu.Unlock()
s.mu.Lock()
if s.closed {
s.mu.Unlock()
return nil, fmt.Errorf("worker session is closed")
}
if s.fatal {
msg := s.fatalMsg
s.mu.Unlock()
return nil, RuntimeSessionFatalError{Err: fmt.Errorf("worker session is invalid: %s", msg)}
}
s.mu.Unlock()
callCtx, cancel := context.WithTimeout(ctx, s.exportTimeout)
defer cancel()
result := make(chan workerCallOutcome, 1)
go func() {
result <- s.call(callCtx, export, input)
}()
select {
case outcome := <-result:
if outcome.err != nil {
return nil, outcome.err
}
return outcome.output, nil
case <-callCtx.Done():
s.markFatal("worker export timeout")
s.kill()
outcome := <-result
if outcome.err != nil && !errors.Is(outcome.err, io.EOF) {
return nil, RuntimeSessionFatalError{Err: fmt.Errorf("worker export timeout: %w", outcome.err)}
}
return nil, RuntimeSessionFatalError{Err: callCtx.Err()}
}
}
type workerCallOutcome struct {
output []byte
err error
}
func (s *workerRuntimeSession) call(ctx context.Context, export string, input []byte) workerCallOutcome {
msg, err := workerMessageWithData(workerMessageCallExport, workerCallExport{
WASMPath: s.plugin.WASMPath,
Export: export,
InputBase64: encodeWorkerBytes(input),
SessionID: s.sessionID,
})
if err != nil {
return workerCallOutcome{err: err}
}
if err := writeWorkerMessage(s.stdin, s.requestMaxBytes, msg); err != nil {
s.markFatal("write worker call_export failed")
s.kill()
return workerCallOutcome{err: RuntimeSessionFatalError{Err: err}}
}
for {
msg, err := readWorkerMessage(s.stdout, s.responseMaxBytes)
if err != nil {
s.markFatal("read worker message failed")
s.kill()
return workerCallOutcome{err: RuntimeSessionFatalError{Err: s.withStderr(err)}}
}
switch msg.Type {
case workerMessageHostHTTPRequest:
if err := s.handleHostHTTPRequest(ctx, msg); err != nil {
s.markFatal("host http rpc failed")
s.kill()
return workerCallOutcome{err: RuntimeSessionFatalError{Err: s.withStderr(err)}}
}
case workerMessageHostLog:
s.handleHostLog(msg)
case workerMessageCallResult:
result, err := workerData[workerCallResult](msg)
if err != nil {
s.markFatal("invalid call_result payload")
s.kill()
return workerCallOutcome{err: RuntimeSessionFatalError{Err: err}}
}
if result.PluginError != nil {
return workerCallOutcome{err: PluginCallError{
PluginID: s.plugin.Manifest.ID,
Export: export,
PluginError: *result.PluginError,
}}
}
output, err := decodeWorkerBytes(result.OutputBase64)
if err != nil {
s.markFatal("invalid call_result output")
s.kill()
return workerCallOutcome{err: RuntimeSessionFatalError{Err: err}}
}
return workerCallOutcome{output: output}
case workerMessageError:
payload, _ := workerData[workerError](msg)
s.markFatal(payload.Message)
s.kill()
if payload.Message == "" {
payload.Message = "worker returned fatal error"
}
return workerCallOutcome{err: RuntimeSessionFatalError{Err: s.withStderr(fmt.Errorf("%s", payload.Message))}}
default:
s.markFatal("unexpected worker message")
s.kill()
return workerCallOutcome{err: RuntimeSessionFatalError{Err: fmt.Errorf("unexpected worker message %q", msg.Type)}}
}
}
}
func (s *workerRuntimeSession) handleHostLog(msg workerMessage) {
entry, err := workerData[workerHostLog](msg)
if err != nil {
log.Printf("plugin log invalid: session %s: %v", s.sessionID, err)
return
}
if entry.SessionID == "" {
entry.SessionID = s.sessionID
}
level, err := normalizeHostLogLevel(entry.Level)
if err != nil {
log.Printf("plugin log invalid: session %s: %v", s.sessionID, err)
return
}
message := sanitizeHostLogMessage(entry.Message)
if message == "" {
log.Printf("plugin log invalid: session %s: log message is required", s.sessionID)
return
}
log.Printf("plugin log [%s]: session %s: %s", level, entry.SessionID, message)
}
func (s *workerRuntimeSession) handleHostHTTPRequest(ctx context.Context, msg workerMessage) error {
request, err := workerData[workerHostHTTPRequest](msg)
if err != nil {
return err
}
requestBytes, err := decodeWorkerBytes(request.RequestBase64)
if err != nil {
return err
}
response := executeHostHTTPRequest(ctx, s.plugin.Manifest, s.policy, requestBytes)
responseBytes, err := json.Marshal(response)
if err != nil {
return err
}
reply, err := workerMessageWithData(workerMessageHostHTTPResponse, workerHostHTTPResponse{
ResponseBase64: encodeWorkerBytes(responseBytes),
})
if err != nil {
return err
}
return writeWorkerMessage(s.stdin, s.responseMaxBytes, reply)
}
func (s *workerRuntimeSession) Close(ctx context.Context) error {
s.mu.Lock()
if s.closed {
s.mu.Unlock()
return nil
}
s.closed = true
fatal := s.fatal
s.mu.Unlock()
if s.sessionTimer != nil {
s.sessionTimer.Stop()
}
if !fatal {
_ = writeWorkerMessage(s.stdin, s.requestMaxBytes, workerMessage{Type: workerMessageShutdown})
}
_ = s.stdin.Close()
wait := make(chan error, 1)
go func() {
wait <- s.wait()
}()
select {
case err := <-wait:
s.slot.Release(1)
if err != nil && !fatal {
return s.withStderr(err)
}
return nil
case <-ctx.Done():
s.kill()
err := <-wait
s.slot.Release(1)
if err != nil {
return s.withStderr(err)
}
return ctx.Err()
}
}
func (s *workerRuntimeSession) wait() error {
s.waitMu.Lock()
defer s.waitMu.Unlock()
if s.waited {
return s.waitErr
}
s.waited = true
s.waitErr = s.cmd.Wait()
return s.waitErr
}
func (s *workerRuntimeSession) markFatal(msg string) {
s.mu.Lock()
defer s.mu.Unlock()
s.fatal = true
if s.fatalMsg == "" {
s.fatalMsg = msg
}
}
func (s *workerRuntimeSession) kill() {
if s.cmd != nil && s.cmd.Process != nil {
_ = s.cmd.Process.Kill()
}
_ = s.stdin.Close()
_ = s.stdout.Close()
}
func (s *workerRuntimeSession) withStderr(err error) error {
if err == nil {
return nil
}
stderr := strings.TrimSpace(s.stderr.String())
if stderr == "" {
return err
}
return fmt.Errorf("%w: worker stderr: %s", err, stderr)
}
func acquireWorkerSlot(ctx context.Context) (*semaphore.Weighted, error) {
timeout := envDuration("WANDERER_PLUGIN_WORKER_SLOT_TIMEOUT", defaultWorkerSlotAcquireTimeout)
acquireCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
slot := workerSemaphore()
if err := slot.Acquire(acquireCtx, 1); err != nil {
return nil, fmt.Errorf("acquire plugin worker slot: %w", err)
}
return slot, nil
}
func workerSemaphore() *semaphore.Weighted {
limit := int64(envInt("WANDERER_PLUGIN_WORKER_MAX", maxInt(2, runtime.NumCPU())))
workerSlotsMu.Lock()
defer workerSlotsMu.Unlock()
if workerSlots == nil {
workerSlots = semaphore.NewWeighted(limit)
}
return workerSlots
}
func envInt(key string, fallback int) int {
raw := strings.TrimSpace(os.Getenv(key))
if raw == "" {
return fallback
}
value, err := strconv.Atoi(raw)
if err != nil || value <= 0 {
return fallback
}
return value
}
func envDuration(key string, fallback time.Duration) time.Duration {
raw := strings.TrimSpace(os.Getenv(key))
if raw == "" {
return fallback
}
if value, err := time.ParseDuration(raw); err == nil && value > 0 {
return value
}
seconds, err := strconv.Atoi(raw)
if err != nil || seconds <= 0 {
return fallback
}
return time.Duration(seconds) * time.Second
}
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}
func childEnvWithout(keys ...string) []string {
blocked := map[string]bool{}
for _, key := range keys {
blocked[key] = true
}
env := os.Environ()
filtered := make([]string, 0, len(env))
for _, entry := range env {
key := entry
if idx := strings.IndexByte(entry, '='); idx >= 0 {
key = entry[:idx]
}
if blocked[key] {
continue
}
filtered = append(filtered, entry)
}
return filtered
}
func newWorkerSessionID(pluginID string) string {
var random [8]byte
if _, err := rand.Read(random[:]); err != nil {
return pluginID + "-" + strconv.FormatInt(time.Now().UnixNano(), 10)
}
return pluginID + "-" + hex.EncodeToString(random[:])
}
type boundedWorkerBuffer struct {
mu sync.Mutex
limit int
data []byte
}
func (b *boundedWorkerBuffer) Write(p []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
if b.limit <= 0 || len(b.data) >= b.limit {
return len(p), nil
}
remaining := b.limit - len(b.data)
if len(p) > remaining {
b.data = append(b.data, p[:remaining]...)
return len(p), nil
}
b.data = append(b.data, p...)
return len(p), nil
}
func (b *boundedWorkerBuffer) String() string {
b.mu.Lock()
defer b.mu.Unlock()
return string(b.data)
}

View File

@@ -0,0 +1,285 @@
package pluginsystem
import (
"context"
"encoding/json"
"fmt"
"io"
"os"
extism "github.com/extism/go-sdk"
)
// RunPluginWorker runs the stdio worker process. It is called by the main
// binary's plugin-worker subcommand before PocketBase is initialized.
func RunPluginWorker(ctx context.Context, stdin io.Reader, stdout io.Writer, stderr io.Writer) int {
_ = os.Unsetenv("EXTISM_ENABLE_WASI_OUTPUT")
worker := &pluginWorkerProcess{
ctx: ctx,
stdin: stdin,
stdout: stdout,
stderr: stderr,
requestMaxBytes: envInt("WANDERER_PLUGIN_WORKER_REQUEST_BYTES", defaultWorkerRequestMaxBytes),
responseMaxBytes: envInt("WANDERER_PLUGIN_WORKER_RESPONSE_BYTES", defaultWorkerResponseMaxBytes),
}
if err := worker.run(); err != nil {
_, _ = fmt.Fprintf(stderr, "plugin worker: %v\n", err)
return 1
}
return 0
}
type pluginWorkerProcess struct {
ctx context.Context
stdin io.Reader
stdout io.Writer
stderr io.Writer
requestMaxBytes int
responseMaxBytes int
wasmPath string
sessionID string
instance *extism.Plugin
fatalErr error
}
func (w *pluginWorkerProcess) run() error {
defer func() {
if w.instance != nil {
_ = w.instance.Close(w.ctx)
}
}()
for {
msg, err := readWorkerMessage(w.stdin, w.requestMaxBytes)
if err != nil {
// A clean io.EOF means the parent closed stdin without a
// shutdown frame (e.g. it crashed); exit quietly. An
// io.ErrUnexpectedEOF means stdin was cut mid-frame, which is a
// truncated/corrupt frame and should surface as an error.
if err == io.EOF {
return nil
}
return err
}
switch msg.Type {
case workerMessageShutdown:
return nil
case workerMessageCallExport:
if err := w.handleCallExport(msg); err != nil {
_ = w.sendError(err.Error())
return err
}
default:
err := fmt.Errorf("unexpected worker message %q", msg.Type)
_ = w.sendError(err.Error())
return err
}
}
}
func (w *pluginWorkerProcess) handleCallExport(msg workerMessage) error {
call, err := workerData[workerCallExport](msg)
if err != nil {
return err
}
if call.WASMPath == "" || call.Export == "" {
return fmt.Errorf("call_export requires wasmPath and export")
}
// The session ID is set by the parent once per worker process and reused
// for every call. It carries no routing semantics here (a worker serves a
// single wasm path) but is threaded into errors so captured stderr can be
// tied back to a specific session during diagnosis.
w.sessionID = call.SessionID
if w.instance == nil {
if err := w.openPlugin(call.WASMPath); err != nil {
return w.errCtx(call.Export, err)
}
} else if call.WASMPath != w.wasmPath {
return w.errCtx(call.Export, fmt.Errorf("worker session cannot switch wasm path (have %q, got %q)", w.wasmPath, call.WASMPath))
}
input, err := decodeWorkerBytes(call.InputBase64)
if err != nil {
return w.errCtx(call.Export, fmt.Errorf("decode call input: %w", err))
}
w.fatalErr = nil
code, output, err := w.instance.CallWithContext(w.ctx, call.Export, input)
if w.fatalErr != nil {
return w.errCtx(call.Export, w.fatalErr)
}
if err != nil {
return w.errCtx(call.Export, fmt.Errorf("call %s: %w", call.Export, err))
}
if code != 0 {
pluginErr := pluginErrorForCode(call.Export, code, w.instance.GetErrorWithContext(w.ctx))
return w.sendCallResult(workerCallResult{PluginError: &pluginErr})
}
return w.sendCallResult(workerCallResult{OutputBase64: encodeWorkerBytes(output)})
}
// pluginErrorForCode maps a non-zero export return code into the PluginError
// reported to the parent. It prefers the structured error JSON the plugin set
// via the host error API, and falls back to a generic plugin_error when that
// payload is missing, malformed, or has no code.
func pluginErrorForCode(export string, code uint32, rawErr string) PluginError {
var parsed PluginError
if rawErr == "" || json.Unmarshal([]byte(rawErr), &parsed) != nil || parsed.Code == "" {
return PluginError{
Code: "plugin_error",
Message: fmt.Sprintf("call %s failed with code %d", export, code),
}
}
return parsed
}
func (w *pluginWorkerProcess) openPlugin(wasmPath string) error {
manifest := extism.Manifest{
Wasm: []extism.Wasm{
extism.WasmFile{Path: wasmPath},
},
}
instance, err := extism.NewPlugin(w.ctx, manifest, extism.PluginConfig{
EnableWasi: true,
}, w.hostFunctions())
if err != nil {
return fmt.Errorf("create wasm plugin: %w", err)
}
w.wasmPath = wasmPath
w.instance = instance
return nil
}
func (w *pluginWorkerProcess) hostFunctions() []extism.HostFunction {
httpFn := extism.NewHostFunctionWithStack(
"http_request",
func(ctx context.Context, plugin *extism.CurrentPlugin, stack []uint64) {
requestBytes, err := plugin.ReadBytes(stack[0])
if err != nil {
writeHostHTTPResponse(ctx, plugin, stack, hostHTTPResponse{
Error: &PluginError{Code: "invalid_request", Message: err.Error()},
})
return
}
msg, err := workerMessageWithData(workerMessageHostHTTPRequest, workerHostHTTPRequest{
RequestBase64: encodeWorkerBytes(requestBytes),
})
if err != nil {
writeHostHTTPResponse(ctx, plugin, stack, hostHTTPResponse{
Error: &PluginError{Code: "internal_error", Message: err.Error()},
})
return
}
if err := writeWorkerMessage(w.stdout, w.responseMaxBytes, msg); err != nil {
w.failHostRPC(stack, fmt.Errorf("write host http request: %w", err))
return
}
responseMsg, err := readWorkerMessage(w.stdin, w.responseMaxBytes)
if err != nil {
w.failHostRPC(stack, fmt.Errorf("read host http response: %w", err))
return
}
if responseMsg.Type != workerMessageHostHTTPResponse {
w.failHostRPC(stack, fmt.Errorf("unexpected host http response message %q", responseMsg.Type))
return
}
response, err := workerData[workerHostHTTPResponse](responseMsg)
if err != nil {
w.failHostRPC(stack, fmt.Errorf("decode host http response: %w", err))
return
}
responseBytes, err := decodeWorkerBytes(response.ResponseBase64)
if err != nil {
w.failHostRPC(stack, fmt.Errorf("decode host http response bytes: %w", err))
return
}
offset, err := plugin.WriteBytes(responseBytes)
if err != nil {
plugin.Log(extism.LogLevelError, "write host http response: "+err.Error())
stack[0] = 0
return
}
stack[0] = offset
},
[]extism.ValueType{extism.ValueTypePTR},
[]extism.ValueType{extism.ValueTypePTR},
)
httpFn.SetNamespace("wanderer")
logFn := extism.NewHostFunctionWithStack(
"log",
func(ctx context.Context, plugin *extism.CurrentPlugin, stack []uint64) {
message, err := readBoundedHostLogPayload(plugin, stack[0])
if err != nil {
plugin.Log(extism.LogLevelError, "read host log message: "+err.Error())
return
}
entry, err := parseHostLogEntry(message)
if err != nil {
_, _ = fmt.Fprintf(w.stderr, "plugin log invalid: session %s: %v\n", w.sessionID, err)
return
}
msg, err := workerMessageWithData(workerMessageHostLog, workerHostLog{
Level: entry.Level,
Message: entry.Message,
SessionID: w.sessionID,
})
if err != nil {
_, _ = fmt.Fprintf(w.stderr, "plugin log encode failed: session %s: %v\n", w.sessionID, err)
return
}
if err := writeWorkerMessage(w.stdout, w.responseMaxBytes, msg); err != nil {
_, _ = fmt.Fprintf(w.stderr, "plugin log write failed: session %s: %v\n", w.sessionID, err)
}
_ = ctx
},
[]extism.ValueType{extism.ValueTypePTR},
nil,
)
logFn.SetNamespace("wanderer")
return []extism.HostFunction{httpFn, logFn}
}
func (w *pluginWorkerProcess) failHostRPC(stack []uint64, err error) {
w.fatalErr = err
stack[0] = 0
}
// errCtx annotates a fatal worker error with the active session and export so
// the message that the parent captures from stderr can be tied back to a
// specific call during diagnosis.
func (w *pluginWorkerProcess) errCtx(export string, err error) error {
if err == nil {
return nil
}
return fmt.Errorf("session %s export %s: %w", w.sessionID, export, err)
}
// Worker error channels follow a strict convention:
//
// - sendCallResult with a PluginError reports a business-level rejection from
// the plugin (a bad call code). The session stays alive and reusable; the
// parent surfaces it as a PluginCallError.
// - sendError reports a broken protocol or runtime (corrupt frame, host RPC
// failure, unexpected message). The parent treats it as fatal and tears the
// session down.
//
// Keep new failure paths on the correct channel: recoverable plugin outcomes
// use sendCallResult, anything that invalidates the session uses sendError.
func (w *pluginWorkerProcess) sendCallResult(result workerCallResult) error {
msg, err := workerMessageWithData(workerMessageCallResult, result)
if err != nil {
return err
}
return writeWorkerMessage(w.stdout, w.responseMaxBytes, msg)
}
func (w *pluginWorkerProcess) sendError(message string) error {
msg, err := workerMessageWithData(workerMessageError, workerError{Message: message})
if err != nil {
return err
}
return writeWorkerMessage(w.stdout, w.responseMaxBytes, msg)
}

View File

@@ -0,0 +1,149 @@
package pluginsystem
import (
"encoding/base64"
"encoding/binary"
"encoding/json"
"fmt"
"io"
)
const (
workerMessageCallExport = "call_export"
workerMessageShutdown = "shutdown"
workerMessageHostHTTPResponse = "host_http_response"
workerMessageHostHTTPRequest = "host_http_request"
workerMessageHostLog = "host_log"
workerMessageCallResult = "call_result"
workerMessageError = "error"
defaultWorkerRequestMaxBytes = 32 * 1024 * 1024
defaultWorkerResponseMaxBytes = 64 * 1024 * 1024
)
// workerMessage is one framed RPC message on the worker stdio protocol. The
// protocol is strictly synchronous (one call_export in flight at a time, with
// host HTTP RPC nested synchronously), so messages carry no correlation ID.
type workerMessage struct {
Type string `json:"type"`
Data json.RawMessage `json:"data,omitempty"`
}
type workerCallExport struct {
WASMPath string `json:"wasmPath"`
Export string `json:"export"`
InputBase64 string `json:"inputBase64,omitempty"`
SessionID string `json:"sessionId,omitempty"`
}
type workerCallResult struct {
OutputBase64 string `json:"outputBase64,omitempty"`
PluginError *PluginError `json:"pluginError,omitempty"`
}
type workerHostHTTPRequest struct {
RequestBase64 string `json:"requestBase64"`
}
type workerHostHTTPResponse struct {
ResponseBase64 string `json:"responseBase64"`
}
type workerHostLog struct {
Level string `json:"level"`
Message string `json:"message"`
SessionID string `json:"sessionId,omitempty"`
}
type workerError struct {
Message string `json:"message"`
}
func writeWorkerMessage(w io.Writer, maxBytes int, msg workerMessage) error {
payload, err := json.Marshal(msg)
if err != nil {
return err
}
if len(payload) > maxBytes {
return fmt.Errorf("worker rpc frame too large: %d > %d", len(payload), maxBytes)
}
var header [4]byte
binary.BigEndian.PutUint32(header[:], uint32(len(payload)))
if err := writeAll(w, header[:]); err != nil {
return err
}
return writeAll(w, payload)
}
func readWorkerMessage(r io.Reader, maxBytes int) (workerMessage, error) {
var header [4]byte
if _, err := io.ReadFull(r, header[:]); err != nil {
return workerMessage{}, err
}
size := binary.BigEndian.Uint32(header[:])
if size == 0 {
return workerMessage{}, fmt.Errorf("worker rpc frame is empty")
}
if int(size) > maxBytes {
return workerMessage{}, fmt.Errorf("worker rpc frame too large: %d > %d", size, maxBytes)
}
payload := make([]byte, int(size))
if _, err := io.ReadFull(r, payload); err != nil {
return workerMessage{}, err
}
var msg workerMessage
if err := json.Unmarshal(payload, &msg); err != nil {
return workerMessage{}, err
}
if msg.Type == "" {
return workerMessage{}, fmt.Errorf("worker rpc message type is empty")
}
return msg, nil
}
func encodeWorkerBytes(data []byte) string {
if len(data) == 0 {
return ""
}
return base64.StdEncoding.EncodeToString(data)
}
func decodeWorkerBytes(encoded string) ([]byte, error) {
if encoded == "" {
return nil, nil
}
return base64.StdEncoding.DecodeString(encoded)
}
func workerData[T any](msg workerMessage) (T, error) {
var value T
if len(msg.Data) == 0 {
return value, nil
}
if err := json.Unmarshal(msg.Data, &value); err != nil {
return value, err
}
return value, nil
}
func workerMessageWithData[T any](typ string, data T) (workerMessage, error) {
raw, err := json.Marshal(data)
if err != nil {
return workerMessage{}, err
}
return workerMessage{Type: typ, Data: raw}, nil
}
func writeAll(w io.Writer, data []byte) error {
for len(data) > 0 {
n, err := w.Write(data)
if err != nil {
return err
}
if n == 0 {
return io.ErrShortWrite
}
data = data[n:]
}
return nil
}

View File

@@ -0,0 +1,369 @@
package pluginsystem
import (
"bytes"
"context"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"os"
"strings"
"testing"
extism "github.com/extism/go-sdk"
"github.com/pocketbase/pocketbase/core"
)
func TestWorkerRPCFrameRoundTrip(t *testing.T) {
msg, err := workerMessageWithData(workerMessageCallExport, workerCallExport{
WASMPath: "/tmp/plugin.wasm",
Export: "list_routes_v1",
InputBase64: encodeWorkerBytes([]byte(`{"ok":true}`)),
})
if err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
if err := writeWorkerMessage(&buf, 1024, msg); err != nil {
t.Fatalf("write message: %v", err)
}
got, err := readWorkerMessage(&buf, 1024)
if err != nil {
t.Fatalf("read message: %v", err)
}
if got.Type != workerMessageCallExport {
t.Fatalf("unexpected type: %q", got.Type)
}
payload, err := workerData[workerCallExport](got)
if err != nil {
t.Fatalf("decode payload: %v", err)
}
input, err := decodeWorkerBytes(payload.InputBase64)
if err != nil {
t.Fatalf("decode input: %v", err)
}
if string(input) != `{"ok":true}` {
t.Fatalf("unexpected input: %s", input)
}
}
func TestWorkerRPCRejectsOversizedFrameBeforePayloadRead(t *testing.T) {
msg, err := workerMessageWithData(workerMessageError, workerError{Message: "too large"})
if err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
if err := writeWorkerMessage(&buf, 1024, msg); err != nil {
t.Fatalf("write message: %v", err)
}
if _, err := readWorkerMessage(&buf, 4); err == nil {
t.Fatal("expected oversized frame error")
}
}
func TestPluginWorkerExitsOnStdinEOF(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
code := RunPluginWorker(context.Background(), bytes.NewReader(nil), &stdout, &stderr)
if code != 0 {
t.Fatalf("unexpected exit code %d, stderr %q", code, stderr.String())
}
if stdout.Len() != 0 {
t.Fatalf("unexpected stdout: %q", stdout.String())
}
}
func TestPluginWorkerTruncatedFrameReturnsError(t *testing.T) {
var header [4]byte
binary.BigEndian.PutUint32(header[:], 100)
stdin := bytes.NewReader(append(header[:], []byte("partial")...))
var stdout bytes.Buffer
var stderr bytes.Buffer
code := RunPluginWorker(context.Background(), stdin, &stdout, &stderr)
if code == 0 {
t.Fatal("expected non-zero exit code for truncated frame")
}
if stderr.Len() == 0 {
t.Fatal("expected truncated frame error on stderr")
}
}
func TestPluginWorkerUnexpectedMessageTypeFails(t *testing.T) {
msg, err := workerMessageWithData(workerMessageHostHTTPResponse, workerHostHTTPResponse{})
if err != nil {
t.Fatal(err)
}
var stdin bytes.Buffer
if err := writeWorkerMessage(&stdin, defaultWorkerRequestMaxBytes, msg); err != nil {
t.Fatal(err)
}
var stdout bytes.Buffer
var stderr bytes.Buffer
code := RunPluginWorker(context.Background(), &stdin, &stdout, &stderr)
if code == 0 {
t.Fatal("expected non-zero exit code for unexpected message type")
}
reply, err := readWorkerMessage(&stdout, defaultWorkerResponseMaxBytes)
if err != nil {
t.Fatalf("read worker reply: %v", err)
}
if reply.Type != workerMessageError {
t.Fatalf("expected error reply, got %q", reply.Type)
}
}
func TestHandleCallExportRejectsWasmPathSwitch(t *testing.T) {
worker := &pluginWorkerProcess{
ctx: context.Background(),
instance: &extism.Plugin{},
wasmPath: "/plugins/a.wasm",
}
msg, err := workerMessageWithData(workerMessageCallExport, workerCallExport{
WASMPath: "/plugins/b.wasm",
Export: "list_routes_v1",
SessionID: "sess-1",
})
if err != nil {
t.Fatal(err)
}
err = worker.handleCallExport(msg)
if err == nil {
t.Fatal("expected error when switching wasm path")
}
for _, want := range []string{"sess-1", "list_routes_v1", "/plugins/a.wasm", "/plugins/b.wasm"} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("error %q missing %q", err.Error(), want)
}
}
}
func TestHandleCallExportRejectsInvalidInput(t *testing.T) {
worker := &pluginWorkerProcess{
ctx: context.Background(),
instance: &extism.Plugin{},
wasmPath: "/plugins/a.wasm",
}
msg, err := workerMessageWithData(workerMessageCallExport, workerCallExport{
WASMPath: "/plugins/a.wasm",
Export: "list_routes_v1",
InputBase64: "!!!not-base64!!!",
SessionID: "sess-2",
})
if err != nil {
t.Fatal(err)
}
err = worker.handleCallExport(msg)
if err == nil {
t.Fatal("expected error for invalid input base64")
}
if !strings.Contains(err.Error(), "sess-2") || !strings.Contains(err.Error(), "decode call input") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestWorkerHostLogFrameRoundTrip(t *testing.T) {
msg, err := workerMessageWithData(workerMessageHostLog, workerHostLog{
Level: "info",
Message: "detail fetch took 1s",
SessionID: "sess-log",
})
if err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
if err := writeWorkerMessage(&buf, 1024, msg); err != nil {
t.Fatalf("write message: %v", err)
}
got, err := readWorkerMessage(&buf, 1024)
if err != nil {
t.Fatalf("read worker message: %v", err)
}
if got.Type != workerMessageHostLog {
t.Fatalf("expected host_log, got %q", got.Type)
}
payload, err := workerData[workerHostLog](got)
if err != nil {
t.Fatalf("decode host log: %v", err)
}
if payload.Level != "info" || payload.Message != "detail fetch took 1s" || payload.SessionID != "sess-log" {
t.Fatalf("unexpected host log payload: %#v", payload)
}
}
func TestPluginErrorForCode(t *testing.T) {
t.Run("falls back when raw error is empty", func(t *testing.T) {
got := pluginErrorForCode("list_routes_v1", 7, "")
if got.Code != "plugin_error" || !strings.Contains(got.Message, "code 7") {
t.Fatalf("unexpected fallback error: %#v", got)
}
})
t.Run("falls back when raw error is malformed", func(t *testing.T) {
got := pluginErrorForCode("list_routes_v1", 1, "{not json")
if got.Code != "plugin_error" {
t.Fatalf("expected fallback for malformed json, got %#v", got)
}
})
t.Run("falls back when code is empty", func(t *testing.T) {
got := pluginErrorForCode("list_routes_v1", 1, `{"message":"boom"}`)
if got.Code != "plugin_error" {
t.Fatalf("expected fallback for missing code, got %#v", got)
}
})
t.Run("passes through structured error", func(t *testing.T) {
got := pluginErrorForCode("list_routes_v1", 1, `{"code":"rate_limited","message":"slow down"}`)
if got.Code != "rate_limited" || got.Message != "slow down" {
t.Fatalf("expected structured error, got %#v", got)
}
})
}
func TestExecuteHostHTTPRequestRejectsInvalidPayload(t *testing.T) {
response := executeHostHTTPRequest(context.Background(), Manifest{}, RequestPolicyContext{}, []byte("not json"))
if response.Error == nil || response.Error.Code != "invalid_request" {
t.Fatalf("expected invalid_request error, got %#v", response)
}
}
func TestPluginWorkerHostRPCFatalSetsClearError(t *testing.T) {
worker := &pluginWorkerProcess{}
stack := []uint64{123}
worker.failHostRPC(stack, errors.New("host RPC read failed"))
if stack[0] != 0 {
t.Fatalf("expected null response pointer, got %d", stack[0])
}
if worker.fatalErr == nil || worker.fatalErr.Error() != "host RPC read failed" {
t.Fatalf("unexpected fatal error: %v", worker.fatalErr)
}
}
func TestRuntimeSessionFatalErrorIsDetectableThroughWrapping(t *testing.T) {
err := fmt.Errorf("outer: %w", RuntimeSessionFatalError{Err: errors.New("worker died")})
if !IsRuntimeSessionFatalError(err) {
t.Fatal("expected fatal session error")
}
if IsRuntimeSessionFatalError(errors.New("plugin error")) {
t.Fatal("unexpected fatal session error")
}
}
func TestChildEnvWithoutStripsKeys(t *testing.T) {
t.Setenv("EXTISM_ENABLE_WASI_OUTPUT", "1")
t.Setenv("WANDERER_TEST_KEEP", "yes")
env := childEnvWithout("EXTISM_ENABLE_WASI_OUTPUT")
for _, entry := range env {
if entry == "EXTISM_ENABLE_WASI_OUTPUT=1" {
t.Fatalf("unexpected stripped env entry in %#v", env)
}
}
if os.Getenv("EXTISM_ENABLE_WASI_OUTPUT") != "1" {
t.Fatal("childEnvWithout should not mutate the current process env")
}
}
func TestInjectHostRequestAuthUsesExistingSessionForRefresh(t *testing.T) {
spec := HostRequestSpec{Auth: "session"}
session := &fakeRuntimeSession{
output: []byte(`{"token":"session-token"}`),
}
err := InjectHostRequestAuth(context.Background(), AuthInjectionInput{
Session: session,
Plugin: LocalPlugin{Manifest: Manifest{
Auth: AuthManifest{Contexts: map[string]AuthContext{
"session": {
Type: AuthTypeSession,
SecretFields: []string{"email", "password"},
Refresh: &AuthRefresh{Mode: AuthRefreshModePlugin, Function: "refresh_session_v1"},
},
}},
Permissions: PermissionManifest{Auth: []string{"session"}},
}},
Instance: testPluginInstance("inst1", "plugin.test"),
Auth: map[string]any{"email": "user@example.com", "password": "secret"},
Spec: &spec,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if session.export != "refresh_session_v1" {
t.Fatalf("unexpected export: %q", session.export)
}
if got := spec.Headers[AuthHeaderAuthorization]; got != AuthSchemeBearer+" session-token" {
t.Fatalf("unexpected auth header: %q", got)
}
var input map[string]any
if err := json.Unmarshal(session.input, &input); err != nil {
t.Fatalf("invalid refresh input: %v", err)
}
auth, ok := input["auth"].(map[string]any)
if !ok {
t.Fatalf("missing refresh auth: %#v", input)
}
if _, ok := auth["accessToken"]; ok {
t.Fatalf("refresh auth leaked access token: %#v", auth)
}
}
type fakeRuntimeSession struct {
export string
input []byte
output []byte
err error
}
func (s *fakeRuntimeSession) Call(_ context.Context, export string, input []byte) ([]byte, error) {
s.export = export
s.input = append([]byte(nil), input...)
if s.err != nil {
return nil, s.err
}
return s.output, nil
}
func (s *fakeRuntimeSession) Close(context.Context) error {
return nil
}
func TestInjectHostRequestAuthDoesNotRequireRuntimeWhenSessionProvided(t *testing.T) {
spec := HostRequestSpec{Auth: "session"}
session := &fakeRuntimeSession{err: errors.New("session failed")}
err := InjectHostRequestAuth(context.Background(), AuthInjectionInput{
Session: session,
Plugin: LocalPlugin{Manifest: Manifest{
Auth: AuthManifest{Contexts: map[string]AuthContext{
"session": {
Type: AuthTypeSession,
SecretFields: []string{"email"},
Refresh: &AuthRefresh{Mode: AuthRefreshModePlugin, Function: "refresh_session_v1"},
},
}},
Permissions: PermissionManifest{Auth: []string{"session"}},
}},
Instance: testPluginInstance("inst1", "plugin.test"),
Auth: map[string]any{"email": "user@example.com"},
Spec: &spec,
})
if err == nil || err.Error() != "session failed" {
t.Fatalf("unexpected error: %v", err)
}
}
func testPluginInstance(id string, pluginID string) *core.Record {
collection := core.NewBaseCollection("plugin_instances")
collection.Fields.Add(&core.TextField{Name: "plugin_id"})
record := core.NewRecord(collection)
record.Id = id
record.Set("plugin_id", pluginID)
return record
}