Claim document-converter and serve convert: queue the ask for the converter and read its answer, holding no credential (issue 1595)

This commit is contained in:
2026-10-21 14:55:47 +02:00
parent 6ee3507af0
commit caae838a2a
6 changed files with 417 additions and 0 deletions
@@ -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"))
},
}}
}
@@ -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)
}
}
@@ -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)
}
}
+5
View File
@@ -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
+2
View File
@@ -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=
+39
View File
@@ -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",