docs(plugins): the http endpoint contract, and the query pass-through pinned - #796
Conversation
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
left a comment
There was a problem hiding this comment.
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.
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
left a comment
There was a problem hiding this comment.
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>
|
Both points fixed in The origin. You are right that The parser. Confirmed against the real WHATWG parser before writing it: I extracted the code from the doc and ran it against the hostile cases: |
tadelv
left a comment
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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.
|
@ChampionDesigns please rebase on origin/main and resolve the conflicts in |
…ntract # Conflicts: # doc/Plugins.md
|
Done in 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 Verified after the merge:
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:
I took Happy to redo it as a true rebase under a new branch name if you would rather have the linear history; just say so. |
Summary
Two things about the plugin HTTP endpoint contract that were true but undocumented, plus the tests
that pin them.
doc/Plugins.mdgains a Serving HTTP Endpoints section: how anapientry of"type": "http"exposes a plugin at/api/v1/plugins/:id/:endpoint, and what therequestobject carries.
handleHttpRequestalias. A method of that name on the objectcreatePluginreturns 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.dartgains 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
returncontract, as you asked. You were right thatreturn=/skin/historywas not a usable contract: a plugin page runs on the API origin, so thatpath resolves there rather than on the skin server, and accepting an absolute URL instead would be
an open redirect.
doc/Plugins.mdnow givesreturnits own section and states the rule: require a skin-localpath — one leading slash, no
//, no scheme, nothing else accepted; read the skin origin fromGET /api/v1/webui/server/status, which already answers{serving, path, port, ip}and is alreadyin
rest_v1.yml; then join the two withnew URL(path, skinOrigin), which cannot escape thatorigin once the path is known to be skin-local. The worked example returns
nullwhen the skin isnot 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
returnis named as the exception, with a cross-reference from the example.Documentation only — no code and no behaviour change.
flutter analyze— clean.flutter test— full suite 3893 passed / 1 skipped, run against currentmainon5 Sep 2026.
flutter test—plugins_handler_test13 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.dartonmain, notassumed 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
doc/Plugins.mdgains a section. No behaviour changes.Contributor Responsibility
AI-assisted development is allowed. The submitter remains responsible for the submitted work.