You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Mini App loopback credential guard and readiness #253
A Mini App's own loopback server rejects requests that do not carry the host-issued credential, and a
Mini App counts as ready only when that guard is in the path rather than merely listening.
Acceptance criteria
A request without the credential is answered 401 with no body; the same request with it reaches
the plugin's own handler.
The exact loopback host is required, and an upgrade or connect attempt without the credential is
destroyed rather than answered.
Plugin code never observes the credential header.
A reserved health path is answered by the guard itself, and readiness fails when an anonymous
request is not rejected.
The credential is minted per process generation and never appears in a published view of a Mini
App, its surface path, or the client's data.
Implementation is up in #262 (branch feat/miniapp-loopback-guard, commits 7a79dab0 + 32d01c2e, stacked
on #261). Three of the five criteria are proven by tests; two are implemented and reviewed but have no
execution evidence yet, so this ticket stays open and the comment names exactly what is missing.
Proven
1. A request without the credential is answered 401 with no body; the same request with it reaches the
plugin's own handler.packages/local-runtime-v2/test/unit/miniapp/loopback-guard.test.ts drives a real http.Server through the real startMiniAppModule seam (loadModule injected, everything else real):
answers an unauthenticated request with 401 and no body → status 401, body ''
answers a request carrying the wrong credential with 401
reaches the plugin handler with the right credential and hides the header from it → 200 with the
plugin's own body
2. The exact loopback host is required, and an upgrade without the credential is destroyed rather than
answered.
rejects the right credential on the wrong host → the credential with Host: localhost:<port> is
answered 401, because the guard compares the exact 127.0.0.1:<port> string
destroys an upgrade without the credential and forwards one that carries it → the client socket sees
no bytes at all, and the plugin's upgrade listener is never called; the credentialed upgrade gets the
plugin's 101
3. Plugin code never observes the credential header. Same case asserts observed[0].headers has no x-mavis-miniapp-token and rawHeaders contains no such entry, while the
request that reached the handler did carry it (both headers and rawHeaders are stripped).
Red proof for the above: with the installMiniAppLoopbackGuard(...) call removed, 6 of 7 cases fail. The seventh is the init-message validator case, which does not depend on the
install.
4. A reserved health path is answered by the guard itself, and readiness fails when an anonymous
request is not rejected.
The first half is proven: answers the reserved health path itself, before the plugin handler gets 204
for the credentialed request, 401 for the anonymous one, and the plugin handler is never called.
The second half is only implemented. node-runtime now requires anonymous → 401 followed by
credentialed → 204 in probeLoopbackAuth, with READINESS_PROBE_REJECTED for a wrong status and READINESS_PROBE_TIMEOUT for a stalled probe; the loop stops retrying once a port answers wrongly
(isProbeRejection). No test exercises this: the probe lives in node-runtime, which has no test
harness in this repository — the shipped package's node-runtime.test.ts brings one (1999 lines) and it
has not been adopted. Closing this criterion needs that harness, or a narrower one that stubs spawn.
5. The credential is minted per process generation and never appears in a published view, its surface
path, or the client's data.
Proven by construction and type, not by test:
minted at node-runtime.ts:228 — prepareRuntime, i.e. once per process generation and outside the
port-attempt loop, so a port retry reuses one credential
carried on the init message and kept on MiniAppNodeRuntime as loopbackToken, documented
server-only (contracts.ts:104)
MiniAppPublishedView (contracts.ts:129-140) has no credential field, and no renderer contract
carries one, so nothing published, no surface path and no client payload can hold it
The per-generation property has no assertion behind it. A test would need to prepare two runtimes and
compare the credentials, which needs the same node-runtime harness as criterion 4.
Deliberately left to the proxy change
The supervisor does not yet expose the credential to the proxy. ADR 0020 counts "the supervisor's launch
credential so the proxy has something to inject" as part of the guard's cost, but the only consumer is
the proxy in #255, so this branch does not add a field nothing reads. If you would rather wire it here,
say so and I will extend this branch rather than #255.
Update at daf9a396 (PR #262): criterion 4 now has execution evidence too. Only criterion 5's
per-generation assertion is still open, so the ticket stays open on that one item.
Now proven — readiness fails when an anonymous request is not rejected
Readiness moved out of node-runtime.ts into process/node-runtime/loopback-readiness.ts
(waitForLoopbackReadiness), so it can be driven against real servers instead of a faked child. Four
cases in the same suite:
passes when the guard rejects the anonymous probe and answers the credentialed one — the guarded
fixture resolves
fails when the credentialed probe is rejected → READINESS_PROBE_REJECTED, authenticated health probe expected 204, received 401
fails when an anonymous probe is not rejected — a plain node:http server in a child process that
answers 200 to everything → READINESS_PROBE_REJECTED, unauthenticated health probe expected 401, received 200. This is exactly the failure the old TCP
connect could not see.
times out when nothing is listening → READINESS_TIMEOUT
So both halves of criterion 4 are now behavioural: the reserved path is answered by the guard itself
(204 / 401, plugin handler never called), and readiness refuses a socket that does not reject anonymous
requests.
Still open — criterion 5's per-generation property
The credential is minted at node-runtime.ts:228 in prepareRuntime, before the port-attempt loop, so
a port retry reuses one credential and a new process generation gets a new one. That placement is the
enforcement; no test asserts it, because the assertion needs a node-runtime harness that scripts a
child process through spawn (the shipped package has one at 1999 lines, not yet adopted here).
The other half of criterion 5 is structural and holds: MiniAppNodeRuntime.loopbackToken is documented
server-only, MiniAppPublishedView (contracts.ts:129-140) has no credential field, and no renderer
contract carries one, so no published view, surface path or client payload can hold it.
Two ways to close this ticket, your call:
Close on construction evidence — the property holds and is reviewable at the two line references
above; the missing piece is only a regression test.
All five criteria now have evidence. Closing at 33322479 (PR #262).
The gap in my previous comment — per-generation minting — needed the scripted-child harness this
repository did not have. It exists now: packages/local-runtime-v2/test/unit/miniapp/node-runtime-credential.test.ts
drives createMiniAppNodeRuntimeAdapter with a ChildProcess stand-in that runs the real runner
in-process, so the readiness probe meets the real guard while the child itself is scripted.
Criterion 4 — readiness fails when an anonymous request is not rejected (proven)
waitForLoopbackReadiness lives in process/node-runtime/loopback-readiness.ts and is driven against
real servers:
guarded fixture → resolves
wrong credential → READINESS_PROBE_REJECTED, authenticated health probe expected 204, received 401
plain node:http server in a child process answering 200 to everything → READINESS_PROBE_REJECTED, unauthenticated health probe expected 401, received 200
closed port → READINESS_TIMEOUT
The third case is the one the old TCP connect could not see: the socket accepts, the guard is absent.
Criterion 5 — minted per process generation, never published (proven)
mints one credential per preparation and hands that exact value down — one preparation calls makeLoopbackToken exactly once, the init message carries that value, and the runtime handle exposes
the same value after readiness passed for it
gives a new process generation a different credential — a second generation's init message carries
a different value
The leak half is structural: MiniAppNodeRuntime.loopbackToken is documented server-only, MiniAppPublishedView (contracts.ts:129-140) has no credential field, and no renderer contract
carries one
Criteria 1–3 (proven, unchanged from the previous comment)
packages/local-runtime-v2/test/unit/miniapp/loopback-guard.test.ts against a real http.Server
through the real startMiniAppModule seam: anonymous 401 with an empty body, wrong credential 401, the
right credential on the wrong Host 401, the plugin handler reached with no credential header visible
in headers or rawHeaders, the reserved health path answered 204/401 without reaching the plugin,
and an unauthenticated upgrade destroyed while a credentialed one is forwarded. Removing the guard
install fails 6 of the 7 original cases.
pnpm verify in full was not run; the browser and release-package steps are left to CI on #262.
Untested, and stated rather than implied
The connect (HTTP CONNECT) branch goes through the same socket-event allowlist as upgrade but has no
case of its own. Everything else in the ticket has a behavioural test.
Activity
Fectivnfy112357 commentedon Oct 26, 2026
Implementation is up in #262 (branch
feat/miniapp-loopback-guard, commits7a79dab0+32d01c2e, stackedon #261). Three of the five criteria are proven by tests; two are implemented and reviewed but have no
execution evidence yet, so this ticket stays open and the comment names exactly what is missing.
Proven
1. A request without the credential is answered 401 with no body; the same request with it reaches the
plugin's own handler.
packages/local-runtime-v2/test/unit/miniapp/loopback-guard.test.tsdrives a realhttp.Serverthrough the realstartMiniAppModuleseam (loadModuleinjected, everything else real):answers an unauthenticated request with 401 and no body→ status 401, body''answers a request carrying the wrong credential with 401reaches the plugin handler with the right credential and hides the header from it→ 200 with theplugin's own body
2. The exact loopback host is required, and an upgrade without the credential is destroyed rather than
answered.
rejects the right credential on the wrong host→ the credential withHost: localhost:<port>isanswered 401, because the guard compares the exact
127.0.0.1:<port>stringdestroys an upgrade without the credential and forwards one that carries it→ the client socket seesno bytes at all, and the plugin's
upgradelistener is never called; the credentialed upgrade gets theplugin's
1013. Plugin code never observes the credential header. Same case asserts
observed[0].headershas nox-mavis-miniapp-tokenandrawHeaderscontains no such entry, while therequest that reached the handler did carry it (both
headersandrawHeadersare stripped).Red proof for the above: with the
installMiniAppLoopbackGuard(...)call removed,6 of 7 cases fail. The seventh is the init-message validator case, which does not depend on the
install.
Full-suite result at
32d01c2e:Implemented, not proven
4. A reserved health path is answered by the guard itself, and readiness fails when an anonymous
request is not rejected.
The first half is proven:
answers the reserved health path itself, before the plugin handlergets 204for the credentialed request, 401 for the anonymous one, and the plugin handler is never called.
The second half is only implemented.
node-runtimenow requires anonymous → 401 followed bycredentialed → 204 in
probeLoopbackAuth, withREADINESS_PROBE_REJECTEDfor a wrong status andREADINESS_PROBE_TIMEOUTfor a stalled probe; the loop stops retrying once a port answers wrongly(
isProbeRejection). No test exercises this: the probe lives innode-runtime, which has no testharness in this repository — the shipped package's
node-runtime.test.tsbrings one (1999 lines) and ithas not been adopted. Closing this criterion needs that harness, or a narrower one that stubs
spawn.5. The credential is minted per process generation and never appears in a published view, its surface
path, or the client's data.
Proven by construction and type, not by test:
node-runtime.ts:228—prepareRuntime, i.e. once per process generation and outside theport-attempt loop, so a port retry reuses one credential
MiniAppNodeRuntimeasloopbackToken, documentedserver-only (
contracts.ts:104)MiniAppPublishedView(contracts.ts:129-140) has no credential field, and no renderer contractcarries one, so nothing published, no surface path and no client payload can hold it
The per-generation property has no assertion behind it. A test would need to prepare two runtimes and
compare the credentials, which needs the same
node-runtimeharness as criterion 4.Deliberately left to the proxy change
The supervisor does not yet expose the credential to the proxy. ADR 0020 counts "the supervisor's launch
credential so the proxy has something to inject" as part of the guard's cost, but the only consumer is
the proxy in #255, so this branch does not add a field nothing reads. If you would rather wire it here,
say so and I will extend this branch rather than #255.
Fectivnfy112357 commentedon Oct 26, 2026
Update at
daf9a396(PR #262): criterion 4 now has execution evidence too. Only criterion 5'sper-generation assertion is still open, so the ticket stays open on that one item.
Now proven — readiness fails when an anonymous request is not rejected
Readiness moved out of
node-runtime.tsintoprocess/node-runtime/loopback-readiness.ts(
waitForLoopbackReadiness), so it can be driven against real servers instead of a faked child. Fourcases in the same suite:
passes when the guard rejects the anonymous probe and answers the credentialed one— the guardedfixture resolves
fails when the credentialed probe is rejected→READINESS_PROBE_REJECTED,authenticated health probe expected 204, received 401fails when an anonymous probe is not rejected— a plainnode:httpserver in a child process thatanswers 200 to everything →
READINESS_PROBE_REJECTED,unauthenticated health probe expected 401, received 200. This is exactly the failure the old TCPconnect could not see.
times out when nothing is listening→READINESS_TIMEOUTSo both halves of criterion 4 are now behavioural: the reserved path is answered by the guard itself
(204 / 401, plugin handler never called), and readiness refuses a socket that does not reject anonymous
requests.
At
daf9a396:Still open — criterion 5's per-generation property
The credential is minted at
node-runtime.ts:228inprepareRuntime, before the port-attempt loop, soa port retry reuses one credential and a new process generation gets a new one. That placement is the
enforcement; no test asserts it, because the assertion needs a
node-runtimeharness that scripts achild process through
spawn(the shipped package has one at 1999 lines, not yet adopted here).The other half of criterion 5 is structural and holds:
MiniAppNodeRuntime.loopbackTokenis documentedserver-only,
MiniAppPublishedView(contracts.ts:129-140) has no credential field, and no renderercontract carries one, so no published view, surface path or client payload can hold it.
Two ways to close this ticket, your call:
above; the missing piece is only a regression test.
node-runtimeharness lands, then add "two preparations mintdifferent credentials" as the assertion. That harness is needed by One client owns a Mini App at a time #254 and Serve a Mini App page through its own proxy #255 anyway.
test(webui): prove the Mini App credential is per generation
Fectivnfy112357 commentedon Oct 26, 2026
All five criteria now have evidence. Closing at
33322479(PR #262).The gap in my previous comment — per-generation minting — needed the scripted-child harness this
repository did not have. It exists now:
packages/local-runtime-v2/test/unit/miniapp/node-runtime-credential.test.tsdrives
createMiniAppNodeRuntimeAdapterwith aChildProcessstand-in that runs the real runnerin-process, so the readiness probe meets the real guard while the child itself is scripted.
Criterion 4 — readiness fails when an anonymous request is not rejected (proven)
waitForLoopbackReadinesslives inprocess/node-runtime/loopback-readiness.tsand is driven againstreal servers:
READINESS_PROBE_REJECTED,authenticated health probe expected 204, received 401node:httpserver in a child process answering 200 to everything →READINESS_PROBE_REJECTED,unauthenticated health probe expected 401, received 200READINESS_TIMEOUTThe third case is the one the old TCP connect could not see: the socket accepts, the guard is absent.
Criterion 5 — minted per process generation, never published (proven)
mints one credential per preparation and hands that exact value down— one preparation callsmakeLoopbackTokenexactly once, the init message carries that value, and the runtime handle exposesthe same value after readiness passed for it
gives a new process generation a different credential— a second generation's init message carriesa different value
MiniAppNodeRuntime.loopbackTokenis documented server-only,MiniAppPublishedView(contracts.ts:129-140) has no credential field, and no renderer contractcarries one
Criteria 1–3 (proven, unchanged from the previous comment)
packages/local-runtime-v2/test/unit/miniapp/loopback-guard.test.tsagainst a realhttp.Serverthrough the real
startMiniAppModuleseam: anonymous 401 with an empty body, wrong credential 401, theright credential on the wrong
Host401, the plugin handler reached with no credential header visiblein
headersorrawHeaders, the reserved health path answered 204/401 without reaching the plugin,and an unauthenticated upgrade destroyed while a credentialed one is forwarded. Removing the guard
install fails 6 of the 7 original cases.
Gates at
33322479pnpm verifyin full was not run; the browser and release-package steps are left to CI on #262.Untested, and stated rather than implied
The
connect(HTTP CONNECT) branch goes through the same socket-event allowlist asupgradebut has nocase of its own. Everything else in the ticket has a behavioural test.
feat(webui): hand the Mini App's launch credential to the caller that…
feat(webui): guard every Mini App loopback server with the host crede…
feat(webui): guard every Mini App loopback server with the host crede…
test(webui): prove the Mini App credential is per generation
feat(webui): hand the Mini App's launch credential to the caller that…