<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:blog="https://jonesrussell.github.io/blog/ns"><channel><title>Northway on Web Developer Blog</title><link>https://jonesrussell.github.io/blog/tags/northway/</link><description>Recent content in Northway on Web Developer Blog</description><image><title>Web Developer Blog</title><url>https://jonesrussell.github.io/blog/images/og-default.png</url><link>https://jonesrussell.github.io/blog/images/og-default.png</link></image><generator>Hugo -- 0.166.0</generator><language>en-us</language><lastBuildDate>Sun, 13 Sep 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://jonesrussell.github.io/blog/tags/northway/feed.xml" rel="self" type="application/rss+xml"/><item><title>A loopback-only proxy for prototyping northway's feed reader</title><link>https://jonesrussell.github.io/blog/feed-reader-prototype-loopback-proxy/</link><pubDate>Sun, 13 Sep 2026 00:00:00 +0000</pubDate><guid>https://jonesrussell.github.io/blog/feed-reader-prototype-loopback-proxy/</guid><category>general</category><blog:tag>northway</blog:tag><blog:tag>nodejs</blog:tag><blog:tag>security</blog:tag><blog:tag>prototyping</blog:tag><description>How northway&amp;rsquo;s browser prototype keeps its API key off client JavaScript with a loopback-only Node proxy, before any of it reaches the production Pi build.</description><content:encoded><![CDATA[<p>Ahnii!</p>
<p><a href="https://github.com/jonesrussell/northway">northway</a> 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.</p>
<h2 id="why-a-proxy-and-why-disposable">Why a proxy, and why disposable</h2>
<p>The prototype had one job: settle the reader&rsquo;s interaction design before any of it went into the Go service. That meant nailing down:</p>
<ul>
<li>Five feed tabs — <strong>Mixed</strong>, <strong>Development</strong>, <strong>Entertainment</strong>, <strong>Canada</strong>, <strong>World</strong></li>
<li>A compact dark layout</li>
<li>Honest handling of empty or failed results</li>
</ul>
<p>Doing that in a browser means calling northway&rsquo;s real API from client-side code, and that creates an immediate problem: the API key can&rsquo;t go anywhere client-side JavaScript can read it.</p>
<p>The fix is a small <code>node:http</code> server (<code>prototype/server.mjs</code>) that serves the static files and exposes one endpoint, <code>/api/news</code>, which holds the key and forwards requests upstream:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">apiKey</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">process</span>.<span style="color:#a6e22e">env</span>.<span style="color:#a6e22e">NORTHWAY_API_KEY</span>;
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">upstream</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">fetch</span>(<span style="color:#e6db74">`</span><span style="color:#e6db74">${</span><span style="color:#a6e22e">northwayURL</span><span style="color:#e6db74">}</span><span style="color:#e6db74">/v1/feed-queries`</span>, {
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">method</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#34;POST&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">signal</span><span style="color:#f92672">:</span> <span style="color:#a6e22e">AbortSignal</span>.<span style="color:#a6e22e">timeout</span>(<span style="color:#ae81ff">10_000</span>),
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">headers</span><span style="color:#f92672">:</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">Authorization</span><span style="color:#f92672">:</span> <span style="color:#e6db74">`Bearer </span><span style="color:#e6db74">${</span><span style="color:#a6e22e">apiKey</span><span style="color:#e6db74">}</span><span style="color:#e6db74">`</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;Content-Type&#34;</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#34;application/json&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;Idempotency-Key&#34;</span><span style="color:#f92672">:</span> <span style="color:#a6e22e">randomUUID</span>(),
</span></span><span style="display:flex;"><span>  },
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">body</span><span style="color:#f92672">:</span> <span style="color:#a6e22e">JSON</span>.<span style="color:#a6e22e">stringify</span>({
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">feed_id</span><span style="color:#f92672">:</span> <span style="color:#a6e22e">selected</span>.<span style="color:#a6e22e">id</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">context</span><span style="color:#f92672">:</span> { <span style="color:#a6e22e">intent</span><span style="color:#f92672">:</span> <span style="color:#a6e22e">selected</span>.<span style="color:#a6e22e">context</span>, <span style="color:#a6e22e">technologies</span><span style="color:#f92672">:</span> [], <span style="color:#a6e22e">focus</span><span style="color:#f92672">:</span> [<span style="color:#a6e22e">selected</span>.<span style="color:#a6e22e">label</span>] },
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">max_age_hours</span><span style="color:#f92672">:</span> <span style="color:#a6e22e">maxAgeHours</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">limit</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">10</span>,
</span></span><span style="display:flex;"><span>  }),
</span></span><span style="display:flex;"><span>});
</span></span></code></pre></div><p>The browser only ever talks to <code>/api/news</code> 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 <code>Idempotency-Key</code> and a 10-second <code>AbortSignal.timeout</code> guard against duplicate or hung upstream calls, which matters when every query is a paid AI-provider request.</p>
<h2 id="refuse-to-bind-anywhere-but-loopback">Refuse to bind anywhere but loopback</h2>
<p>A proxy that holds a live API key is a liability the moment it&rsquo;s reachable from anything but the machine running it. The server checks its own bind address before it does anything else:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">host</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">process</span>.<span style="color:#a6e22e">env</span>.<span style="color:#a6e22e">HOST</span> <span style="color:#f92672">??</span> <span style="color:#e6db74">&#34;127.0.0.1&#34;</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">if</span> (<span style="color:#f92672">!</span>[<span style="color:#e6db74">&#34;127.0.0.1&#34;</span>, <span style="color:#e6db74">&#34;localhost&#34;</span>, <span style="color:#e6db74">&#34;::1&#34;</span>].<span style="color:#a6e22e">includes</span>(<span style="color:#a6e22e">host</span>)) {
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">throw</span> <span style="color:#66d9ef">new</span> Error(<span style="color:#e6db74">&#34;HOST must be a loopback address; this prototype cannot be exposed.&#34;</span>);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>There&rsquo;s no flag to override this — the only way past the check is to not pass a non-loopback <code>HOST</code> in the first place. The README spells out the same constraint in plain language: keep it bound to loopback, don&rsquo;t put it on a LAN, don&rsquo;t expose it to the internet.</p>
<h2 id="dont-trust-requests-just-because-theyre-local">Don&rsquo;t trust requests just because they&rsquo;re local</h2>
<p>Loopback-only isn&rsquo;t a substitute for validating what shows up on <code>/api/news</code>. The handler rejects anything that doesn&rsquo;t look like the reader&rsquo;s own frontend before routing even happens:</p>
<table>
	<thead>
			<tr>
					<th>Check</th>
					<th>Rejects when</th>
					<th>Response</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Host header</td>
					<td>Doesn&rsquo;t match <code>127.0.0.1:&lt;port&gt;</code>, <code>localhost:&lt;port&gt;</code>, or <code>[::1]:&lt;port&gt;</code></td>
					<td>400</td>
			</tr>
			<tr>
					<td>Content-Type</td>
					<td>Anything other than <code>application/json</code></td>
					<td>415</td>
			</tr>
			<tr>
					<td><code>Sec-Fetch-Site</code></td>
					<td>Present and not <code>same-origin</code> (blocks other tabs and pages)</td>
					<td>403</td>
			</tr>
			<tr>
					<td>Body size</td>
					<td>Over <strong>16 KB</strong></td>
					<td>Aborted, never buffered</td>
			</tr>
	</tbody>
