Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 161 additions & 0 deletions solution.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,134 @@ type Manifest struct {
// validation are owned by the host, so this stays an opaque declaration to
// keep the runtime host-agnostic.
Dashboard any
// Surfaces are what this solution offers inside a client application — a
// Word add-in, a Slack app — rather than as a page the host renders. The
// host's registry projects them so a client can discover, without a table
// of its own, which solutions offer it something.
Surfaces []Surface
}

// Surface is one offering a solution makes inside a client application.
type Surface struct {
ID string // unique per client, e.g. "footnote"
Client string // client kind: word, powerpoint, excel, slack
Title string // what the client labels the surface with
// Description is the longer explanation a client may show beside the title.
// Optional, and omitted from the manifest when empty so a client renders no
// description rather than an empty one.
Description string
// Module is the path, on this solution's own origin, of a self-contained ES
// module the client loads, resolved against the origin the client loaded
// this solution from. The check on it catches an author reaching off that
// origin by mistake; it is not what keeps a hostile solution from doing so
// deliberately, since a solution serves its own manifest and need not use
// this runtime at all. The host's registry and the client's loader own that
// boundary and have to enforce it themselves.
Module string
// Contract is the surface contract major this module is built against
// (obin-ai/obin-word docs/SURFACE.md), so a client refuses a surface it
// cannot run rather than loading it and failing inside.
Contract int
// Applies is "always" or a {"tagged": [...]} selector, and Events are the
// journal namespaces the surface reconciles on. Both are carried verbatim:
// what a tag selects and what a namespace names are the client's to
// interpret, not this runtime's.
Applies any
Events []string
}

// surfaceAppliesAlways is what an undeclared Applies is served as. A surface is
// present in the manifest either way, so leaving the slot out would not say
// "no surface" the way an absent dashboard does — it would ask every client to
// invent the same default for a surface that is otherwise fully declared.
const surfaceAppliesAlways = "always"

// surfaceClients are the client kinds a surface may declare. A client loads
// only what names it, so an unknown kind is a surface nothing will ever load —
// caught at boot rather than showing up as a solution that simply never
// appears in the add-in.
var surfaceClients = map[string]bool{"word": true, "powerpoint": true, "excel": true, "slack": true}

// validateSurfaces refuses a surface declaration no client could use. The
// author writes these in code, so every failure here is a mistake a boot should
// name rather than a condition the runtime can recover from.
func (m Manifest) validateSurfaces() error {
// Keyed by client and id together: two clients never see each other's
// surfaces, so the same offering carries the same id in Word and in
// PowerPoint, and only a collision within one client is ambiguous. The id
// and client checks below run first, so neither half of the key can contain
// the separator and no two distinct pairs can collide into one string.
seen := make(map[string]bool, len(m.Surfaces))
for i, surface := range m.Surfaces {
switch {
case surface.ID == "":
return fmt.Errorf("surface %d has no id", i)
case !isSurfaceID(surface.ID):
return fmt.Errorf("surface %q: id must be lowercase letters and digits, separated by dashes", surface.ID)
case surface.Title == "":
return fmt.Errorf("surface %q has no title: a client labels the surface with it, and renders a blank control without one", surface.ID)
case !surfaceClients[surface.Client]:
return fmt.Errorf("surface %q: unknown client kind %q", surface.ID, surface.Client)
case seen[surface.Client+"/"+surface.ID]:
return fmt.Errorf("surface %q declared twice for client %q: a client addresses a surface by id, so two of its own cannot share one",
surface.ID, surface.Client)
case surface.Contract < 1:
// A client runs a surface only when it recognises the contract
// major it declares, so the zero an author leaves behind is not a
// lenient default: it is a surface every client refuses, in a boot
// that otherwise looks entirely healthy.
return fmt.Errorf("surface %q: contract %d is not a surface contract major; declare the major this module is built against (>= 1)",
surface.ID, surface.Contract)
case !isSameOriginPath(surface.Module):
return fmt.Errorf("surface %q: module %q must be a path on this solution's own origin, e.g. %q",
surface.ID, surface.Module, "/surfaces/"+surface.Client+"/"+surface.ID+".js")
}
seen[surface.Client+"/"+surface.ID] = true
}
return nil
}

