fix(extension): normalize FC URL so credential push doesn't 405
CI / lint (push) Successful in 3s
extension / lint (push) Successful in 38s
CI / frontend-build (push) Successful in 40s
CI / backend-lint-and-test (push) Successful in 2m58s
CI / integration (push) Successful in 4m56s

The stored apiUrl was required to already carry the `/api` suffix, since
api.js builds requests as `${baseUrl}/credentials`. The options label read
"FC base URL", so entering the instance root -- the natural reading --
sent every request one path segment short: POST /credentials hit the Vue
SPA catch-all and came back 405, and GET /extension/manifest 404'd.

Worse, Test Connection reported success on it: the catch-all answers GET
/credentials with 200 HTML, so `r.ok` was true and the only affordance
meant to catch this misconfiguration actively masked it.

Normalize instead of validate (rules 92, 26):

- New lib/url.js: normalizeApiUrl / webRootFromApiUrl, one source shared
  by the background client and the options page. Accepts either the
  instance root or the API root.
- api.js normalizes on read, so configs already stored in the broken form
  heal themselves without the operator reopening Settings.
- options.js stores the canonical form, echoes back what it saved, and
  the test now asserts a JSON content-type -- killing the false green.
- 404/405 in request() now names the URL and points at the setting.
- Options label/placeholder state that both forms work.

Version 1.0.9 -> 1.0.10 in BOTH manifest.json and package.json; build.yml
resolves the release version from package.json, and a stale value there
would hit the cached ext-1.0.9 asset and republish the old XPI unsigned
against the new code.

Refs #2393

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude
2026-08-02 19:37:38 -04:00
parent 306de50f61
commit 8214afee1e
6 changed files with 79 additions and 16 deletions
+7 -3
View File
@@ -21,9 +21,12 @@
<body>
<h1>FabledCurator extension</h1>
<label for="api-url">FC base URL</label>
<input id="api-url" type="url" placeholder="http://curator.example.com/api" />
<div class="hint">Find this on FC → Settings → Maintenance → Browser extension.</div>
<label for="api-url">FC instance URL</label>
<input id="api-url" type="url" placeholder="http://curator.example.com" />
<div class="hint">
Your FabledCurator address — with or without the trailing <code>/api</code>; both work.
Find it on FC → Settings → Maintenance → Browser extension.
</div>
<label for="api-key">Extension API key</label>
<input id="api-key" type="password" placeholder="paste from FC Settings card" />
@@ -36,6 +39,7 @@
<div id="status" class="status" style="display:none;"></div>
<script src="../lib/url.js"></script>
<script src="options.js"></script>
</body>
</html>
+23 -5
View File
@@ -8,7 +8,7 @@ document.addEventListener('DOMContentLoaded', async () => {
});
async function save() {
const apiUrl = document.getElementById('api-url').value.trim().replace(/\/+$/, '');
const apiUrl = normalizeApiUrl(document.getElementById('api-url').value);
const apiKey = document.getElementById('api-key').value.trim();
if (!apiUrl || !apiKey) {
showStatus('Both fields are required.', 'err');
@@ -16,11 +16,14 @@ async function save() {
}
await browser.storage.local.set({ apiUrl, apiKey });
await browser.storage.local.remove(['lastConnectionTest', 'lastConnectionStatus']);
showStatus('Saved.', 'ok');
// Show what was actually stored — the operator may have typed the instance
// root and it was normalized to the API root.
document.getElementById('api-url').value = apiUrl;
showStatus(`Saved — using ${apiUrl}`, 'ok');
}
async function test() {
const apiUrl = document.getElementById('api-url').value.trim().replace(/\/+$/, '');
const apiUrl = normalizeApiUrl(document.getElementById('api-url').value);
const apiKey = document.getElementById('api-key').value.trim();
if (!apiUrl || !apiKey) {
showStatus('Fill both fields first.', 'err');
@@ -31,8 +34,23 @@ async function test() {
method: 'GET',
headers: { 'X-Extension-Key': apiKey },
});
if (r.ok) showStatus(`Connected — HTTP ${r.status}.`, 'ok');
else showStatus(`HTTP ${r.status}: ${r.statusText}`, 'err');
if (!r.ok) {
showStatus(`HTTP ${r.status}: ${r.statusText}`, 'err');
return;
}
// A 200 is NOT sufficient. If the URL resolves to the Vue SPA instead of
// the JSON API, the catch-all route returns 200 with an HTML document —
// which used to report "Connected" on a config that could not POST at all.
const contentType = r.headers.get('content-type') || '';
if (!contentType.includes('json')) {
showStatus(
`${apiUrl} answered with ${contentType || 'no content-type'}, not JSON `
+ '— that looks like the FC web UI rather than its API.',
'err',
);
return;
}
showStatus(`Connected to ${apiUrl} — HTTP ${r.status}.`, 'ok');
} catch (e) {
showStatus(`Cannot reach ${apiUrl}: ${e.message}`, 'err');
}