Skip to content

docs(plugins): the http endpoint contract, and the query pass-through pinned - #796

Merged
tadelv merged 5 commits into
decentespresso:mainfrom
ChampionDesigns:ben/plugin-query-contract
Sep 10, 2026
Merged

tadelv merged 5 commits into
decentespresso:mainfrom
ChampionDesigns:ben/plugin-query-contract

Conversation

@ChampionDesigns

@ChampionDesigns ChampionDesigns commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

Summary

Two things about the plugin HTTP endpoint contract that were true but undocumented, plus the tests
that pin them.

  • doc/Plugins.md gains a Serving HTTP Endpoints section: how an api entry of
    "type": "http" exposes a plugin at /api/v1/plugins/:id/:endpoint, and what the request
    object carries.
  • It names the handleHttpRequest alias. A method of that name on the object createPlugin
    returns works exactly like __httpRequestHandler, because the loader aliases it at load
    (plugin_manager.dart:1978). That has always been true and was never written down.
  • plugins_handler_test.dart gains nine assertions pinning the query-string pass-through.

Base: main. Independent. Documentation and tests only — no production change.

Linked Issue

N/A

Verification

Update, 6 Sep 2026 — the return contract, as you asked. You were right that
return=/skin/history was not a usable contract: a plugin page runs on the API origin, so that
path resolves there rather than on the skin server, and accepting an absolute URL instead would be
an open redirect.

doc/Plugins.md now gives return its own section and states the rule: require a skin-local
path
— one leading slash, no //, no scheme, nothing else accepted; read the skin origin from
GET /api/v1/webui/server/status, which already answers {serving, path, port, ip} and is already
in rest_v1.yml; then join the two with new URL(path, skinOrigin), which cannot escape that
origin once the path is known to be skin-local. The worked example returns null when the skin is
not being served or the value was not skin-local, so a page shows its own way out instead of
navigating somewhere wrong.

The section also says to read the origin on every use, because Decaid assigns the skin server
its port and a remembered origin can point at a port nothing is listening on.

The general guidance above it now separates the two cases: most parameters are REST lookup keys,
and return is named as the exception, with a cross-reference from the example.

Documentation only — no code and no behaviour change.

  • flutter analyze — clean.

  • flutter testfull suite 3893 passed / 1 skipped, run against current main on
    5 Sep 2026.

  • flutter testplugins_handler_test 13 passed, including the nine new query assertions.

  • dart format — clean on every changed file.

  • The aliasing claim was checked against lib/src/plugins/plugin_manager.dart on main, not
    assumed from the canary.

  • Verified on hardware. This change ships in the Decaid-Canary build Ben runs on his own
    machine, and has been exercised in normal use rather than only under test.

Impact

  • Documentation: doc/Plugins.md gains a section. No behaviour changes.
  • API: none. The endpoint and the alias both already existed.
  • Compatibility: none.
  • Security: none.

Contributor Responsibility

AI-assisted development is allowed. The submitter remains responsible for the submitted work.

  • I have reviewed and understand all changes in this PR and take responsibility for their correctness, security, behavior, licensing, and provenance, including any AI-assisted or AI-generated work.

ChampionDesigns and others added 2 commits September 5, 2026 06:45
Follow-up hardening from the R14 "dye2-shot-handoff" investigation (Decal audit
finding F-028). That workflow closed as a finding, not a branch: a skin cannot
hand DYE2 the shot the user is looking at, because the `edit-shot` page picks
its shot only from `sessionStorage['dye_editShotId']`, and skins are served on a
different browser origin than plugin pages. The fix is roughly five lines of
plugin-side JavaScript and it can only be written in the plugin's own repository,
`decentespresso/dye2` — no version of that page exists in this tree.

The app side already works. `plugins_handler.dart` forwards every query
parameter into the dispatched request as `HttpRequest.query`, so
`edit-shot?shotId=<id>` reaches `__httpRequestHandler` today, and a served page
can read the same URL through `location.search`. Measured on the bench: the
parameter arrives with a 200 and the page ignores it. So the requirement being
put to the plugin owner rests entirely on an app behaviour that was never
tested and never written down.

