The permission model being built reads the HTTP method to decide whether a
non-owner may make a call: safe methods are reads, everything else is a write.
That only works if the method tells the truth. These five read something and
returned it while announcing themselves as writes, so a member would have been
denied a read they are entitled to because of a habit in how the route was
declared.
/api/file-browser/video-info POST {url} -> GET ?url=
/api/file-browser/video-playlist POST {url} -> GET ?url=
/api/server-settings/ocr/models POST {url} -> GET ?url=
/api/transmission/_officer/port-test POST -> GET
/api/jellyfin/_config/:id/test POST|GET -> GET only
The last one already answered to both, which is worse than either: a method that
means nothing cannot be the thing authorisation reads.
Deliberately stops at five. A sweep of all 100 mutating routes found many more
reads wearing POST, and they are staying, for two reasons that are not going
away: some need a request body GET cannot carry (/stt takes multipart audio;
/tts, /ocr, /transcribe take payloads), and some carry a credential, where a
query string is the wrong place — access logs, shell history and Referer headers
all capture those, request bodies do not (/tts/voices takes an apiKey, the four
/test endpoints take connection secrets, /local-providers/probe takes auth).
So the method alone can never carry the permission model, and the registry will
need an explicit per-route classification regardless. Converting these five is
worth it because it is free; converting the rest would be a breaking change
across 117 mobile call sites that buys nothing.
Web callers updated in the same commit; the sidecar contract comments now match.
Mobile has exactly one caller to change — transmissionPortTest in
packages/core/src/services/transmission.ts — and no shim was added, because an
endpoint answering to both methods is the problem this commit exists to fix.
docs/api-method-changes-2026-08-06.md is the handoff for the mobile team: what
changed, the one line to edit, what deliberately did NOT change and why, and how
to verify.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
5.2 KiB
API method changes — 2026-08-06
For the mobile team. Five endpoints changed from POST to GET. One of them affects the mobile
app; the other four have no mobile caller. This is a breaking change with no compatibility shim — see
What breaks and when below.
Why
Officer is growing a permission model. Members will get access to specific apps, and within an app the
default is read everything, write only your own data. The rule that separates those two is the HTTP
method: safe methods (GET, HEAD) are reads, everything else is a write.
That only works if the method tells the truth. These five endpoints read something and returned it, while announcing themselves as writes — so under the new model a member would be denied a read they are entitled to, for no reason other than a habit in how the route was declared.
Nothing else about them changed: same path, same response shape, same auth.
The changes
| # | Endpoint | Was | Now | Mobile affected |
|---|---|---|---|---|
| 1 | /api/file-browser/video-info |
POST body {url} |
GET ?url= |
no |
| 2 | /api/file-browser/video-playlist |
POST body {url} |
GET ?url= |
no |
| 3 | /api/server-settings/ocr/models |
POST body {url} |
GET ?url= |
no |
| 4 | /api/transmission/_officer/port-test |
POST no body |
GET |
YES |
| 5 | /api/jellyfin/_config/:id/test |
POST body {} |
GET |
no |
Note on #5: it already accepted GET as well as POST. It is now GET only, so that the method is a
reliable signal rather than "whichever the caller felt like".
What the mobile app has to change
One line. packages/core/src/services/transmission.ts:126
// before
export const transmissionPortTest = () =>
request<{ open: boolean }>(`${T}/port-test`, { method: 'POST' });
// after
export const transmissionPortTest = () =>
request<{ open: boolean }>(`${T}/port-test`);
The response is unchanged: { open: boolean }.
I searched monorepo-mobile/packages and monorepo-mobile/apps for callers of the other four and
found none. If you know of one outside those trees, it needs the same treatment — path and response are
identical, only the method and the location of url move.
For the three that take a url, it moves from the JSON body to a query parameter and must be
percent-encoded:
`${base}/file-browser/video-info?url=${encodeURIComponent(url)}`
What breaks and when
port-test returns 405 to a POST from the moment the platform is deployed. There is deliberately
no transitional shim accepting both — the whole point of the change is that the method means something,
and an endpoint answering to both methods means nothing.
The blast radius is one button in the Transmission screen ("test peer port"). It does not affect torrents, downloads, or anything else in that app. If that is still unacceptable timing, a shim is a two-line change on the platform side — ask and it can go in, with a date for removal.
What did NOT change, and will not
Several endpoints look like the ones above but are staying POST on purpose. If you are tempted to
"fix" them for consistency, please don't — both reasons below are deliberate.
They need a request body that GET cannot carry:
POST /api/chat/stt— multipart audio uploadPOST /api/file-browser/tts,/tts-text,/ocr,/transcribe— payloads to transform
They carry a credential, and a query string is the wrong place for one. Query strings are written to
access logs, shell history, proxy logs and Referer headers; request bodies are not:
POST /api/server-settings/tts/voices— takes anapiKeyPOST /api/server-settings/{smtp,tts,stt,ocr}/test— take connection secretsPOST /api/server-settings/local-providers/probe— takes{url, auth}
These are reads that must remain POST. The permission model handles them with an explicit annotation
rather than by inferring from the method, which is why the method change stops at five endpoints instead
of sweeping the whole API.
Everything else that is POST/PUT/PATCH/DELETE genuinely mutates something and is unaffected.
Verifying
Against a running platform, with a valid token:
# should be 200
curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $TOKEN" \
"$BASE/api/transmission/_officer/port-test"
# should be 405
curl -s -o /dev/null -w '%{http_code}\n' -X POST -H "Authorization: Bearer $TOKEN" \
"$BASE/api/transmission/_officer/port-test"
Platform-side changes, for reference
Backend:
src/servers/api/file-browser/router.ts—/video-info,/video-playlistsrc/servers/api/server-settings/ocr.ts—/modelssrc/servers/sidecar/transmission/routes.ts—handlePortTestsrc/servers/sidecar/jellyfin/config.ts— thetestaction
Web callers, already updated:
src/workspaces/officerdev/src/hooks/useFilesAPI.tssrc/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/OCRSection.tsxsrc/workspaces/officerdev/src/apps/Transmission/useTransmissionData.tssrc/workspaces/officerdev/src/apps/Jellyfin/useJellyfinData.ts
The sidecar contract comments at the top of transmission/index.ts and jellyfin/index.ts were updated
to match.