Skip to content

fix(cli): answer the Private Network Access preflight on the loopback callback - #348

Draft
DevQwinB wants to merge 1 commit into
blockchain-maxis:mainfrom
DevQwinB:feat/cli-loopback-pna
Draft

fix(cli): answer the Private Network Access preflight on the loopback callback#348
DevQwinB wants to merge 1 commit into
blockchain-maxis:mainfrom
DevQwinB:feat/cli-loopback-pna

Conversation

@DevQwinB

Copy link
Copy Markdown

closes #272

Draft, deliberately. There is no CLI on main@signet/cli, the loopback server (#255) and the pairing endpoints (#266/#268) are all still open, and the four open CLI PRs each pick a different layout (apps/web/app/api/cli/link, .../cli/unlink, .../link/device/*, app/link/[code]). Rather than bet on one, this lands the part that is protocol rather than layout and is self-contained and testable today. See Where this fits below.

The bug

The approval page is served over HTTPS from the deployment; the callback target is http://127.0.0.1:<port>. Loopback is a potentially-trustworthy origin so mixed-content blocking does not apply — but Chrome sends a CORS preflight for public → private requests and refuses the real request unless the private server opts in with Access-Control-Allow-Private-Network: true.

Without it the callback fails with an opaque network error, the page shows a spinner, and the CLI waits out its timeout. Two components that both look hung, neither saying why.

And it passes local testing, because localhost → localhost is not a public → private transition. That is what makes it the most likely way this breaks for a developer and the least likely way it breaks for whoever wrote it.

The server half

apps/cli/src/loopback-cors.ts — the policy, as pure functions over plain header maps with no server or transport in sight, so it is testable on its own and ports directly if the CLI is re-hosted (the Go module in #251, say). These headers are protocol, not implementation.

  • Access-Control-Allow-Private-Network: true is sent only when the browser asked for it. Firefox and Safari do not implement PNA and send an ordinary preflight; they get working CORS, and nobody is handed a private-network grant they never requested.
  • Access-Control-Allow-Origin is the deployment origin, never * — this server is about to accept a pairing completion, and a wildcard means any tab the developer has open can post to it. Compared as a parsed origin, so https://signet.dev:443 matches and https://signet.dev.evil.test does not.
  • Vary: Origin, Access-Control-Request-Private-Network, so a cache cannot serve one origin's answer — or a non-PNA answer — to a different request.
  • Access-Control-Max-Age: 60, short on purpose: the server lives for one pairing, and a long cached preflight for 127.0.0.1:<port> would outlive it and apply to whatever binds that port next.
  • A disallowed origin gets 403 with no CORS headers, not a bare 204. The browser rejects both, but the first leaves something in the network tab and the CLI log instead of a silent "CORS error" the developer has to guess at.

apps/cli/src/loopback.ts — the single-use server. Binds 127.0.0.1 explicitly (never 0.0.0.0), re-checks the origin on the real POST because a preflight is a browser courtesy that anything non-browser simply skips, caps the body, sends CORS headers on the actual response too (without them the page cannot read the result — the same silent hang), accepts exactly one callback, then closes.

The browser half

The issue also asks that the browser-side failure path surface a real message instead of hanging. A blocked cross-origin fetch rejects with a bare TypeError: Failed to fetch — no status, no reason, no way to tell "Chrome refused the private-network request" from "the CLI already exited".

apps/web/lib/cli-callback.tspostToCli() never throws and never hangs. It always resolves with a typed reason:

reason what the developer is told
blocked names Private Network Access, says to update the CLI and re-run signet link
timeout the CLI did not answer; approve while it is still waiting
refused the CLI answered and rejected it — probably a different pairing
invalid-response the CLI answered with something unusable

It sets mode: "cors" explicitly: no-cors would resolve opaquely and report success for a request the CLI never accepted.

Verification

Real runs.

  • apps/cli: 15 tests passing. Eight over the policy functions, and seven driving a real server over real HTTP — including a full Chrome-shaped exchange: OPTIONS with Access-Control-Request-Private-Network: true204 + the opt-in, then the POST200 with CORS headers and the payload delivered. Plus: loopback-only bind, foreign origin refused on the real POST with a logged reason, 405 with Allow, malformed body → 400, single-use (the second callback finds the port gone), and the timeout rejecting with LoopbackTimeoutError.
  • apps/web: 7 tests passing for the callback helper, covering the bare-TypeError case, the deadline, refusal vs block, and that it never throws whatever fetch does.
  • pnpm --filter @signet/cli typecheck and pnpm --filter @signet/web typecheckboth clean. eslint clean on both; prettier --check clean.

Where this fits

I could not fix this against the existing CLI because there is not one on main. What is here is the piece that stays correct regardless of which layout wins: the header policy is decided by the Fetch and PNA specs, not by where the route lives.

apps/cli matches the layout #320 and #321 already use. If the Go direction in #251 wins instead, loopback-cors.ts is the file to port — it has no Node dependencies beyond the type of a header map, and its tests are the conformance suite for a port.

Happy to rebase onto whichever pairing PR lands first and wire this into it.

… callback

The approval page is served over HTTPS from the deployment; the callback
target is http://127.0.0.1:<port>. Loopback is potentially trustworthy so
mixed-content blocking does not apply, but Chrome sends a CORS preflight
for public -> private requests and refuses the real request unless the
private server opts in. Without that opt-in the callback fails with an
opaque network error and the CLI waits out its timeout.

This is the most likely way the feature breaks for a developer and the
least likely way it breaks for whoever wrote it: localhost -> localhost
is not a public -> private transition, so it works in local testing.

The policy is pure functions over plain header maps, with no server or
transport in sight, so it is testable on its own and portable if the CLI
is re-hosted — the headers are protocol, not implementation.
Access-Control-Allow-Private-Network is sent only when the browser asked
for it, so Firefox and Safari get ordinary working CORS and nobody is
handed a private-network grant they never requested. The allow-origin is
the deployment origin, compared as a parsed origin and never a wildcard:
this server is about to accept a pairing completion, and a wildcard means
any tab the developer has open can post to it. Origin is re-checked on
the real POST, since a preflight is a browser courtesy and anything that
is not a browser skips it. A refused preflight answers 403 with no CORS
headers rather than a bare 204, so there is something in the network tab
and the log instead of a silent "CORS error".

The browser side gets the other half. A blocked cross-origin fetch
rejects with a bare TypeError carrying no status and no reason, which is
why both components look hung and neither says why. postToCli always
resolves with a typed reason — blocked, timeout, refused,
invalid-response — each with a message naming the likely cause and the
next step, and always under a deadline. It requests cors mode
explicitly: no-cors would resolve opaquely and report success for a
request the CLI never accepted.
@drips-wave

drips-wave Bot commented Aug 30, 2026

Copy link
Copy Markdown

@DevQwinB Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@vercel

vercel Bot commented Aug 30, 2026

Copy link
Copy Markdown

@DevQwinB is attempting to deploy a commit to the blockchainmaxis-8449's projects Team on Vercel.

A member of the Team first needs to authorize it.

@netlify

netlify Bot commented Aug 30, 2026

Copy link
Copy Markdown

Deploy Preview for stellar-signet ready!

Name Link
🔨 Latest commit a7e5248
🔍 Latest deploy log https://app.netlify.com/projects/stellar-signet/deploys/6a9461e36239ed0008736f5b
😎 Deploy Preview https://deploy-preview-348--stellar-signet.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

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.

Browser → loopback POST needs Private Network Access preflight handling

1 participant