Skip to content

A page where JSON was expected names the portal, not the doctype - #117

Merged
unitypark merged 1 commit into
mainfrom
fix/an-html-answer-says-so
Aug 17, 2026
Merged

A page where JSON was expected names the portal, not the doctype#117
unitypark merged 1 commit into
mainfrom
fix/an-html-answer-says-so

Conversation

@unitypark

Copy link
Copy Markdown
Owner

Reported from the wizard's GitLab review-credential check:

✕ Unexpected token '<', "<!DOCTYPE "... is not valid JSON

The chain, exactly

  1. Check token POSTs /projects/:slug/connections/vcs with
    reviewProvider: 'gitlab'.
  2. The server calls GitLabAdapter.verify()GET {instance}/api/v4/user.
  3. An access portal in front of the instance answers with its own sign-in
    page, at 200
    . The request never reached GitLab.
  4. res.ok is true, so the error branch is skipped and res.json() throws a
    SyntaxError.
  5. connectVcs passes err.message through for any Error, so that string
    becomes the 400 body.
  6. The wizard renders it verbatim.

A JSON parser describing the first character of a web page, presented as the
diagnosis.

The fix

Both adapters now read the body as text and parse it themselves. A 2xx that
is not JSON becomes a VcsError naming the likely cause:

https://…/api/v4/user answered with an HTML page rather than JSON. That is
usually an SSO or access portal in front of the instance: it serves its own
login page at 200, so the request never reached the API. specd talks to the
API directly with a token, and cannot complete a browser sign-in — the
instance has to be reachable from this machine without one, or the token has
to be accepted by whatever sits in front of it.

  • Non-HTML bodies are quoted rather than guessed at (upstream connect error is a proxy, not a portal).
  • 204 keeps its early return. An empty body is not a broken one, and
    propose deletes a branch on every run — pinned by a test that drives the
    whole propose sequence rather than the private method.
  • The GitHub adapter gets the same guard; Enterprise Server sits behind exactly
    these portals.

The web client had the same hole on the other side: it handles a non-JSON
error body carefully and then does an unguarded JSON.parse on the success
path. That one fires when something answers in the API's place — a dev server
on the same port, a proxy — and it now says so and names the base URL it was
talking to.

This is the same class as the transport guard added alongside decision 0020: a
failure that is not an HttpException reaching a person as an opaque 500 or a
parser error. Two more instances, closed the same way.

What this does not fix

Nothing here makes the connection work. If a portal intercepts /api/v4, no
token specd holds will help — it speaks the API directly and cannot complete a
browser sign-in. The resolutions are environmental, and both docs/gitlab.md
and the published GitLab page now say so:

  • reach the instance by a network path the portal does not intercept (often the
    VPN itself rather than the public hostname);
  • have the portal admit requests carrying PRIVATE-TOKEN;
  • or run specd where the API is directly reachable.

Making the error honest is the whole of this change. It turns an unanswerable
symptom into a question someone's platform team can act on.

Verify

pnpm typecheck && pnpm test, plus pnpm build and pnpm site:check — 1,764
links, none broken. 4 new adapter tests covering the portal page, a non-HTML
body, the VcsError type (so the controller answers 400 and not 500), and the
204 path.

Reported as `Unexpected token '<', "<!DOCTYPE "... is not valid JSON` on the
GitLab review-credential check. The chain is exact: an access portal in front
of a corporate instance answers `GET {instance}/api/v4/user` with its own
sign-in page at 200, so `res.ok` is true, `res.json()` throws a SyntaxError,
and `connectVcs` — which passes `err.message` through for any Error — hands
that string to the wizard verbatim. A JSON parser describing the first
character of a web page, presented as the diagnosis.

Both adapters now read the body as text and parse it themselves, so a 2xx that
is not JSON becomes a VcsError naming the likely cause: a portal that serves
its login page at 200, which specd cannot work around because it speaks the API
with a token and cannot complete a browser sign-in. Non-HTML bodies are quoted
rather than guessed at. 204 keeps its early return — an empty body is not a
broken one, and `propose` deletes a branch on every run.

The web client had the same hole on its success path: it handles a non-JSON
*error* body carefully and then does an unguarded `JSON.parse` on a 2xx. That
one fires when something answers in the API's place — a dev server on the same
port, a proxy — and it now says so, and names the base URL it was talking to.

This is the same class as the transport guard in 0020's follow-up: a failure
that is not an HttpException reaching a person as either an opaque 500 or a
parser error. Two more instances of it, closed the same way.
@unitypark
unitypark merged commit 11c32b6 into main Aug 17, 2026
2 checks passed
@unitypark
unitypark deleted the fix/an-html-answer-says-so branch August 17, 2026 17:09
@unitypark
unitypark restored the fix/an-html-answer-says-so branch August 17, 2026 17:13
@unitypark
unitypark deleted the fix/an-html-answer-says-so branch August 17, 2026 17:14
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.

1 participant