This commit closes both gaps and changes no app code.

The test loads a plugin that echoes `request.query` back and asserts a request
carrying a shotId-shaped UUID plus a percent-encoded `return` surfaces both,
decoded. A second test pins that `query` is present and empty when the URL has
none, so a plugin may read `request.query.shotId` without guarding — the exact
call the DYE2 change will make on a no-parameter load.

The pin bites. Deleting the `'query': req.url.queryParameters` line fails both
new tests and NOT ONE of the eleven that were already in the file, which is the
whole point: the existing suite exercised the dispatch path without ever
observing the query map, so a refactor could have dropped it in silence.

`doc/Plugins.md` gains a "Serving HTTP Endpoints" section. The guide documented
outbound `fetch` and mentioned that declared HTTP endpoints need the `api`
permission, but never showed a `"type": "http"` manifest entry, never described
`__httpRequestHandler`, and never named the `query` field at all. A plugin
author had to read app source to learn the contract this handoff depends on.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review fix. The new section taught only __httpRequestHandler — the name in
host.d.ts and the dispatcher — while the test alongside it defines
handleHttpRequest, which the plugin loader aliases to __httpRequestHandler
at load (plugin_manager.dart). Both are valid; say so, so the doc and the
test stop looking contradictory to a plugin author reading both.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@tadelv tadelv left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One required documentation fix: the proposed return navigation pattern needs to define a working, constrained cross-origin return target. The HTTP/query contract and tests otherwise match the current implementation and repository guidance.

Comment thread doc/Plugins.md
Vid's review: the example `return=/skin/history` is not a usable return
contract. A plugin page runs on the API origin, so that path resolves
against the API origin rather than the skin server, and accepting an
absolute URL instead would be an open redirect.

The doc now states the rule rather than implying one. `return` is a
navigation target, not a REST lookup key, and it gets its own section:

  1. Require a skin-local path - a single leading slash, no `//`, no
     scheme. Nothing else is accepted.
  2. Read the skin origin from GET /api/v1/webui/server/status, which
     answers {serving, path, port, ip}. The page never learns the origin
     from the parameter.
  3. Join them with new URL(path, skinOrigin), which cannot escape that
     origin once the path is known to be skin-local.

A worked example returns null when the skin is not being served or the
value was not skin-local, so a page shows its own way out instead of
navigating somewhere wrong.

The section also says to read the origin on every use. Decaid assigns
the skin server its port, so a remembered origin can point at a port
nothing is listening on.

The general guidance above it now says most parameters are lookup keys
and names `return` as the exception, with a cross-reference from the
example.

Documentation only. No code and no behaviour change; the lane's
plugins_handler_test still passes 13 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@tadelv tadelv left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The original return-target ambiguity is mostly addressed, but the new worked example still needs one correction before merge: do not use server/status.ip as the browser origin. On Android that value is the 0.0.0.0 bind address, while the skin origin is based on the host the browser actually used plus the current backing port. Preserve the current page host, replace the port from /api/v1/webui/server/status, and validate the parsed target's origin after new URL(...) so alternate slash parsing cannot escape it. I left the concrete details on the existing thread. Everything else in this PR still looks consistent with current main, AGENTS.md, and the existing plugin HTTP implementation/tests.

The worked example built the skin origin from the status response's ip. On
Android WebUIService binds 0.0.0.0 and serverIP() returns the bind address,
so that value is not an origin a browser can reach. The example now keeps
the host the browser used and replaces only the port, which is what the
fixed :3000 entry point does when it redirects.

The leading-slash test was also not sufficient on its own. The URL parser
treats a backslash as a slash in an http URL and strips tab and newline
before parsing, so /\example.invalid passed the test and resolved to
another host. The example now parses the target and requires its origin to
equal the skin origin, and the port is required to be an integer so a null
port cannot leave the origin on the API port.

Documentation only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ChampionDesigns

Copy link
Copy Markdown
Collaborator Author

Both points fixed in b1844a9a. Documentation only.