// isSurfaceID accepts lowercase letters and digits separated by dashes. A
// leading or trailing dash is refused so that an id is never a prefix game, and
// so the empty string cannot pass by having no characters to object to.
func isSurfaceID(id string) bool {
if id == "" {
return false
}
for i, r := range id {
if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' {
continue
}
if r != '-' || i == 0 || i == len(id)-1 {
return false
}
}
return true
}

// isSameOriginPath reports whether p addresses this solution's own origin: a
// rooted path and nothing else.
//
// Whether a path is rooted is decided by the parser that will resolve it — the
// client's, not Go's — and the two disagree on three inputs. A browser follows
// WHATWG URL, where a backslash beside the leading slash is read as a slash and
// what follows it as an authority, so "/\host/x.js" resolves to https://host/x.js
// while net/url reads that backslash as an ordinary path byte and reports no
// host at all; and where ASCII tab and newline are removed from the input before
// it is parsed, so "/<tab>/host/x.js" becomes protocol-relative on arrival. None
// of the three can be seen in net/url's answer, so they are refused here by
// inspecting the bytes rather than by asking net/url what it made of them.
func isSameOriginPath(p string) bool {
if !strings.HasPrefix(p, "/") || strings.HasPrefix(p, "//") {
return false
}
for _, r := range p {
if r == '\\' || unicode.IsControl(r) {
return false
}
}
u, err := url.Parse(p)
return err == nil && u.Scheme == "" && u.Host == ""
}

