From caae838a2a51a9ca4c7d1d67bc8576aa72980771 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 21 Oct 2026 14:55:47 +0200 Subject: [PATCH] Claim document-converter and serve convert: queue the ask for the converter and read its answer, holding no credential (issue 1595) --- .../only-office/cmd/only-office/convert.go | 167 ++++++++++++++++ .../cmd/only-office/convert_test.go | 183 ++++++++++++++++++ modules/only-office/cmd/only-office/main.go | 21 ++ modules/only-office/go.mod | 5 + modules/only-office/go.sum | 2 + modules/only-office/module.json | 39 ++++ 6 files changed, 417 insertions(+) create mode 100644 modules/only-office/cmd/only-office/convert.go create mode 100644 modules/only-office/cmd/only-office/convert_test.go create mode 100644 modules/only-office/cmd/only-office/main.go create mode 100644 modules/only-office/go.mod create mode 100644 modules/only-office/go.sum diff --git a/modules/only-office/cmd/only-office/convert.go b/modules/only-office/cmd/only-office/convert.go new file mode 100644 index 00000000..f9d9b8d5 --- /dev/null +++ b/modules/only-office/cmd/only-office/convert.go @@ -0,0 +1,167 @@ +package main + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "net/url" + "sort" + "strings" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// The ask the verb queues for the converter process (ADR 0468 §7; the shape of brother's device asks, ADR 0436 §6): +// on the work queue convert-asks, whose one consumer is the module's process `converter`, which writes its answer +// under the ask's id in the state convert-answers. +const ( + // EventAsked is the event on the work queue; the stream convert-asks carries it. + EventAsked = "convert.asked" + // AnswersState is the state the converter writes its answers in. + AnswersState = "convert-answers" + // ConvertWait is how long the verb waits for the converter: a document server converts a large document in + // seconds to a minute or two. + ConvertWait = 120 * time.Second +) + +// Types are the document types the converter takes (ADR 0468 §7). Any other is refused by name. +var Types = []string{"docx", "doc", "xlsx", "xls", "pptx", "ppt", "odt", "ods", "odp", "rtf", "txt", "html"} + +// Ask is what the verb queues: the operation, when it was asked, and the operation's arguments. +type Ask struct { + ID string `json:"id"` + Op string `json:"op"` + At time.Time `json:"asked-at"` + Deadline time.Time `json:"deadline"` + // Source is the signed GET link of the document, Type its type, Target the signed PUT link of the PDF. + Source string `json:"source"` + Type string `json:"type"` + Target string `json:"target"` +} + +// OpConvert is the one operation. +const OpConvert = "convert" + +// AskBus is how the tool reaches the converter: it queues an ask and reads an answer back. +type AskBus interface { + Publish(id string, body any) error + Answer(id string) (json.RawMessage, error) +} + +type sdkAskBus struct{} + +func (sdkAskBus) Publish(id string, body any) error { + return stdio.Emit(EventAsked, body, stdio.EmitOptions{MessageID: id}) +} + +func (sdkAskBus) Answer(id string) (json.RawMessage, error) { + e, err := stdio.State(AnswersState).Get(id) + if err != nil || e == nil { + return nil, err + } + return e.Value, nil +} + +// Service is the tool side. +type Service struct { + Asks AskBus + Now func() time.Time + // Wait is how long an answer is waited for, Poll how often it is looked for. + Wait, Poll time.Duration +} + +func newService() *Service { + return &Service{Asks: sdkAskBus{}, Now: time.Now, Wait: ConvertWait, Poll: 500 * time.Millisecond} +} + +func newAskID() string { + b := make([]byte, 12) + rand.Read(b) + return hex.EncodeToString(b) +} + +// cleanType is a type as asked, lower-case and without a leading dot, when it is one the converter takes. +func cleanType(raw string) (string, error) { + t := strings.ToLower(strings.TrimPrefix(strings.TrimSpace(raw), ".")) + for _, ok := range Types { + if t == ok { + return t, nil + } + } + taken := append([]string(nil), Types...) + sort.Strings(taken) + return "", fmt.Errorf("type %q is not one the converter takes: %s", raw, strings.Join(taken, ", ")) +} + +func checkLink(name, raw string) error { + u, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") { + return fmt.Errorf("%s is a signed http(s) link, such as one object-store.link gives", name) + } + return nil +} + +// Convert queues one ask and waits for the converter's answer {size, sha256} (the PDF it put to target). Nothing is +// queued for a type the converter does not take or a link that is not one. +func (s *Service) Convert(ctx context.Context, source, typ, target string) (map[string]any, error) { + t, err := cleanType(typ) + if err != nil { + return nil, err + } + if err := checkLink("source", source); err != nil { + return nil, err + } + if err := checkLink("target", target); err != nil { + return nil, err + } + at := s.Now().UTC() + ask := Ask{ID: newAskID(), Op: OpConvert, At: at, Deadline: at.Add(s.Wait), Source: strings.TrimSpace(source), Type: t, Target: strings.TrimSpace(target)} + if err := s.Asks.Publish(ask.ID, ask); err != nil { + return nil, fmt.Errorf("the ask for the converter could not be queued: %w", err) + } + ctx, cancel := context.WithTimeout(ctx, s.Wait) + defer cancel() + for { + raw, err := s.Asks.Answer(ask.ID) + if err != nil { + return nil, fmt.Errorf("the converter's answers cannot be read (ask %s): %w", ask.ID, err) + } + if raw != nil { + var out map[string]any + if err := json.Unmarshal(raw, &out); err != nil { + return nil, fmt.Errorf("the converter's answer is not JSON (ask %s): %w", ask.ID, err) + } + if msg, bad := out["error"].(string); bad { + return nil, fmt.Errorf("%s (ask %s)", msg, ask.ID) + } + out["ask"] = ask.ID + return out, nil + } + select { + case <-ctx.Done(): + return nil, fmt.Errorf("timed out: the converter did not answer within %s; it may not be running (ask %s)", s.Wait, ask.ID) + case <-time.After(s.Poll): + } + } +} + +func tools(s *Service) []stdio.Tool { + return []stdio.Tool{{ + Name: "document-converter.convert", + Description: "Turn a Word, spreadsheet, slide, text or web document into a PDF: the document is read from a signed GET " + + "link and the PDF is put to a signed PUT link, so no document crosses the bus. Takes " + strings.Join(Types, ", ") + + "; any other type is refused by name. Answers the PDF's size and SHA-256, or the document server's error.", + Input: map[string]any{ + "source": map[string]any{"type": "string", "description": "a signed GET link of the document"}, + "type": map[string]any{"type": "string", "description": "the document's type: " + strings.Join(Types, ", ")}, + "target": map[string]any{"type": "string", "description": "a signed PUT link for the PDF"}, + }, + Run: func(a map[string]any) (any, error) { + str := func(k string) string { v, _ := a[k].(string); return v } + return s.Convert(context.Background(), str("source"), str("type"), str("target")) + }, + }} +} diff --git a/modules/only-office/cmd/only-office/convert_test.go b/modules/only-office/cmd/only-office/convert_test.go new file mode 100644 index 00000000..aa2b670b --- /dev/null +++ b/modules/only-office/cmd/only-office/convert_test.go @@ -0,0 +1,183 @@ +package main + +import ( + "context" + "encoding/json" + "os" + "strings" + "sync" + "testing" + "time" +) + +// fakeBus is the work queue and the state: it records each ask and lets a test write the answer, as the converter +// process would under the ask's id. +type fakeBus struct { + mu sync.Mutex + asks []Ask + answers map[string]json.RawMessage + answerOn func(Ask) json.RawMessage // answers an ask as it is published +} + +func (b *fakeBus) Publish(id string, body any) error { + b.mu.Lock() + defer b.mu.Unlock() + raw, _ := json.Marshal(body) + var a Ask + _ = json.Unmarshal(raw, &a) + b.asks = append(b.asks, a) + if b.answerOn != nil { + if ans := b.answerOn(a); ans != nil { + b.answers[id] = ans + } + } + return nil +} + +func (b *fakeBus) Answer(id string) (json.RawMessage, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.answers[id], nil +} + +func rig(wait time.Duration) (*Service, *fakeBus) { + b := &fakeBus{answers: map[string]json.RawMessage{}} + return &Service{Asks: b, Now: time.Now, Wait: wait, Poll: time.Millisecond}, b +} + +const get, put = "http://novox.internal:20001/b/doc.docx?X-Amz-Signature=a", "http://novox.internal:20001/b/doc.pdf?X-Amz-Signature=b" + +// 1595/AC1: one ask with those fields is published on convert-asks, and the verb answers what the converter wrote under +// the ask's id. +func TestConvertQueuesOneAskAndAnswersWhatTheConverterWrote(t *testing.T) { + s, b := rig(time.Second) + b.answerOn = func(a Ask) json.RawMessage { return json.RawMessage(`{"size": 4096, "sha256": "ab12"}`) } + got, err := s.Convert(context.Background(), get, "DOCX", put) + if err != nil { + t.Fatal(err) + } + if len(b.asks) != 1 { + t.Fatalf("%d asks queued", len(b.asks)) + } + a := b.asks[0] + if a.Op != OpConvert || a.Source != get || a.Type != "docx" || a.Target != put || a.ID == "" { + t.Fatalf("ask %+v", a) + } + if got["size"] != float64(4096) || got["sha256"] != "ab12" || got["ask"] != a.ID { + t.Fatalf("answer %v", got) + } +} + +// 1595/AC1: an error the converter wrote is the verb's error. +func TestConvertAnswersTheConvertersError(t *testing.T) { + s, b := rig(time.Second) + b.answerOn = func(a Ask) json.RawMessage { + return json.RawMessage(`{"error": "-4: the document server could not download the file"}`) + } + if _, err := s.Convert(context.Background(), get, "doc", put); err == nil || !strings.Contains(err.Error(), "-4: the document server") { + t.Fatalf("error %v", err) + } +} + +// 1595/AC2: a type outside the twelve is refused naming the types taken, and nothing is queued. +func TestConvertRefusesOtherTypes(t *testing.T) { + s, b := rig(time.Second) + for _, typ := range []string{"exe", "pdf", "", "docm", "jpg"} { + _, err := s.Convert(context.Background(), get, typ, put) + if err == nil || !strings.Contains(err.Error(), "docx") || !strings.Contains(err.Error(), "txt") { + t.Errorf("type %q: %v", typ, err) + } + } + if _, err := s.Convert(context.Background(), "file:///etc/passwd", "docx", put); err == nil { + t.Error("a file link was taken") + } + if len(b.asks) != 0 { + t.Fatalf("%d asks queued", len(b.asks)) + } + if len(Types) != 12 { + t.Fatalf("%d types", len(Types)) + } +} + +// 1595/AC3: with no answer within the bound the verb says it timed out, with the ask id. +func TestConvertTimesOutNamingTheAsk(t *testing.T) { + s, b := rig(30 * time.Millisecond) + _, err := s.Convert(context.Background(), get, "docx", put) + if err == nil || !strings.Contains(err.Error(), "timed out") || !strings.Contains(err.Error(), b.asks[0].ID) { + t.Fatalf("error %v", err) + } +} + +// 1595/AC4: the tool side opens no credential: its code never names the JWT or a secret file, and the manifest's +// tools bundle is given no env naming one. +func TestTheToolSideHoldsNoCredential(t *testing.T) { + // The tool side is these two files; the converter process (issue 1596) is code of its own, which alone reads the token. + for _, f := range []string{"main.go", "convert.go"} { + raw, err := os.ReadFile(f) + if err != nil { + t.Fatal(err) + } + for _, word := range []string{"jwt", "JWT", ".secret", "os.ReadFile", "os.Open"} { + if strings.Contains(string(raw), word) { + t.Errorf("%s mentions %q", f, word) + } + } + } + m := manifest(t) + for _, a := range m["build"].(map[string]any)["artifacts"].([]any) { + if env, ok := a.(map[string]any)["env"]; ok { + t.Errorf("an artifact is given env %v", env) + } + } +} + +func manifest(t *testing.T) map[string]any { + t.Helper() + raw, err := os.ReadFile("../../module.json") + if err != nil { + t.Fatal(err) + } + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + return m +} + +// 1595/AC5: only-office claims document-converter (scope mesh) serving convert, declares the stream convert-asks as a +// work queue and the state convert-answers (the shape of brother's device-asks and device-answers). +func TestManifestClaimsTheSeat(t *testing.T) { + m := manifest(t) + claims := m["claims"].([]any) + c := claims[0].(map[string]any) + if len(claims) != 1 || c["name"] != "document-converter" || c["scope"] != "mesh" || + len(c["serves"].([]any)) != 1 || c["serves"].([]any)[0] != "convert" { + t.Fatalf("claims %v", claims) + } + streams := m["streams"].([]any) + s := streams[0].(map[string]any) + if len(streams) != 1 || s["name"] != "convert-asks" || s["retention"] != "work-queue" || s["max-deliveries"] != float64(3) || + s["events"].([]any)[0] != EventAsked { + t.Fatalf("streams %v", streams) + } + state := m["state"].([]any) + st := state[0].(map[string]any) + if len(state) != 1 || st["name"] != AnswersState || st["ttl-seconds"] != float64(600) { + t.Fatalf("state %v", state) + } + found := false + for _, a := range m["build"].(map[string]any)["artifacts"].([]any) { + found = found || a.(map[string]any)["from"] == "cmd/only-office" + } + if !found { + t.Error("no Go bundle") + } +} + +func TestTheVerbIsListedUnderTheSeat(t *testing.T) { + s, _ := rig(time.Second) + ts := tools(s) + if len(ts) != 1 || ts[0].Name != "document-converter.convert" { + t.Fatalf("tools %v", ts) + } +} diff --git a/modules/only-office/cmd/only-office/main.go b/modules/only-office/cmd/only-office/main.go new file mode 100644 index 00000000..c4a95df9 --- /dev/null +++ b/modules/only-office/cmd/only-office/main.go @@ -0,0 +1,21 @@ +// only-office's Go part (novox/hq ADR 0468 §7): the tool side of the seat `document-converter`. A caller asks to +// have a document turned into a PDF, by a signed link to read it from and a signed link to put the PDF to; the +// tool side queues the ask for the module's `converter` process (issue 1596) and reads its answer. It holds no +// credential: the document server's token is the process's alone (ADR 0436 §12). +// +// stdout is the MCP channel; what this module says, it says on stderr. +package main + +import ( + "fmt" + "os" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +func main() { + if err := stdio.Serve("", tools(newService())); err != nil { + fmt.Fprintf(os.Stderr, "[only-office] %v\n", err) + os.Exit(1) + } +} diff --git a/modules/only-office/go.mod b/modules/only-office/go.mod new file mode 100644 index 00000000..2d1d9157 --- /dev/null +++ b/modules/only-office/go.mod @@ -0,0 +1,5 @@ +module only-office + +go 1.26.0 + +require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021122348-6c857e1c025f diff --git a/modules/only-office/go.sum b/modules/only-office/go.sum new file mode 100644 index 00000000..9728ce93 --- /dev/null +++ b/modules/only-office/go.sum @@ -0,0 +1,2 @@ +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021122348-6c857e1c025f h1:7J/9jOfSfjfYpf8MOj96HwQRViQUkBNMb71RHP2mjn8= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021122348-6c857e1c025f/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= diff --git a/modules/only-office/module.json b/modules/only-office/module.json index 4af651f4..717d5bbb 100644 --- a/modules/only-office/module.json +++ b/modules/only-office/module.json @@ -14,6 +14,34 @@ "endpoint": "web" } }, + "claims": [ + { + "name": "document-converter", + "scope": "mesh", + "serves": [ + "convert" + ] + } + ], + "streams": [ + { + "name": "convert-asks", + "events": [ + "convert.asked" + ], + "max-age-days": 1, + "max-mib": 8, + "retention": "work-queue", + "max-deliveries": 3, + "duplicates": 24 + } + ], + "state": [ + { + "name": "convert-answers", + "ttl-seconds": 600 + } + ], "binds": { "route": "${dir:state}/route.json" }, @@ -163,6 +191,17 @@ ], "build": { "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/only-office", + "binary": "only-office", + "loads": [ + "only-office" + ] + }, { "name": "files", "kind": "archive",