Skip to content

feat(http): serve the openai apps domain verification challenge - #228

Merged
Jerod Santo (jerodsanto) merged 3 commits into
mainfrom
feat/openai-apps-challenge
Nov 6, 2026
Merged

Jerod Santo (jerodsanto) merged 3 commits into
mainfrom
feat/openai-apps-challenge

Conversation

@jerodsanto

@jerodsanto Jerod Santo (jerodsanto) commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

The OpenAI plugin portal verifies domain control by probing a fixed path and comparing the response body against a token it issues. This adds that route, which is the last hard blocker on the ChatGPT plugin submission.

Inert until configured. With SOCKET_MCP_APPS_CHALLENGE_TOKEN unset the path serves the ordinary 404, so merging and deploying this changes nothing observable.

The route - token in, token out, nothing else

GET /.well-known/openai-apps-challenge returns the configured token as text/plain; charset=utf-8 with an explicit Content-Length. No JSON envelope, no trailing newline, no token list - the portal compares the body byte for byte.

Condition Response
Token set, GET or HEAD 200 with the token (HEAD sends headers only)
Token unset 404 Not found, the existing catch-all
Any write method 404 Not found

A deployment with no token configured can never report itself as verified: the absent case is a 404, not an empty 200.

Why it answers ahead of the OAuth gate

The portal probe carries no credential and no Origin header. The route is placed after the existing origin/host validation, which already admits an absent Origin on an allowed host, and before authentication - an authenticated challenge endpoint could never be verified.

Refactor: writeOauthMetadata extracted

The new branch pushed routeRequest to a cyclomatic complexity of 17 against the repo ceiling of 15. Rather than suppress the rule, the RFC 9728 protected-resource metadata block moved into writeOauthMetadata(), which reports whether it answered. Behavior is unchanged and the existing http-server tests cover it as before.

Validation

Ran: 12 new tests in test/repo/unit/apps-challenge.test.mts, plus the existing http-server and env suites - 74 passing, no routing regression. tsc --noEmit reports 0 errors repo-wide. oxlint is clean on every file touched.

