From 198d03125b2de599745f6c5afe23456e8e35aa50 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Tue, 3 Nov 2026 11:11:22 +0000 Subject: [PATCH] feat(addons): let a connector kind declare its file abilities A connector kind can declare files.receive and files.send, each with a maximum size and optional media types. The field is additive and needs manifest api 2. A kind that declares nothing keeps the inline path. Assisted-by: Claude Code:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto --- pkg/addons/contract/connector.go | 7 ++ pkg/addons/contract/connectorfiles.go | 78 +++++++++++++++++++ pkg/addons/contract/connectorfiles_test.go | 89 ++++++++++++++++++++++ 3 files changed, 174 insertions(+) create mode 100644 pkg/addons/contract/connectorfiles.go create mode 100644 pkg/addons/contract/connectorfiles_test.go diff --git a/pkg/addons/contract/connector.go b/pkg/addons/contract/connector.go index 276ee34b00..5f509845c7 100644 --- a/pkg/addons/contract/connector.go +++ b/pkg/addons/contract/connector.go @@ -141,6 +141,10 @@ type ConnectorKind struct { // this declaration and sends it with the op poll. It needs manifest api // MinHistoryAPI. Options []ConnectorOption `json:"options,omitempty"` + // Files declares the files the kind can receive and send, with a limit + // for each direction. Without it, the host moves files inline and caps + // them at MaxConnectorFileBytes. It needs manifest api MinFilesAPI. + Files *ConnectorFiles `json:"files,omitempty"` } // ConnectorOption is one choice among a few values. @@ -285,6 +289,9 @@ func validateConnector(c *ConnectorCapability, ui *UI, api VersionRange) error { return err } } + if err := k.Files.validate(api); err != nil { + return err + } if k.Page != "" { ok := false if ui != nil { diff --git a/pkg/addons/contract/connectorfiles.go b/pkg/addons/contract/connectorfiles.go new file mode 100644 index 0000000000..9bbe185bfb --- /dev/null +++ b/pkg/addons/contract/connectorfiles.go @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: MIT +package contract + +import ( + "fmt" + "regexp" +) + +// MinFilesAPI is the first manifest API version that knows the files ability +// of a connector kind. A manifest that declares one must accept it. +const MinFilesAPI uint64 = 2 + +// Limits of the files ability. +const ( + // MaxDeclaredFileBytes is the largest limit a kind may declare: 1 TiB. + // The host ceiling and the owner's settings still apply. + MaxDeclaredFileBytes int64 = 1 << 40 + // MaxFileTypes bounds the media types of one direction. + MaxFileTypes = 32 +) + +// FileAbility is what a connector kind can move in one direction. +type FileAbility struct { + // MaxBytes is the largest file the platform lets the add-on move, in + // bytes. The host uses it as an upper bound that the owner can lower. It + // is not proof that a transfer works. + MaxBytes int64 `json:"maxBytes"` + // Types lists the media types the kind moves, such as application/pdf or + // image/*. Empty means every type the host allows. + Types []string `json:"types,omitempty"` +} + +// ConnectorFiles is the manifest field files of a connector kind. A kind that +// declares none keeps the inline path, capped by MaxConnectorFileBytes. +type ConnectorFiles struct { + // Receive is the ability to read files that people send to the bot. + Receive *FileAbility `json:"receive,omitempty"` + // Send is the ability to upload files from the bot. + Send *FileAbility `json:"send,omitempty"` +} + +var mediaTypePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9!#$&^_.+-]{0,63}/(\*|[a-z0-9][a-z0-9!#$&^_.+-]{0,63})$`) + +func (a *FileAbility) validate(dir string) error { + if a == nil { + return nil + } + if a.MaxBytes < 1 || a.MaxBytes > MaxDeclaredFileBytes { + return fmt.Errorf("files.%s.maxBytes must be 1 to %d", dir, MaxDeclaredFileBytes) + } + if len(a.Types) > MaxFileTypes { + return fmt.Errorf("files.%s.types has more than %d entries", dir, MaxFileTypes) + } + seen := map[string]bool{} + for _, t := range a.Types { + if !mediaTypePattern.MatchString(t) || seen[t] { + return fmt.Errorf("files.%s.types needs distinct media types such as application/pdf or image/*", dir) + } + seen[t] = true + } + return nil +} + +func (f *ConnectorFiles) validate(api VersionRange) error { + if f == nil { + return nil + } + if api.Max < MinFilesAPI { + return fmt.Errorf("a connector kind that declares files needs api %d", MinFilesAPI) + } + if f.Receive == nil && f.Send == nil { + return fmt.Errorf("files must declare receive, send or both") + } + if err := f.Receive.validate("receive"); err != nil { + return err + } + return f.Send.validate("send") +} diff --git a/pkg/addons/contract/connectorfiles_test.go b/pkg/addons/contract/connectorfiles_test.go new file mode 100644 index 0000000000..8612695b47 --- /dev/null +++ b/pkg/addons/contract/connectorfiles_test.go @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: MIT +package contract + +import ( + "encoding/json" + "os" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("the files ability of a connector kind", func() { + var m Manifest + BeforeEach(func() { + b, err := os.ReadFile("../../../tests/addons/fixtures/contracts/page.json") + Expect(err).NotTo(HaveOccurred()) + m, err = ParseManifest(b) + Expect(err).NotTo(HaveOccurred()) + m.API = VersionRange{Min: 1, Max: 2} + m.Capabilities.UI = nil + m.Capabilities.Connector = &ConnectorCapability{Kinds: []ConnectorKind{{ID: "telegram", Title: "Telegram", Delivery: DeliveryPull}}} + }) + host := func() HostCompatibility { + h := compatible() + h.API, h.APIMin = HostAPIVersion, HostAPIMin + h.SDK = HostSDKVersion + return h + } + declare := func(f *ConnectorFiles) { m.Capabilities.Connector.Kinds[0].Files = f } + + It("accepts a kind that declares what it receives and sends", func() { + declare(&ConnectorFiles{ + Receive: &FileAbility{MaxBytes: 20 << 20}, + Send: &FileAbility{MaxBytes: 50 << 20, Types: []string{"application/pdf", "image/*"}}, + }) + Expect(ValidateManifest(m, host())).To(Succeed()) + }) + + It("accepts a kind that declares one direction only, or nothing", func() { + declare(&ConnectorFiles{Send: &FileAbility{MaxBytes: 1 << 20}}) + Expect(ValidateManifest(m, host())).To(Succeed()) + declare(nil) + Expect(ValidateManifest(m, host())).To(Succeed()) + }) + + It("needs a manifest that accepts API 2", func() { + declare(&ConnectorFiles{Receive: &FileAbility{MaxBytes: 1 << 20}}) + m.API = VersionRange{Min: 1, Max: 1} + Expect(ValidateManifest(m, host())).To(MatchError(ContainSubstring("api 2"))) + }) + + DescribeTable("refuses", + func(f *ConnectorFiles) { + declare(f) + Expect(ValidateManifest(m, host())).NotTo(Succeed()) + }, + Entry("a limit of zero", &ConnectorFiles{Receive: &FileAbility{}}), + Entry("a negative limit", &ConnectorFiles{Send: &FileAbility{MaxBytes: -1}}), + Entry("a limit above the largest the host knows", &ConnectorFiles{Send: &FileAbility{MaxBytes: MaxDeclaredFileBytes + 1}}), + Entry("an empty ability", &ConnectorFiles{}), + Entry("a type that is no media type", &ConnectorFiles{Send: &FileAbility{MaxBytes: 1, Types: []string{"pdf"}}}), + Entry("a repeated type", &ConnectorFiles{Send: &FileAbility{MaxBytes: 1, Types: []string{"image/*", "image/*"}}}), + Entry("too many types", &ConnectorFiles{Send: &FileAbility{MaxBytes: 1, Types: manyTypes(MaxFileTypes + 1)}}), + ) + + It("reads files from JSON and refuses an unknown field in it", func() { + var k ConnectorKind + Expect(json.Unmarshal([]byte(`{"id":"x","title":"X","delivery":"pull","files":{"receive":{"maxBytes":10,"types":["text/plain"]}}}`), &k)).To(Succeed()) + Expect(k.Files.Receive.MaxBytes).To(Equal(int64(10))) + Expect(k.Files.Send).To(BeNil()) + declare(&ConnectorFiles{Receive: &FileAbility{MaxBytes: 1}}) + raw, err := json.Marshal(m) + Expect(err).NotTo(HaveOccurred()) + _, err = ParseManifest(raw) + Expect(err).NotTo(HaveOccurred()) + bad := strings.Replace(string(raw), `"maxBytes":1`, `"maxBytes":1,"bogus":2`, 1) + _, err = ParseManifest([]byte(bad)) + Expect(err).To(HaveOccurred()) + }) +}) + +func manyTypes(n int) []string { + out := make([]string, n) + for i := range out { + out[i] = "text/x-" + strings.Repeat("a", i+1) + } + return out +}