The origin. You are right that status.ip is not an origin. On current
main, WebUIService._serveFresh binds 0.0.0.0 and serverIP() returns
_server.address.address on Android, so that value is the bind address. The
worked example now keeps the host the browser actually used and replaces only
the port with the live port from /api/v1/webui/server/status — which is what
the fixed :3000 entry point does when it redirects. The section says so
explicitly, so nobody reaches for ip again.

The parser. Confirmed against the real WHATWG parser before writing it:
/\example.invalid passes raw.startsWith('/') && !raw.startsWith('//') and
resolves to http://example.invalid. So does /<tab>/example.invalid, since
the parser strips tab and newline before parsing. The example now parses the
target and requires target.origin === skinOrigin.origin, with the leading-slash
test demoted to a first filter. It also requires port to be an integer, so a
null port cannot leave the origin sitting on the API port.

I extracted the code from the doc and ran it against the hostile cases:
/\example.invalid, //example.invalid, /<tab>/example.invalid,
https://example.invalid/, a bare relative path and an empty value all return
null. /skin/history resolves onto the skin origin, query and fragment intact.

@tadelv tadelv left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previous review concern is resolved. The return-path guidance now preserves the browser host, substitutes only the live skin port, and verifies the parsed target stays on the reconstructed skin origin. Query pass-through tests and documentation remain consistent with the implementation and repository guidance. Current PR checks pass. GitHub still reports a merge conflict with current main, so the branch needs to be updated/rebased before merge, but I found no remaining blocker in the PR's changes themselves.

@tadelv tadelv left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previous review concern is resolved. The return-path guidance now preserves the browser host, substitutes only the live skin port, and verifies the parsed target stays on the reconstructed skin origin. Query pass-through tests and documentation remain consistent with the implementation and repository guidance. Current PR checks pass. GitHub still reports a merge conflict with current main, so the branch needs to be updated/rebased before merge, but I found no remaining blocker in the PR's changes themselves.

@tadelv

tadelv commented Sep 10, 2026

Copy link
Copy Markdown
Member

@ChampionDesigns please rebase on origin/main and resolve the conflicts in doc/Plugins.md. Thanks!

@ChampionDesigns

Copy link
Copy Markdown
Collaborator Author

Done in b25f3883, with one deliberate difference from what you asked: I merged upstream/main into the lane rather than rebasing onto it.

Why. This branch is already published and you have already reviewed it. A rebase rewrites every commit on it, which means a force-push — and my pre-push hook refuses a non-fast-forward by design, precisely so a reviewed branch cannot move under a reviewer. Merging gets you the same thing a rebase would: the branch merges cleanly onto main, and the three-dot diff GitHub shows you is still only this lane's own work. Merging main in moves the merge-base to main, so nothing upstream leaks into the diff.

Verified after the merge:

  • ben/plugin-query-contract merges clean onto main.
  • The PR diff is still exactly two files — doc/Plugins.md (+128) and test/services/webserver/plugins_handler_test.dart (+109). Nothing upstream appears in it.
  • flutter analyze clean, dart format clean, the lane's 13 plugin-handler tests pass.

The conflict, and how I resolved it. It was not a simple add/add. Both sides changed the same two-line sentence about BLE-backed drivers:

  • main rewrote it to "BLE-backed drivers use the separate binding contract below; probing and grinder registration are not supported" and added ### BLE Driver Binding.
  • This lane left that sentence alone and added ## Serving HTTP Endpoints immediately after it.

I took main's wording — the lane has no opinion on that sentence, and the old text would have contradicted the new section. Ordering then mattered: ### BLE Driver Binding is an H3, so it had to stay ahead of this lane's new H2 or it would have been reparented under "Serving HTTP Endpoints". Both sections are otherwise preserved byte for byte — main's 62 lines and this lane's 127 — which I checked mechanically rather than by eye.

Happy to redo it as a true rebase under a new branch name if you would rather have the linear history; just say so.

@tadelv
tadelv merged commit d2d9a38 into decentespresso:main Sep 10, 2026
5 checks passed
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.

2 participants