cd /news/ai-agents/a-loopback-only-proxy-for-prototypin… · home topics ai-agents article
[ARTICLE · art-129024] src=dev.to ↗ pub= topic=ai-agents verified=true sentiment=· neutral

A loopback-only proxy for prototyping northway's feed reader

A developer built a loopback-only Node.js proxy to prototype the reader interface for northway, a Go service that generates ranked, source-backed news feeds for AI agents. The proxy keeps the upstream API key server-side, enforces loopback binding with no override, and validates Host headers, Content-Type, Sec-Fetch-Site, and a 16 KB body limit before forwarding paid AI-provider requests. The prototype is intended strictly as a local UX reference, not a production component.

by read3 min views3 publishedSep 14, 2026

Ahnii!

northway is a Go service that turns approved sources into small, ranked, source-backed news feeds for AI agents, deployed Pi-first. Before committing that UX to the single-process Go build, I prototyped it in the browser first — a plain HTML/CSS/JS reader backed by a small Node.js proxy. What follows is why that prototype needed its own proxy, and the checks that keep it from becoming anything more than a local UX reference.

The prototype had one job: settle the reader's interaction design before any of it went into the Go service. That meant nailing down:

Doing that in a browser means calling northway's real API from client-side code, and that creates an immediate problem: the API key can't go anywhere client-side JavaScript can read it.

The fix is a small node:http server (prototype/server.mjs) that serves the static files and exposes one endpoint, /api/news, which holds the key and forwards requests upstream:

const apiKey = process.env.NORTHWAY_API_KEY;
js
const upstream = await fetch(`${northwayURL}/v1/feed-queries`, {
  method: "POST",
  signal: AbortSignal.timeout(10_000),
  headers: {
    Authorization: `Bearer ${apiKey}`,
    "Content-Type": "application/json",
    "Idempotency-Key": randomUUID(),
  },
  body: JSON.stringify({
    feed_id: selected.id,
    context: { intent: selected.context, technologies: [], focus: [selected.label] },
    max_age_hours: maxAgeHours,
    limit: 10,
  }),
});

The browser only ever talks to /api/news on the same origin. It never sees the key, the upstream URL, or the feed ID mapping — those live entirely on the proxy side. The Idempotency-Key and a 10-second AbortSignal.timeout guard against duplicate or hung upstream calls, which matters when every query is a paid AI-provider request.

A proxy that holds a live API key is a liability the moment it's reachable from anything but the machine running it. The server checks its own bind address before it does anything else:

const host = process.env.HOST ?? "127.0.0.1";
if (!["127.0.0.1", "localhost", "::1"].includes(host)) {
  throw new Error("HOST must be a loopback address; this prototype cannot be exposed.");
}

There's no flag to override this — the only way past the check is to not pass a non-loopback HOST in the first place. The README spells out the same constraint in plain language: keep it bound to loopback, don't put it on a LAN, don't expose it to the internet.

Loopback-only isn't a substitute for validating what shows up on /api/news. The handler rejects anything that doesn't look like the reader's own frontend before routing even happens:

Check Rejects when Response
Host header Doesn't match 127.0.0.1:<port> ,localhost:<port> , or[::1]:<port> 400
Content-Type Anything other than application/json 415
Sec-Fetch-Site Present and not same-origin (blocks other tabs and pages) 403
Body size Over 16 KB Aborted, never buffered

Every response also carries a restrictive Content-Security-Policy (default-src 'self', locked-down script-src/ style-src, no inline scripts or styles), X-Content-Type-Options: nosniff, and Referrer-Policy: no-referrer. None of it is exotic, but skipping any row in that table turns "only my machine can reach this" into "anything on my machine can reach this."

On the client side, a failed refresh shouldn't blank out a working feed. app.js tracks whether a snapshot has ever loaded successfully and falls back to it on error instead of clearing the screen:

} catch (error) {
  if (requestNumber !== activeRequest) return;
  if (error.name === "AbortError") return;
  activeFeed = displayedFeed;
  selectFeed(displayedFeed);
  briefingHeading.textContent = feedLabels[displayedFeed];
  briefingMeta.textContent = hasSnapshot
    ? `Refresh failed · showing last available ${feedLabels[displayedFeed]} feed`
    : "Service unavailable";
  ...
}

An activeRequest counter also discards any response that isn't from the most recent fetch, so clicking between tabs quickly can't let a slow, stale response overwrite a newer one. When a feed genuinely comes back empty, the reader says so directly ("No current stories matched this feed") rather than padding the list. That's the same rule spelled out in app.js itself: the empty result is preserved rather than padded.

This prototype has clear limits:

Its only job was to prove out the interaction design against real, live snapshots (all five feeds, desktop and mobile), so the accepted UX could guide a single-process Go implementation instead of being designed twice.

Baamaapii

── more in #ai-agents 4 stories · sorted by recency
── more on @northway 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/a-loopback-only-prox…] indexed:0 read:3min 2026-09-14 ·