Repository navigation
feat(sdk,auth): a web host — @HttpRoute pages and SSE routes through a hookable http:route flow, with loopback-token sign-in - #1020
Conversation
…kable http:route flow, with people auth and SSE from resource topics @HttpRoute classes listed in @app({ routes }) run through a new http:route flow on the Express host and the fetch handler alike: the IP filter, the global rate limit, a same-origin check on POST, PUT, PATCH and DELETE, people auth, then handle() with the app's providers, the ones its tools get. An SSE route sends handle()'s view at once and after each burst of publishes on its resource-store topics, with a heartbeat, and ends when the client leaves or the server shuts down. http.auth.people: 'loopback-token' (libs/auth) prints a token once at startup; the token buys single-use sign-in codes with a short TTL, and a code posted from the server's own page buys an HttpOnly, SameSite=Strict, Path-scoped session cookie. Tokens, codes and sessions are compared in constant time, and bad ones lock a client out. The dashboard page is now DashboardPageRoute, so it is served on every adapter and behind throttle.ipFilter, with its options unchanged.
…undle, check http.auth.people at boot, and type-check @HttpRoute in the dts consumer The @HttpRoute metadata schema now lives with the decorator, and the people auth schema is built on first use, so a browser bundle that declares neither carries neither. http.auth.people is parsed when the server builds its routes and fails with InvalidPeopleAuthOptionsError. The bundle check names the http-route modules as server-only.
…e dashboard page route and @HttpRoute in the decorators guide Also: build the route table from the scope in the server-only helper, check that a listed route is an @HttpRoute class at boot, and test a hook on the http:route flow, a tool that hands out sign-in codes, and the sign-in lockout over HTTP.
…ture, type the dashboard page spec, and export the route flow's state schema
…s response, and test the Secure session cookie over https
…-origin check only behind a trusted proxy A page can send X-Forwarded-Host itself once permissive CORS lets its preflight through, so naming its own host there passed a cross-site mutation. The route's same-origin check now reads it only with FRONTMCP_TRUST_PROXY, as the issuer and discovery URLs do. Also: the dashboard README lists the page route.
…lready left The Node writer wrote every chunk of an sse output at once, so a reader that stopped reading made the server buffer each view, and a client that left before the response was written was never noticed: its stream, subscription and heartbeat ran on. It now takes the next chunk only once the socket drains, and ends the iterator at once when the response is already destroyed.
…aims the same on both adapters - An SSE stream on a people route asks for the session before each view and heartbeat, and ends once it expired or was ended; it used to run on after sign-out. - The fetch handler tries the scopes' routes in the order the Express host mounts them, so a path that routes of two scopes serve goes to the same route on both adapters. - An SSE route on the MCP path fails startup: an EventSource always asks for text/event-stream, which the MCP endpoint serves there. - Tests for a code spent by two requests at once, a lockout that a new X-Forwarded-For does not dodge, a client that leaves before its stream opens, and more Origin cases.
…nto feat/m16-web-host # Conflicts: # apps/e2e/demo-e2e-dts-consumer/e2e/dts-consumer.e2e.spec.ts # libs/auth/src/index.ts # libs/sdk/src/errors/index.ts
|
@coderabbitai review |
|
Warning Review limit reachedYour organization has reached its usage spending cap. Adjust your spending cap in the billing tab. Next included review available in 28 minutes. View limit details
|
| Layer / File(s) | Summary |
|---|---|
Route and authentication contracts libs/auth/src/people/*, libs/sdk/src/common/decorators/*, libs/sdk/src/common/interfaces/*, libs/sdk/src/common/metadata/*, libs/sdk/src/common/types/options/http/*, libs/sdk/src/errors/* |
Adds route metadata, @HttpRoute, HttpRouteContext, app route registration, HTTP authentication options, and route-related errors. |
Loopback-token authentication libs/auth/src/people/*, libs/auth/src/index.ts |
Adds token and code exchanges, expiring sessions, client lockouts, cookie construction, configuration validation, exports, and tests. |
Route registration, execution, and SSE libs/sdk/src/http-route/* |
Adds route-table construction, built-in sign-in routes, request flow handling, SSE streams, and tests for route validation, authentication, responses, hooks, and stream lifecycle. |
Server and fetch-handler integration libs/sdk/src/scope/*, libs/sdk/src/transport/web-fetch-handler.ts, libs/sdk/src/server/*, libs/sdk/src/hooks/__tests__/* |
Registers routes with scopes, checks route claims in fetch handling, supports SSE backpressure and closure, and adds same-origin and hook-type coverage. |
Dashboard page route migration plugins/plugin-dashboard/src/app/*, plugins/plugin-dashboard/src/__tests__/*, plugins/plugin-dashboard/src/README.md, libs/skills/catalog/frontmcp-development/references/official-plugins.md |
Replaces dashboard page middleware with a route and updates dashboard tests and documentation. |
Web-host demo and E2E coverage apps/e2e/demo-e2e-web-host/* |
Adds a board app and E2E tests for sign-in, access controls, same-origin mutations, SSE updates, and shared provider state. |
SDK consumers and feature documentation apps/e2e/demo-e2e-dts-consumer/*, apps/e2e/demo-e2e-bundle-size/*, libs/skills/* |
Adds route declaration compilation coverage, updates browser bundle budgets and leak checks, and documents route setup, authentication, SSE, and flow behavior. |
Estimated code review effort: 4 (Complex) | ~60 minutes
Sequence Diagram(s)
sequenceDiagram
participant Browser
participant HttpRouteFlow
participant HttpRouteTable
participant LoopbackTokenAuth
participant SseRouteStream
participant ResourceStore
Browser->>HttpRouteFlow: Send route request
HttpRouteFlow->>HttpRouteTable: Match method and path
HttpRouteFlow->>LoopbackTokenAuth: Verify session when required
HttpRouteFlow->>SseRouteStream: Open SSE response
SseRouteStream->>ResourceStore: Subscribe to route topics
ResourceStore->>SseRouteStream: Notify topic update
SseRouteStream->>Browser: Send encoded event
Suggested reviewers: alexmercerpo
Merge Risk: 
Low · previous review 24acb
-
The new route guide misstates path syntax and does not warn that route registration order matters when a parameter route overlaps an SSE path. Readers following it could register a route that never fires. The code change itself looks sound, so these are small documentation fixes that can be made before or shortly after merge.
Pre-merge checks: 
4 · 
1
-
Failed checks: 1 inconclusive Passed checks: 4 passed-
Full details: Title check
-
Explanation
The title names the main change: a web host with
@HttpRoutepages and SSE routes through a hookable http:route flow, plus loopback-token sign-in. The changeset matches this, including the route classes, the http:route flow, SSE streams, and the people auth in libs/auth. The title is long (124 characters), but it stays specific and on topic.
Full details: Description check
-
Explanation
The description follows the template's required sections: 'What and why', 'User-facing change' (with what changed, before/after, frontmcp.dev pages, and skills catalog entries), and 'Tests'. It is detailed and covers the changes, the threat model, known limits, and test results. The 'Tests' section is thorough. The 'User-facing change' section is filled in, and the template's guidance on this section applies because the PR changes public APIs and error messages.
-
Full details: Docstring Coverage
-
Explanation
Docstring coverage is 30.95% which is insufficient. The required threshold is 65.00%. Docstring coverage is scoped to functions touched by this diff with cyclomatic complexity of at least 2. Documentation-only edits and deletions within surviving functions are included; unchanged diff context and fully deleted functions are excluded. Analyzed 42 functions across 50 files. (14 skipped: 12 unsupported, 2 over the file limit.)
Resolution
Write docstrings for the functions missing them to satisfy the coverage threshold.
-
Finishing Touches
-
Generate docstrings
-
- Commit to this branch
- Create a new PR
Generate unit tests (beta)
-
- Commit to this branch
- Create a new PR
-
- Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts
Action not completed
|
…nto feat/m16-web-host
Performance Test ResultsStatus: ✅ All tests passed Summary
Total: 101 tests across 21 projects 📊 View full report in workflow run Generated at: 2026-11-02T22:13:39.089Z |
…nto feat/m16-web-host # Conflicts: # apps/e2e/demo-e2e-bundle-size/e2e/browser-bundle-size.e2e.spec.ts
|
@coderabbitai review |
Action not completed
|
|
@coderabbitai review |
Action performed
|
… and document route order The route table matches literal segments and :name parameters only, but a path such as /files/*path or /board/:id(\d+) was accepted and then never matched any request. Such a path is now refused where the route is declared, with a message naming the syntax it takes. The create-http-route guide lists the SSE route before the parameter route it would otherwise be shadowed by, and says the first route that matches, in the order routes lists them, answers.
|
Local runs on a536682: SDK |
Stacked on #1019
What and why
Proposal item 2.6: a web host for people, on the same container.
The reported problem: a server that showed people a live view of what its agent worked on (a board, a queue of slow
queries) ran a second, hand-written HTTP server for it, with its own token login, host guard, same-origin check and SSE
hub, in its own process, rebuilding every piece of state the MCP server already held.
http.routesmounts handlers onthe MCP listener, but they get no DI, no people login, no same-origin check and no SSE.
What this PR adds:
@HttpRoute({ method, path, auth?, sse? })classes, listed in@App({ routes }).HttpRouteContextgivesthis.get()/this.tryGet()over the app's providers (the same instances its tools get),this.request,this.params,this.person,this.scope,this.fetch().handle()answers with anhttpRespondoutput as it is,an object or array as JSON, a string as text, nothing as
204; a thrownPublicMcpErrorkeeps its status, anythingelse is a
500without its message.pathis Express-style, or a function called once at boot (a path from options,which the dashboard needs for
basePath). Metadata is Zod-checked when the class is decorated(
InvalidDecoratorMetadataError); at boot a route on an MCP path (ReservedRouteCollisionError), a duplicate methodand path, a class that is not an
@HttpRoute, and a people route without people auth fail (InvalidHttpRouteError).http:routeflow (HttpRouteHook):checkIpFilter→findRoute→acquireQuota→checkSameOrigin→
authenticatePerson→handle. Adapters only translate: the Express host mounts the flow's middleware in eachscope ahead of
http:request, and the fetch handler lets a scope's route claim its request before the MCP entry pathand across scopes (a standalone app's route outside its base path included), trying the scopes in the order the
Express host mounts them, so a path that routes of two scopes serve goes to the same route on both. Both run the same
flow;
http.routesandserveHttpRouteWebare unchanged.GETroute on the MCP path (/by default) serves page loads there; aGETthat asks fortext/event-streamor carries
Mcp-Session-Idstill reaches the MCP endpoint, whose plain-GETanswer was405. Every other methodthere, and the other reserved paths, stay refused, and so does an SSE route there, which an
EventSource(alwaysasking for
text/event-stream) could never reach.sse: { store, topics, coalesceMs = 50, heartbeatMs = 15000 }:handle()makesthe view, sent at once and again after each burst of publishes on the topics (a
resourceStore()key publishes itsname), with a
: keep-alivecomment when idle. Only the latest view waits for a reader that falls behind: the Nodewriter now takes the next chunk of an
sseoutput only once a full socket drains (it wrote every chunk at once, so areader that stopped reading made the server buffer each view), and the fetch handler's stream is pull-based. The
stream ends, and unsubscribes, when the client disconnects (the Node writer ends an
sseoutput's iterator when theresponse closes, or at once when the client left before the response was written; the fetch handler's stream already
cancels it), on a people route when the person's session expires or ends (checked before each view and heartbeat),
and when the server shuts down (
Scope.shutdown()closes the streams before the host's drain, and SSE responses carryConnection: closeso a client's keep-alive socket does not hold the drain).libs/auth(LoopbackTokenAuth,peopleAuthOptionsSchema(),peopleSessionCookie()), turned onwith
http: { auth: { people: 'loopback-token' } }(or an object withcodeTtlMs,sessionTtlMs,maxFailedAttempts,lockoutMs,cookiePath):[frontmcp] people sign-in token: …);takeToken()hands it outonce, and nothing logs it;
POST /_frontmcp/people/codewithAuthorization: Bearer <token>returns a one-time code (single use, 60 s TTL bydefault, at most 10 min); a tool gets one without the token from
this.get(LoopbackTokenAuth).createCode();GET /_frontmcp/people/login?code=…serves a page whose form posts the code back, and signs nothing in itself;POST /_frontmcp/people/login(same-origin only) spends the code and setsfrontmcp_people=<random>; Path=<cookiePath>; Max-Age=…; HttpOnly; SameSite=Strict(+Secureover https); thecookie value is a fresh random value, never the token or a code;
timingSafeEqualfrom@frontmcp/utils;maxFailedAttempts(5) bad tokens or codes from a client lock it out forlockoutMs(60 s), agood code included;
unless they say
auth: 'public'.POST/PUT/PATCH/DELETE(isSameOriginRequest, next tohost-validation), with bugPR 05's Origin rules: an
Originis accepted when its host is the request'sHost(orX-Forwarded-Hostbehind aproxy trusted with
FRONTMCP_TRUST_PROXY), another loopback spelling of the same port, or an explicithttp.cors.origin/dnsRebindingProtection.allowedOriginsorigin (origin: trueor'*'lists none); without anOrigin,Sec-Fetch-Sitemust besame-originornonewhen present; with neither header (a CLI, an agent) therequest is allowed. On a loopback Node server PR 05's host validation refuses a cross-origin request before any flow;
this check also covers the fetch handler, routable binds and permissive CORS.
plugin-dashboardmoves onto it. The page isDashboardPageRoute, anauth: 'public'route atbasePathguarded by the dashboard's own token as before; the
dashboard:middlewareprovider is gone. The page now runs throughthe flow: behind
throttle.ipFilter(the old middleware skipped it) and oncreateFetchHandler()too (it answered404there). Public options are unchanged.PersonNotSignedInError(401,PERSON_NOT_SIGNED_IN),CrossOriginRequestError(403,CROSS_ORIGIN_REQUEST),SignInRefusedError(401,SIGN_IN_REFUSED),SignInLockedError(429,SIGN_IN_LOCKED);boot errors
InvalidHttpRouteError(INVALID_HTTP_ROUTE) andInvalidPeopleAuthOptionsError(
INVALID_PEOPLE_AUTH_OPTIONS).Threat model. People auth is for a person at the machine the server runs on; the attacker is a page on another site
in that person's browser, or a client on the network.
SameSite=Strictcookie); guessing the token, a code or a session (32 random bytes each, kept as SHA-256 digests,compared in constant time, plus the lockout); a code spent twice, also by two requests at once (the redemption is
synchronous); session fixation (the cookie value is minted at sign-in); an open redirect through
next(a path onthis server only); a lockout dodged by rotating
X-Forwarded-For(it is read only behind a trusted proxy); a slow SSEreader growing the server's memory (one pending view, and the Node writer waits for the socket); a client that leaves,
even before the stream opens, keeping a subscription; a stream outliving its session.
Originwith the request's ownHost, which a page on aDNS-rebound name sets to that name.
dnsRebindingProtectionrefuses such a host: by default on a loopback Nodeserver, and on
createFetchHandler()onceallowedHostsis set. A rebound page never carries the host-only sessioncookie, so people routes stay closed either way; public routes are as open as they are to any client.
the
GET, and the page sendsReferrer-Policy: no-referrer); cookies are not isolated by port, so another server onlocalhostreceivesfrontmcp_people, and signing in to one FrontMCP server onlocalhostsigns out of another(
cookiePathseparates them); the lockout is keyed by the peer address, so clients that share one (behind a proxythat is not trusted, or a fetch handler that is given none) share a lockout, and any of them can hold sign-in shut for
lockoutMs; there is no sign-out route yet (LoopbackTokenAuth.endSession()ends a session, and its open streams);sessions are in memory.
Deviations from the plan, with the reason:
@App({ routes }), not inhttp.routes.http.routesis server metadata that every scopecopies (a standalone app's scope included), so classes there would be mounted once per scope; an app gives a route the
providers its tools use, which is what "DI shared with tools" asks for.
http.routeskeeps its plain handlers.auth: { people: 'loopback-token' }lives underhttp(http.auth.people): it guards the web host, not the MCPendpoint, whose
authis a union of modes.http.auth.peopleis checked when the server builds its routes rather than in the eagerhttpoptions schema, so abrowser bundle that does not use people auth does not carry its schema.
User-facing change
@HttpRoute,HttpRouteContext,HttpRouteHook, thehttp:routeflow,@App({ routes }),http.auth.people,LoopbackTokenAuth(exported from@frontmcp/sdkand@frontmcp/auth), the sign-in routes under/_frontmcp/people/, and the error classes above. The dashboard page is an@HttpRoute.createFetchHandler():404→ served.throttle.ipFilter: not checked → checked (403for a denied address).404{"error":"The FrontMCP dashboard is disabled."}.sseoutput on the Node host: its iterator ran on after the client left, and every chunk was written at once →it is ended (
return()) when the response closes, or at once when the client left before the response waswritten, and the next chunk is taken only once a full socket drains. MCP 2026-07-28 streams use it too.
/_frontmcp/people/*collides with the sign-in routes when people auth is on.@HttpRouteon the MCP path fails startup withInvalidHttpRouteError(anEventSourcethere reaches theMCP endpoint).
/reference/sdk/http-route); the@Appreference(
routes); the HTTP configuration page (http.auth.people); the hooks page (HttpRouteHook, thehttp:routestages); the dashboard plugin page.
frontmcp-development/references/create-http-route.md(new, with its manifestrow),
frontmcp-development/SKILL.md,frontmcp-development/references/decorators-guide.md,frontmcp-development/references/official-plugins.md(dashboard),frontmcp-config/references/configure-http.md. Thecatalog check that refuses
auth: 'public'shorthand now skips@HttpRoute(lines, whereauthis the route's ownoption.
Browser bundle: +608 bytes minified on both measured bundles (174 of them are the Node writer's drain wait, which the browser build carries); see "Bundle size" below for the numbers and the budget change. The
http-route/modules are server-only and checked as such (http routesin thefeature-module check).
Tests
libs/auth/src/people/__tests__/loopback-token.auth.spec.ts(30): options and their refusals, the token handed outonce, single-use codes, code TTL, constant-time comparison, lockout per client and its end, session TTL, sign-out,
the cookie never being the token, the cookie attributes.
libs/sdk/src/server/security/__tests__/same-origin.spec.ts(22): own origin, a trusted proxy's forwarded host, aforwarded host the page set itself (also one a trusted proxy passed on, from a browser sending Fetch Metadata),
loopback aliases (IPv6 included), listed origins,
null, a sibling subdomain, an http page of an https host,Sec-Fetch-Sitevalues, CLI requests.libs/sdk/src/server/__tests__/write-http-response.sse.spec.ts(3): the Node writer waits for a full socket todrain, ends the stream when the client left before the response was written, and when it leaves while the socket is
full.
libs/sdk/src/http-route/__tests__/sse-route.stream.spec.ts(13): first view, coalescing a burst, a publish duringa view, latest view for a slow reader,
undefinedviews, failing views, heartbeat, close and unsubscribe, endingonce the reader is no longer authorized (at the next view and at the next heartbeat).
libs/sdk/src/http-route/__tests__/http-route.flow.spec.ts(56): the same scenarios on the Express host and thefetch handler: people auth, the sign-in pages, cookie attributes (
Secureover https), a code spent by two requestsat once, cross-site refusals (a spoofed
X-Forwarded-Hostincluded), response kinds, a route on the MCP path, SSEupdates and unsubscribe on disconnect, a signed-in stream ended with its session, SSE closed on shutdown, DI shared
with a tool, a tool handing out a code, lockout (
429, also with a newX-Forwarded-Foreach time), a plugin hookon the flow, the token printed once for several scopes,
throttle.ipFilter, a path that routes of twosplitByAppscopes serve going to the first on both adapters, and a client that leaves before its stream opens (held by a hook
on
handle) leaving no subscription on the Express host.libs/sdk/src/http-route/__tests__/http-route.validation.spec.ts(22): decorator refusals and boot refusals (an SSEroute on the MCP path included).
plugins/plugin-dashboard/src/__tests__/dashboard.page-route.spec.ts(8): the page through the fetch handler,basePath, disabled, token gate and cookies,Secure,throttle.ipFilter; the old middleware tests are replaced.libs/sdk/src/hooks/__tests__/fixtures/typed-hook-ctx.fixture.ts:HttpRouteHookadded to the typed hook check.demo-e2e-web-host(new, 7/7): the token printed once, sign-in with a one-time code posted from the page, a reusedcode refused, pages refused without a session, a code posted from another site refused, a cross-site mutation
refused, an SSE page hearing the change a tool made, a page and a tool sharing a provider.
demo-e2e-dashboard12/12,demo-e2e-dts-consumer26/26 (newhttp-routes-must-compile.ts),demo-e2e-bundle-size12/12,demo-e2e-protocol-20260728147/147,demo-e2e-notifications50/50 (the writerchange),
demo-e2e-security28/28,demo-e2e-standalone54/54 (legacy SSE included),demo-e2e-cimd11/11,demo-e2e-transport-recreation40/40.jestforsdk(622 suites, 8,705 tests),auth(100 suites, 2,841 tests),plugin-dashboard(13 suites,223 tests),
nx test skills;nx run-many -t lintand-t buildforauth,sdk,plugin-dashboard,skills; type checks of the new specfiles with
tsc --noEmit;node scripts/fix-unused-imports.mjs; prettier on every changed file.Bundle size
@HttpRoute, its option schema and thehttp:routeflow stub reach the browser bundle; SSE, people auth and the route runtime stay out (checked as server-only indemo-e2e-bundle-size). Measured against #1019's head (2e390cc, after the review fixes below it):create()+ one tool 1,332,315 → 1,332,923 bytes minified (+608; +242 gzipped),create()+ WebMcpPlugin + one tool 1,339,494 → 1,340,102 (+608; +255 gzipped). The two browser budgets move up from #1019's by exactly that, to 1,333,877 and 1,340,744 (24acbf1).Summary by CodeRabbit