// Handler is a solution endpoint. It receives a Gateway bound to the caller's
Expand Down Expand Up @@ -473,6 +601,12 @@ func (s *Server) HandleRequest(path string, handler RequestHandler) *Server {
// Serve reads env config, self-registers, and blocks serving the solution.
func (s *Server) Serve() error {
ctx := context.Background()
// Before anything the environment owns: a surface the author declared wrong
// is wrong in every environment, and saying so first keeps that mistake from
// reading as one more unresolved address.
if err := s.manifest.validateSurfaces(); err != nil {
return fmt.Errorf("solution %q: %w", s.manifest.ID, err)
}
// The SDK owns environment resolution: load Codefly's injected carriers so
// endpoint and workspace-secret lookups resolve from them (falling back to
// the local native workspace map when not running under the runtime).
Expand Down Expand Up @@ -644,6 +778,33 @@ func (s *Server) manifestMap() map[string]any {
if s.manifest.Dashboard != nil {
m["dashboard"] = s.manifest.Dashboard
}
if len(s.manifest.Surfaces) > 0 {
surfaces := make([]any, 0, len(s.manifest.Surfaces))
for _, surface := range s.manifest.Surfaces {
entry := map[string]any{
"id": surface.ID,
"client": surface.Client,
"title": surface.Title,
"module": surface.Module,
"contract": surface.Contract,
"applies": surface.Applies,
}
if surface.Applies == nil {
entry["applies"] = surfaceAppliesAlways
}
// Absent, these two say something a client can act on — no
// description to show, nothing to reconcile on — where a null would
// only be a slot the client still has to interpret.
if surface.Description != "" {
entry["description"] = surface.Description
}
if len(surface.Events) > 0 {
entry["events"] = surface.Events
}
surfaces = append(surfaces, entry)
}
m["surfaces"] = surfaces
}
return m
}

Expand Down
204 changes: 204 additions & 0 deletions solution_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,210 @@ func TestManifestOmitsAbsentDashboard(t *testing.T) {
}
}

// wordFootnote is the surface shape the wiki declares for a Word document: the
// whole set of slots a client reads, so a test can assert on all of them.
func wordFootnote() Surface {
return Surface{
ID: "footnote",
Client: "word",
Title: "Footnote",
Description: "Cite a claim from the wiki.",
Module: "/surfaces/word/footnote.js",
Contract: 1,
Applies: "always",
Events: []string{"documents.entry.*"},
}
}

// A client discovers what a solution offers it from the served manifest alone,
// so every declared slot must survive the trip through JSON — including the
// applies selector, which the runtime carries without interpreting.
func TestServedManifestCarriesDeclaredSurfaces(t *testing.T) {
s := &Server{manifest: Manifest{ID: "wiki", Surfaces: []Surface{wordFootnote()}}}

var got map[string]any
body, err := json.Marshal(s.manifestMap())
if err != nil {
t.Fatalf("marshal manifest: %v", err)
}
if err := json.Unmarshal(body, &got); err != nil {
t.Fatalf("unmarshal manifest: %v", err)
}
want := []any{map[string]any{
"id": "footnote",
"client": "word",
"title": "Footnote",
"description": "Cite a claim from the wiki.",
"module": "/surfaces/word/footnote.js",
"contract": float64(1),
"applies": "always",
"events": []any{"documents.entry.*"},
}}
if !reflect.DeepEqual(got["surfaces"], want) {
t.Errorf("served surfaces = %#v, want %#v", got["surfaces"], want)
}
}

// A tagged selector is a structure, not a keyword, and the runtime must hand it
// to the client exactly as declared rather than flattening it.
func TestServedSurfaceCarriesTaggedAppliesVerbatim(t *testing.T) {
applies := map[string]any{"tagged": []any{"legal", "finance"}}
surface := wordFootnote()
surface.Applies = applies
s := &Server{manifest: Manifest{ID: "wiki", Surfaces: []Surface{surface}}}

var got map[string]any
body, _ := json.Marshal(s.manifestMap())
if err := json.Unmarshal(body, &got); err != nil {
t.Fatalf("unmarshal manifest: %v", err)
}
entry := got["surfaces"].([]any)[0].(map[string]any)
if !reflect.DeepEqual(entry["applies"], applies) {
t.Errorf("served applies = %#v, want %#v", entry["applies"], applies)
}
}

// A solution that offers nothing inside a client must not gain the key at all.
func TestManifestOmitsAbsentSurfaces(t *testing.T) {
bare := &Server{manifest: Manifest{ID: "lastlogin-go"}}
if _, ok := bare.manifestMap()["surfaces"]; ok {
t.Errorf("emitted a surfaces key with no surface declared")
}
}

// A surface is in the manifest whether or not it declares a selector, so an
// absent applies is not the absence of the surface — it is the default the
// contract names, and every client would otherwise have to invent it alike.
// Nothing to show and nothing to reconcile on are different: those slots say
// what they mean by being absent.
func TestManifestDefaultsAppliesAndOmitsEmptySurfaceSlots(t *testing.T) {
surface := wordFootnote()
surface.Applies = nil
surface.Events = nil
surface.Description = ""
s := &Server{manifest: Manifest{ID: "wiki", Surfaces: []Surface{surface}}}

entry := s.manifestMap()["surfaces"].([]any)[0].(map[string]any)
if got := entry["applies"]; got != "always" {
t.Errorf("applies with no selector declared = %#v, want %q", got, "always")
}
if _, ok := entry["description"]; ok {
t.Errorf("emitted a description key with no description declared")
}
if _, ok := entry["events"]; ok {
t.Errorf("emitted an events key with no namespaces declared")
}
}

// A surface no client could load must fail the boot, not register quietly and
// show up as a solution that simply never appears in the add-in.
func TestSurfaceValidationRejectsUnusableDeclarations(t *testing.T) {
cases := []struct {
name string
surfaces []Surface
want string
}{
{"no id", []Surface{func() Surface { s := wordFootnote(); s.ID = ""; return s }()}, "no id"},
{"id with uppercase", []Surface{func() Surface { s := wordFootnote(); s.ID = "Footnote"; return s }()}, "id must be"},
{"id with a slash", []Surface{func() Surface { s := wordFootnote(); s.ID = "word/footnote"; return s }()}, "id must be"},
{"id with a leading dash", []Surface{func() Surface { s := wordFootnote(); s.ID = "-footnote"; return s }()}, "id must be"},
{"id with a trailing dash", []Surface{func() Surface { s := wordFootnote(); s.ID = "footnote-"; return s }()}, "id must be"},
{"id of one dash", []Surface{func() Surface { s := wordFootnote(); s.ID = "-"; return s }()}, "id must be"},
{"duplicate id in one client", []Surface{wordFootnote(), wordFootnote()}, "declared twice"},
{"no title", []Surface{func() Surface { s := wordFootnote(); s.Title = ""; return s }()}, "no title"},
{"unknown client", []Surface{func() Surface { s := wordFootnote(); s.Client = "notion"; return s }()}, "unknown client kind"},
{"no client", []Surface{func() Surface { s := wordFootnote(); s.Client = ""; return s }()}, "unknown client kind"},
{"undeclared contract", []Surface{func() Surface { s := wordFootnote(); s.Contract = 0; return s }()}, "contract"},
{"negative contract", []Surface{func() Surface { s := wordFootnote(); s.Contract = -1; return s }()}, "contract"},
{"absolute module URL", []Surface{func() Surface {
s := wordFootnote()
s.Module = "https://evil.example/footnote.js"
return s
}()}, "own origin"},
{"protocol-relative module", []Surface{func() Surface {
s := wordFootnote()
s.Module = "//evil.example/footnote.js"
return s
}()}, "own origin"},
// A browser reads the backslash beside the leading slash as a slash and
// what follows as a host, so each of these loads third-party code even
// though net/url reports no host in any of them.
{"backslash authority module", []Surface{func() Surface {
s := wordFootnote()
s.Module = `/\evil.example/footnote.js`
return s
}()}, "own origin"},
{"backslash-slash authority module", []Surface{func() Surface {
s := wordFootnote()
s.Module = `/\/evil.example/footnote.js`
return s
}()}, "own origin"},
{"tab-smuggled authority module", []Surface{func() Surface {
s := wordFootnote()
s.Module = "/\t/evil.example/footnote.js"
return s
}()}, "own origin"},
{"newline-smuggled authority module", []Surface{func() Surface {
s := wordFootnote()
s.Module = "/\n//evil.example/footnote.js"
return s
}()}, "own origin"},
{"relative module", []Surface{func() Surface { s := wordFootnote(); s.Module = "surfaces/footnote.js"; return s }()}, "own origin"},
{"no module", []Surface{func() Surface { s := wordFootnote(); s.Module = ""; return s }()}, "own origin"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := Manifest{ID: "wiki", Surfaces: tc.surfaces}.validateSurfaces()
if err == nil {
t.Fatalf("validateSurfaces accepted %s", tc.name)
}
if !strings.Contains(err.Error(), tc.want) {
t.Errorf("error %q does not say %q", err, tc.want)
}
})
}
}