</table>
<p>Every response also carries a restrictive <code>Content-Security-Policy</code> (<code>default-src 'self'</code>, locked-down <code>script-src</code>/<code>style-src</code>, no inline scripts or styles), <code>X-Content-Type-Options: nosniff</code>, and <code>Referrer-Policy: no-referrer</code>. None of it is exotic, but skipping any row in that table turns &ldquo;only my machine can reach this&rdquo; into &ldquo;anything on my machine can reach this.&rdquo;</p>
<h2 id="preserve-the-last-good-feed-on-failure">Preserve the last good feed on failure</h2>
<p>On the client side, a failed refresh shouldn&rsquo;t blank out a working feed. <code>app.js</code> tracks whether a snapshot has ever loaded successfully and falls back to it on error instead of clearing the screen:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span>} <span style="color:#66d9ef">catch</span> (<span style="color:#a6e22e">error</span>) {
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">requestNumber</span> <span style="color:#f92672">!==</span> <span style="color:#a6e22e">activeRequest</span>) <span style="color:#66d9ef">return</span>;
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">error</span>.<span style="color:#a6e22e">name</span> <span style="color:#f92672">===</span> <span style="color:#e6db74">&#34;AbortError&#34;</span>) <span style="color:#66d9ef">return</span>;
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">activeFeed</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">displayedFeed</span>;
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">selectFeed</span>(<span style="color:#a6e22e">displayedFeed</span>);
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">briefingHeading</span>.<span style="color:#a6e22e">textContent</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">feedLabels</span>[<span style="color:#a6e22e">displayedFeed</span>];
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">briefingMeta</span>.<span style="color:#a6e22e">textContent</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">hasSnapshot</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">?</span> <span style="color:#e6db74">`Refresh failed · showing last available </span><span style="color:#e6db74">${</span><span style="color:#a6e22e">feedLabels</span>[<span style="color:#a6e22e">displayedFeed</span>]<span style="color:#e6db74">}</span><span style="color:#e6db74"> feed`</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">:</span> <span style="color:#e6db74">&#34;Service unavailable&#34;</span>;
</span></span><span style="display:flex;"><span>  ...
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>An <code>activeRequest</code> counter also discards any response that isn&rsquo;t from the most recent fetch, so clicking between tabs quickly can&rsquo;t let a slow, stale response overwrite a newer one. When a feed genuinely comes back empty, the reader says so directly (&ldquo;No current stories matched this feed&rdquo;) rather than padding the list. That&rsquo;s the same rule spelled out in <code>app.js</code> itself: the empty result is preserved rather than padded.</p>
<h2 id="whats-next">What&rsquo;s next</h2>
<p>This prototype has clear limits:</p>
<ul>
<li>It doesn&rsquo;t deploy northway</li>
<li>It doesn&rsquo;t poll unattended</li>
<li>It isn&rsquo;t the Pi runtime — it&rsquo;s explicitly excluded from the production Go image</li>
</ul>
<p>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.</p>
<p>Baamaapii</p>
]]></content:encoded></item></channel></rss>