Repository navigation
feat: declare client surfaces in the solution manifest (#32) - #34
Conversation
A solution can offer something inside a client — the wiki offers citing inside a Word document — and until now the add-in carried a table of who offers what. Manifest gains Surfaces, served beside nav and dashboard, so a client discovers the offering from the solution's own declaration. The runtime validates what a client cannot recover from: a well-formed, unique id, a known client kind, and a module path on the solution's own origin. applies and events are carried verbatim, like dashboard — what a tag selects and what a namespace names are the client's to interpret. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The module check did not hold. A browser resolves "/\host/x.js" by reading the backslash as a slash and what follows as an authority, so it loads https://host/x.js, while net/url reads that backslash as an ordinary path byte and reports no host — the check asked net/url and so accepted it. ASCII tab and newline are removed by that same parser before it parses, turning "/<tab>/host/x.js" protocol-relative on arrival; those were refused only because net/url happens to reject control characters. Both are now refused by inspecting the bytes, because what makes a path rooted is the parser that will resolve it, not the one validating it. The check catches an author's mistake, which is all a validation here can do: a solution serves its own manifest, so the host's registry and the client's loader still own that boundary. A zero Contract was emitted as "contract": 0. A client runs a surface only when it recognises the major it declares, so that shipped a surface every client refuses, from a boot that looked healthy and logged nothing — the silent no-op the rest of this runtime's validation exists to prevent. An empty Title had the same shape one step further out: a client labels the surface with it and renders a blank control. Both are now refused at boot. Surface ids were unique per solution, which refused the ordinary case of one offering in two clients — "footnote" in Word and in PowerPoint. Two clients never see each other's surfaces, so only a collision within one client is ambiguous; the key is now (client, id). An undeclared Applies was omitted, leaving every client to invent the same default for a surface that is otherwise fully declared. It is now served as "always". Description and Events keep being omitted when empty: absent, those say something a client can act on. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Correction to the surfaces wire contract — an earlier cross-agent note described this shape before review, and three points in it were wrong. Recording the corrections here, since this PR is the durable copy. For whoever implements the host registry (module-saas-starter) or a client loader (obin-word):
|
Closes #32.
Summary
Manifest.Surfacesmoves that into the solution's own declaration, served at/.well-known/solution.jsonbesidenavanddashboardand carried in the host registration heartbeat (both derive frommanifestMap()).Serverefuses, before touching the environment, a surface no client could load: a well-formed id unique per client, a non-empty title, a knownclientkind (word,powerpoint,excel,slack), acontractmajor >= 1, and amodulethat is a path on this solution's own origin. An author's mistake is the same mistake in every environment, so it should not wait on addresses resolving.appliesdefaults to"always"on the wire when undeclared;descriptionandeventsare omitted when empty, because absent they say something a client can act on. A{"tagged":[…]}selector and the event namespaces are carried verbatim — what a tag selects is the client's to interpret.What the module check is and is not
It catches an author reaching off their own origin by mistake. It is not what stops a hostile solution from doing so deliberately: a solution serves its own manifest and need not use this runtime at all. The host's registry and the client's module loader own that boundary and have to enforce it independently — please do not treat this validation as the enforcement point.
Getting it right here still took care, because Go and the browser disagree about what a rooted path is.
/\evil.example/x.jspassesnet/urlwith an emptyHost, but WHATWG URL (every browser, and Node) reads the backslash as a slash and what follows as an authority:Same for
/\/evil.example/x.js. ASCII tab and newline are removed from the input before that parser runs, so/<tab>/evil.example/x.jsarrives protocol-relative. The check now inspects the bytes rather than askingnet/urlwhat it made of them.Test plan
go test -race ./... -count=1— full suite greengo vet ./...andgofmt -l ./*.goclean (the CI checks verbatim)net/url-only validation accepted both backslash forms and the boot-level test failed{tagged:[…]}selector unflattenedappliesis"always"when undeclared; nodescription/eventskeys when those are empty; nosurfaceskey when none declaredServereturns a boot error naming the surface, and does not bind, on an off-origin moduleHost side (the registry projection in codefly-dev/module-saas-starter) is the companion issue and is not part of this PR.
🤖 Generated with Claude Code