Skip to content

feat: declare client surfaces in the solution manifest (#32) - #34

Merged
antoinetoussaint-byte merged 2 commits into
mainfrom
issue-32-manifest-a-solution-declares-the-client-surfaces-it
Sep 22, 2026
Merged

antoinetoussaint-byte merged 2 commits into
mainfrom
issue-32-manifest-a-solution-declares-the-client-surfaces-it

Conversation

@antoinetoussaint-byte

@antoinetoussaint-byte antoinetoussaint-byte commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

Closes #32.

Summary

  • A solution can offer something inside a client (the wiki's citing surface inside a Word document), and the add-in currently carries a table of who offers what. Manifest.Surfaces moves that into the solution's own declaration, served at /.well-known/solution.json beside nav and dashboard and carried in the host registration heartbeat (both derive from manifestMap()).
  • Serve refuses, before touching the environment, a surface no client could load: a well-formed id unique per client, a non-empty title, a known client kind (word, powerpoint, excel, slack), a contract major >= 1, and a module that 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.
  • applies defaults to "always" on the wire when undeclared; description and events are 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.js passes net/url with an empty Host, but WHATWG URL (every browser, and Node) reads the backslash as a slash and what follows as an authority:

new URL("/\evil.example/footnote.js", "https://solution.example").href
  -> https://evil.example/footnote.js

Same for /\/evil.example/x.js. ASCII tab and newline are removed from the input before that parser runs, so /<tab>/evil.example/x.js arrives protocol-relative. The check now inspects the bytes rather than asking net/url what it made of them.

Test plan

  • go test -race ./... -count=1 — full suite green
  • go vet ./... and gofmt -l ./*.go clean (the CI checks verbatim)
  • The three bypass cases verified red against the previous implementation, green after — net/url-only validation accepted both backslash forms and the boot-level test failed
  • Validation rejects: empty/uppercase/slashed id, leading-, trailing- and bare-dash id, duplicate id within one client, empty title, unknown/empty client, contract 0 and negative, absolute URL module, protocol-relative module, backslash and backslash-slash authority modules, tab- and newline-smuggled modules, relative module, empty module
  • One id shared across two clients is accepted; two surfaces sharing an id in one client are refused
  • Served manifest carries every declared slot through JSON, including a {tagged:[…]} selector unflattened
  • applies is "always" when undeclared; no description/events keys when those are empty; no surfaces key when none declared
  • Serve returns a boot error naming the surface, and does not bind, on an off-origin module

Host 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

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>
@antoinetoussaint-byte

Copy link
Copy Markdown
Contributor Author

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):

  1. Do not rely on this runtime's same-origin check. The earlier note said "the runtime validates that module is a same-origin path," which invites skipping your own check. A solution serves its own manifest and need not use this Go runtime at all — that validation catches an author's mistake and nothing else. The registry and the client's module loader must enforce the origin independently.

    It is also harder than it looks: /\evil.example/x.js has an empty Host in Go's net/url but resolves to https://evil.example/x.js under WHATWG URL (every browser, and Node) — the backslash is read as a slash and what follows as an authority. Same for /\/evil.example/x.js. ASCII tab and newline are stripped before that parser runs, so /<tab>/evil.example/x.js arrives protocol-relative. Validate with the parser that will resolve the path, or inspect the bytes.

  2. Ids are unique per (client, id), not per solution. The same offering in two clients carries the same id — footnote in Word and in PowerPoint — so key the registry by the pair.

  3. applies is always present, defaulting to "always" when the solution declares no selector; the earlier note said it was omitted. contract is always present and >= 1, and title is always non-empty (both refused at boot otherwise). description and events are omitted when empty — absent, those mean "nothing to show" and "nothing to reconcile on". The surfaces key itself stays absent when a solution declares none.

@antoinetoussaint-byte
antoinetoussaint-byte merged commit 2ce3204 into main Sep 22, 2026
1 check passed
@antoinetoussaint-byte
antoinetoussaint-byte deleted the issue-32-manifest-a-solution-declares-the-client-surfaces-it branch October 25, 2026 17:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

manifest · a solution declares the client surfaces it offers (word, powerpoint, slack …)

1 participant