// The same offering in two clients carries the same id — the wiki's footnote is
// "footnote" in Word and in PowerPoint — and neither client can see the other's,
// so that is not a collision. Only two surfaces of one client are ambiguous.
func TestSurfaceIDsAreUniquePerClientNotPerSolution(t *testing.T) {
powerpoint := wordFootnote()
powerpoint.Client = "powerpoint"
powerpoint.Module = "/surfaces/powerpoint/footnote.js"
if err := (Manifest{ID: "wiki", Surfaces: []Surface{wordFootnote(), powerpoint}}).validateSurfaces(); err != nil {
t.Errorf("validateSurfaces refused one id shared across two clients: %v", err)
}

second := wordFootnote()
second.Title = "Footnote, again"
second.Module = "/surfaces/word/footnote-2.js"
err := (Manifest{ID: "wiki", Surfaces: []Surface{wordFootnote(), second}}).validateSurfaces()
if err == nil {
t.Fatal("validateSurfaces accepted two surfaces sharing an id within one client")
}
if !strings.Contains(err.Error(), "declared twice") {
t.Errorf("error %q does not say the id was declared twice", err)
}
}

// Serve must refuse a manifest whose surface no client could load, and name the
// surface — the mistake is in the author's code, and it is the same mistake in
// every environment, so it must not wait on the environment resolving first.
func TestServeRejectsUnusableSurface(t *testing.T) {
surface := wordFootnote()
surface.Module = `/\evil.example/footnote.js`

s := New(Manifest{ID: "wiki", Title: "Wiki", Surfaces: []Surface{surface}})
err := s.Serve()
if err == nil {
t.Fatal("Serve returned nil for an off-origin surface module; expected a boot error and no bind")
}
if !strings.Contains(err.Error(), "footnote") {
t.Errorf("boot error should name the surface, got: %v", err)
}
}

// The host registration payload (the heartbeat body) must carry the declared
// data-graph verbatim, not just the GET manifest — that is the surface the host
// self-registration path reads.
Expand Down
Loading