Three of the new tests assert the response body contains no \n, no {, and equals its own trim(), because the portal rejects anything but the bare token.

Did not run: an end-to-end portal verification, which needs the deployed route plus a token that the portal only issues after Connect in the plugin dashboard.

Follow-ups, not in this PR:

  • Set SOCKET_MCP_APPS_CHALLENGE_TOKEN in the deployment environment after the portal issues the token, then click Verify Domain.
  • plugins/chatgpt/ packaging and its validation script, which is a separate branch.
  • review.demo_recording_url in plugin.json, recorded after the developer-mode install.

Note

Low Risk
Adds an optional, opt-in public endpoint with no change to MCP/OAuth behavior when the token is unset; routing refactor preserves existing OAuth metadata semantics.

Overview
Adds OpenAI ChatGPT plugin domain verification on the HTTP MCP server: when SOCKET_MCP_APPS_CHALLENGE_TOKEN is set, GET/HEAD on /.well-known/openai-apps-challenge returns the portal token as plain text/plain (no JSON, no trailing newline). The route is handled after origin/host checks and before OAuth/MCP auth so unauthenticated portal probes succeed; with no token configured the path still 404s, so deploys stay inert until configured.

OAuth protected-resource metadata handling is extracted to writeOauthMetadata() in http-server.mts (behavior unchanged). Docs cover the new env var, endpoint behavior, and release-checklist steps (challenge URL, demo_recording_url).

Unit tests cover request matching, response shape, and full routeRequest integration.

Reviewed by Cursor Bugbot for commit 150e8cb. Configure here.

@vasco-santos

Copy link
Copy Markdown
Contributor

[agent] Minor maintainability concern: routeRequest now combines request validation, CORS, public endpoint dispatch, and MCP authentication/dispatch. Adding this endpoint also required extracting OAuth metadata handling to stay within the complexity limit, which suggests the routing responsibility deserves a clearer boundary.

Could we extract a small public-route dispatcher as part of this change? Explicit path/method checks would be sufficient; no framework or generic route registry needed. That would keep the main request flow easier to read and give subsequent endpoints a consistent home. Preserve the existing ordering, particularly health bypassing origin validation and public metadata/challenge routes answering before MCP authentication.

@jerodsanto

Jerod Santo (jerodsanto) commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor Author

Vasco Santos (@vasco-santos) good call — done in 469e2e7c. [agent]

routeRequest is now a flat spine, and the endpoints that answer without an MCP credential live in lib/http-public-routes.mts.

One deliberate deviation from the ask: two dispatchers rather than one.

dispatchUnvalidatedRoute(res, pathname)         // /health
dispatchPublicRoute(req, res, pathname, port)   // oauth metadata, apps challenge

The ordering you asked to preserve is the reason. /health answers before origin validation so a container probe needs no origin configuration, while the metadata and challenge probes answer after CORS and before authentication. A single entry point could only cover all three routes by moving one across that boundary, or by taking a stage flag — and no-boolean-trap-param rules out the flag. Two named functions put the trust boundary in the names instead of behind a parameter.

Nothing else moved: the spine runs parse → health → origin/host → CORS/OPTIONS → public routes → 404 → authenticate → MCP dispatch, in that order.

writeOauthMetadata moved into the new module, so it now sits among peers rather than existing only to hold routeRequest under the complexity ceiling. That extraction was the symptom you spotted.

While pinning the ordering I found the existing coverage did not actually test it. answers /health without origin validation sent no Origin header at all, and an absent origin passes validation anyway — so it would have stayed green even if the bypass were lost. It is replaced by a request carrying a rejected origin, plus a control asserting that same origin gets a 403 on the MCP path; without the control the first test proves nothing.

Verification: 99 tests across the routing suites, 483 in the full unit run, types and lint clean including the complexity rule.

CI on this PR is red on fleet payload hydration (install-fleet: no verified fleet pack is available locally or from GHCR). That also fails on #230 and on main, and it is unrelated to this change.

@vasco-santos

Copy link
Copy Markdown
Contributor

[agent] The updated routing looks good to me and addresses the maintainability concern. The two dispatchers keep the health exception explicit and preserve origin/CORS checks before public metadata and challenge handling.

All 42 local HTTP checks passed against the updated code, covering token responses, rejected origins, the health bypass, and the MCP authentication requirement. CI is still red, but it fails during fleet payload setup before the tests run: install-fleet: no verified fleet pack is available locally or from GHCR.

John-David Dalton (@jdalton) could you take a look at the CI setup failure? I found no new actionable issues in the routing change.

@jdalton

Copy link
Copy Markdown
Collaborator

We landed some fixes Jerod Santo (@jerodsanto) and Vasco Santos (@vasco-santos), update the branch and see if it passes 🙏

The OpenAI plugin portal verifies a domain by probing a fixed path and
comparing the body against a token it issues. Serve that token from
SOCKET_MCP_APPS_CHALLENGE_TOKEN at /.well-known/openai-apps-challenge as
text/plain, with no JSON envelope and no trailing newline.

The probe carries no credential and no Origin header, so the route answers
ahead of the OAuth gate. A deployment that configures no token serves the
ordinary 404 rather than an empty 200, so an unconfigured environment can
never report itself as verified.

Extract writeOauthMetadata to keep routeRequest inside the complexity
ceiling the new branch would otherwise exceed. Behavior is unchanged.
routeRequest carried request validation, CORS, public endpoint dispatch, and
the authenticated MCP path in one function, and the domain verification
endpoint only fit by extracting OAuth metadata handling to stay under the
complexity ceiling. That extraction treated the symptom.

Move the endpoints that answer without an MCP credential into
http-public-routes.mts behind two dispatchers, each reporting whether it
answered. Ordering is unchanged: health still answers before origin
validation, and the metadata and challenge probes still answer after CORS
and before authentication.

Two dispatchers rather than one because that ordering is the point. A single
entry point could only cover all three routes by moving one across the trust
boundary or by taking a stage flag, and the boundary is what a reader needs
to see.
@jerodsanto

Copy link
Copy Markdown
Contributor Author

Yup all good John-David Dalton (@jdalton), thanks! I'm going to merge this then I'll work with Vasco Santos (@vasco-santos) on getting the new env var all set up

@jerodsanto
Jerod Santo (jerodsanto) merged commit dd0454f into main Nov 6, 2026
12 checks passed
@jerodsanto
Jerod Santo (jerodsanto) deleted the feat/openai-apps-challenge branch November 6, 2026 14:21
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.

3 participants