Written 2026-08-11. Every patch below is derived from facts measured against the live systems and the actual repo sources, not guessed. Apply order and ship lane follow Joey's 2026-08-11 scope call:
nodedough, fedm8, cipherdeck (all their repos) → TEST-ITjbnx/jbnx.io → projects-portal/server.js — response compression · PROD-ITWhy: measured — projects.jbnx.io returns 371,068 B with no Content-Encoding even when the request sends Accept-Encoding: br,gzip,deflate. hi.jbnx.io (same service) is uncompressed too. Source confirms: no zlib, no accept-encoding handling anywhere in 4,336 lines.
Shape of the file: raw Node http server, start command node server.js, 22 writeHead() call sites, 0 setHeader(). So do not patch 22 sites — wrap the response once at the top of the request handler.
The portal serves a live /api/events EventSource feed. A naive compression wrapper buffers the body until end(), which breaks Server-Sent Events permanently — the feed would hang open and never flush. The wrapper below bails out on two conditions: the SSE path, and any response that calls res.write() (i.e. streams) rather than a single res.end(body).
Add near the top, with the other requires:
const zlib = require('zlib');
Add this function anywhere above the request handler:
// Compress single-shot responses. Bails out on streaming/SSE responses,
// which must not be buffered. Added 2026-08-11 (estate review finding 3).
function enableCompression(req, res) {
const ae = String(req.headers['accept-encoding'] || '');
const enc = /\bbr\b/.test(ae) ? 'br' : /\bgzip\b/.test(ae) ? 'gzip' : null;
if (!enc) return;
// Never buffer the EventSource feed.
if (String(req.url || '').startsWith('/api/events')) return;
const origWriteHead = res.writeHead.bind(res);
const origWrite = res.write.bind(res);
const origEnd = res.end.bind(res);
let code = 200, hdrs = null, streamed = false, headSent = false;
const flushHead = () => { if (!headSent && hdrs) { headSent = true; origWriteHead(code, hdrs); } };
res.writeHead = function (statusCode, headers) {
code = statusCode;
hdrs = headers || {};
return res; // defer until we know the body
};
res.write = function (...args) { // someone is streaming — stand down
streamed = true;
flushHead();
return origWrite(...args);
};
res.end = function (body, ...rest) {
if (streamed || !body || typeof body === 'function') { flushHead(); return origEnd(body, ...rest); }
const ct = String((hdrs && (hdrs['Content-Type'] || hdrs['content-type'])) || '');
const worthIt = /^(text\/|application\/(json|javascript|xml)|image\/svg)/.test(ct)
&& Buffer.byteLength(body) > 1024;
if (!worthIt) { flushHead(); return origEnd(body, ...rest); }
const buf = Buffer.isBuffer(body) ? body : Buffer.from(body);
const compress = enc === 'br' ? zlib.brotliCompress : zlib.gzip;
compress(buf, (err, out) => {
if (err) { flushHead(); return origEnd(body, ...rest); }
hdrs['Content-Encoding'] = enc;
hdrs['Vary'] = 'Accept-Encoding';
hdrs['Content-Length'] = out.length;
headSent = true;
origWriteHead(code, hdrs);
origEnd(out);
});
};
}
Then as the first line inside the request handler passed to http.createServer(...):
enableCompression(req, res);
# 1. compression is on and the payload collapses
curl -sI -H 'Accept-Encoding: br,gzip' https://projects.jbnx.io | grep -i content-encoding # expect: br
curl -s -H 'Accept-Encoding: br,gzip' https://projects.jbnx.io -o /dev/null -w '%{size_download}\n'
# expect roughly 60,000–80,000 (was 371,068)
# 2. the directive still serves and still reports v35
curl -s https://hi.jbnx.io/json | head -c 60
# 3. healthcheck
curl -s -o /dev/null -w '%{http_code}\n' https://projects.jbnx.io/healthz # expect 200
# 4. THE IMPORTANT ONE — SSE must still stream, not hang
curl -N -m 8 -H 'Accept: text/event-stream' https://projects.jbnx.io/api/events | head -c 200
# expect event data within a few seconds, NOT a hang and NOT an empty body
If check 4 hangs, revert immediately — that is the feed every portal client depends on.
jbnx/jbnx-bill → server.js — security headers · PROD-ITWhy: measured — bill.jbnx.io returns no HSTS, no CSP, no X-Frame-Options, no X-Content-Type-Options, no Referrer-Policy. It is the customer login and payment portal, so it is framable and therefore clickjackable today. Source confirms: no strict-transport, no x-frame-options (2,167 lines, 6 writeHead, 2 setHeader).
Do not use X-Frame-Options: DENY here. The service already carries a BILL_SSO_FRAME_ANCESTORS variable, so framing is intentional for SSO. Use CSP frame-ancestors driven by that variable — that is what it is for, and it is currently enforcing nothing.
// Security headers. Added 2026-08-11 (estate review finding 2).
function securityHeaders(res) {
const ancestors = process.env.BILL_SSO_FRAME_ANCESTORS || "'none'";
res.setHeader('Strict-Transport-Security', 'max-age=63072000; includeSubDomains');
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Referrer-Policy', 'same-origin');
res.setHeader('Content-Security-Policy', `frame-ancestors ${ancestors}`);
}
Call securityHeaders(res); as the first line of the request handler.
Watch out: Strict-Transport-Security with includeSubDomains on jbnx.io affects every .jbnx.io host. That is desirable, but confirm no .jbnx.io host is intentionally served over plain HTTP first. All 17 hosts probed on 2026-08-11 already answered on HTTPS, so this should be safe.
curl -sI https://bill.jbnx.io | grep -iE 'strict-transport|content-security|x-content-type|referrer'
curl -s -o /dev/null -w '%{http_code}\n' https://bill.jbnx.io # expect 200
# and log in once by hand — confirm SSO framing still works
CREDITS_PER_HOUR appears 9 times in server.js alone. The directive says the constant "lives in 4 places" (server.js + 3 UI pages) and that a figure off by 100× is a defect of the same class as silent $0. Nine occurrences in one file is worth an eyeball — every one of them should reference a single const CREDITS_PER_HOUR = 6000;, never a repeated literal. (Live check passed: bill.jbnx.io HTML reports CREDITS_PER_HOUR = 6000, matching the directive.)
jbnx/cipherdeck → index.html — missing <h1> · TEST-ITWhy: measured — 179 lines, 7,826 bytes, <h1> count = 0, first heading on the page is <h2>Where to shop. The public storefront has no top-level heading, which costs SEO ranking and screen-reader orientation.
Fix: the page's existing <h2> elements are section headings and should stay <h2>. Add a real <h1> as the page's opening heading — the brand/value line that is currently presented as styled text or an image. Visually it can be identical; it just needs to be an <h1>.
If the design must not change, the minimum acceptable version is a visually-hidden heading:
<h1 class="sr-only">CipherDeck — graded trading cards, shipped protected and tracked</h1>
with, in /static/site.css:
.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;
clip:rect(0,0,0,0);white-space:nowrap;border:0}
Prefer a visible <h1> if the design allows — a hidden one satisfies the checker without helping a sighted user orient.
curl -s https://cipherdeck.com | grep -c '<h1' # expect 1
jbnx/cipherdeck-apps — accessibility · TEST-ITWhy: measured on the live 745 KB bundle — 35 icon-only <button> elements with no accessible name, and ~6 <img> tags built inside JS template strings with no alt. Screen-reader users hear "button" 35 times. This is the largest concrete accessibility debt in the estate and it sits on a paid product.
Two mechanical fixes:
aria-label="<the action>" — "Close", "Next image", "Add to collection", etc.<img> built in a template string gets an alt. Decorative images (the raffle prize art, card thumbnails already labelled by adjacent text) take alt=""; meaningful ones take a real description.This one is a genuine sweep rather than a patch, so it wants its own claim on cipherdeck-apps (28 open tasks there already).
jbnx/fedm8 and the ai.fedm8.com front end — legal pages · TEST-ITWhy: measured — ai.fedm8.com and scan.fedm8.com both return 200 with the same 37,193 B bundle, and neither redirects to the other. Neither publishes terms, privacy, or refund links, and neither sends any security header. This is the product handling veteran PII, CAGE/UEI and Stripe.
Three things:
/terms, /privacy, /refunds. Handling veteran PII with no published privacy notice is a compliance problem independent of whether you are charging yet. NodeDough already has all three at /legal/* — copy that structure.ai.fedm8.com and 301 scan.fedm8.com to it (or the reverse). Two hostnames serving independent copies will drift.⚠️ The fedm8 slug was held by another agent (1099:claude-cowork) throughout 2026-08-11. Check GET /api/1099/board?free=1 before claiming.
This is the finding that matters most, and it got worse on inspection.
jbnx/jbnx.io has exactly two branches: main (default) and ship-it-inbox. There is no production branch. Meanwhile both the production and test Railway environments of the projects service build from main.
So: the directive's ship path ("PR → verify on test → chat approve → promote to production") has no target to promote to, and every push to the test lane deploys the live directive that every agent in the company reads. Confirmed by byte-identical payloads on projects.jbnx.io and test.projects.jbnx.io.
Given the 2026-08-11 scope call, this only needs solving for nodedough, fedm8 and cipherdeck. For those three, per repo:
git branch production main && git push -u origin productionmain → productionmainPROMOTE.cmd fast-forwards production from main, and that it refuses when production has commits main does notFor projects-portal, jbnx-bill, mkt and the landing pages, Joey's call is PROD-IT — no test gate — so main → production is correct and the runbook and directive should be corrected to say so, because they currently describe a gate that does not exist for any repo. A documented control that isn't real is worse than no control, because agents trust it.
mkt.jbnx.io — invalid TLS certificate · DNS, ~60 secondsRecovered from the Railway dashboard on 2026-08-11 (the API does not return the TXT — the runbook's own hardest-won note). Railway shows the domain as "Waiting for DNS update."
Add both records to the jbnx.io zone in Cloudflare:
| Type | Name | Value | Proxy |
|---|---|---|---|
| CNAME | mkt | jk5fuc6j.up.railway.app | DNS only (grey) |
| TXT | _railway-verify.mkt | railway-verify=09fb7e489878b2e04b27011e3952bb23baf39940594209ed7e149230ff7d734e | n/a |
Both are also saved as facts on the full-stack slug.
curl -sI https://mkt.jbnx.io | head -1 # expect 200, no certificate error
Separately: mkt is a marketing page holding SUPABASE_SERVICE_KEY, the RLS-bypassing key. Swap it for the publishable key with an RLS policy, or move the read behind the portal API.
RAILWAY_TOKEN in the projects test environment — do not delete blindA Railway API token in a test build that shares a branch with production means anyone who can deploy to test can control the whole Railway account.
But your own credentials rule says a missing variable must throw at boot. I could not verify whether server.js requires RAILWAY_TOKEN at startup — the read was blocked by the permission classifier — so deleting it could break the test deploy. Check that the code tolerates its absence before removing it. If it is required, the right fix is a separate, scope-limited token for test, not deletion.
Applied and verified on 2026-08-11 — see /framework/estate-review-remediation-2026-08-11:
update_recurring overload droppedsearch_path pinned on 49 jbnx functionsAnd one correction worth carrying forward into jbnx-ops: revoke execute … from anon, authenticated silently does nothing on its own. Postgres grants EXECUTE to PUBLIC by default and both roles inherit through it. You must revoke from public and the named roles. The runbook records the mirror of this trap but not this half.