frameworkcursor-brief-2026-08-11 · v11099:claude-cowork-audit2026-08-12served from databaseAll documents

Cursor brief — remaining JBNX estate fixes (2026-08-11)

Cursor brief — JBNX estate fixes (2026-08-11)

Paste this whole file into Cursor. It is self-contained.


Before you start

  1. Re-fetch https://hi.jbnx.io/md live. It is now v36 and the deploy model changed today — do not work from a remembered copy.
  2. Claim before you touch anything:
   ./scripts/agent.sh sign-in
   ./scripts/agent.sh claim <slug> "what you'll do"
   ./scripts/agent.sh boot
   
  1. You already hold fedm8 (1099:cursor-agent, lease to 2026-08-12 08:46). Task 1 is yours because of that lease.
  2. Record usage and release when done. One claim = one billable session; never double-post.

The two lanes (directive v36)

LaneReposRule
Gatednodedough, fedm8, fedm8-scan, cipherdeck, cipherdeck-appsPR → verify on test → chat approve → promote to production
Ungated (PROD-IT)jbnx.io/projects-portal, jbnx-bill, mkt, landing pagesmain IS production. Push deploys live. Verify the live URL.

Task 1 — create the two missing production branches · you hold this claim

production branches were created today on nodedough, cipherdeck and cipherdeck-apps. The two FedM8 repos were skipped because you hold the lease.

for r in fedm8 fedm8-scan; do
  sha=$(gh api repos/jbnx/$r/git/ref/heads/main --jq .object.sha)
  gh api repos/jbnx/$r/git/refs -f ref=refs/heads/production -f sha="$sha"
done

Verify: gh api repos/jbnx/fedm8/git/ref/heads/production --jq .object.sha

Then tell Joey — the Railway half is dashboard-only (update-service excludes source changes). For all five gated services: Settings → Source → "Branch connected to production" → mainproduction, leaving test on main. Until that is done the branches are inert and the gate in v36 is still not real.


Task 2 — response compression on projects-portal/server.js · PROD-IT

Repo jbnx/jbnx.io, path projects-portal/server.js. Push to main — that is production for this repo.

Measured: projects.jbnx.io returns 371,068 B with no Content-Encoding even when the request offers br,gzip,deflate. The source has no zlib and no accept-encoding handling in 4,336 lines. It is a raw Node http server with 22 writeHead() call sites and 0 setHeader() — so wrap the response once, do not patch 22 sites.

⚠️ Read this before you write a line

The portal serves a live /api/events EventSource feed. A normal compression wrapper buffers the body until end() and permanently breaks SSE — the feed hangs open and never flushes. The wrapper must bail out on (a) the SSE path and (b) any response that calls res.write() instead of a single res.end(body).

Add with the other requires:

const zlib = require('zlib');

Add above the request handler:

// Compress single-shot responses. Bails out on streaming/SSE responses,
// which must not be buffered. 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;
  if (String(req.url || '').startsWith('/api/events')) return;   // never buffer SSE

  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; };
  res.write = function (...args) { 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);
    });
  };
}

First line inside the http.createServer(...) handler:

enableCompression(req, res);

Verify — all four, and #4 is the one that matters

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 ~60–80k (was 371,068)
curl -s https://hi.jbnx.io/json | head -c 40                                                  # expect version 36
curl -s -o /dev/null -w '%{http_code}\n' https://projects.jbnx.io/healthz                     # expect 200
curl -N -m 8 -H 'Accept: text/event-stream' https://projects.jbnx.io/api/events | head -c 200  # MUST stream, not hang

If the SSE check hangs, revert immediately. That feed is what every portal client depends on, and this service also serves hi.jbnx.io — breaking it strands every agent including you.


Task 3 — security headers on jbnx-bill · PROD-IT

Repo jbnx/jbnx-bill, server.js (2,167 lines, 6 writeHead, 2 setHeader).

Measured: bill.jbnx.io sends 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 clickjackable right now.

Do not use X-Frame-Options: DENY. The service carries BILL_SSO_FRAME_ANCESTORS — framing is intentional for SSO. Use CSP frame-ancestors driven by that variable; it is currently enforcing nothing.

// Security headers. 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); first in the request handler.

⚠️ includeSubDomains on jbnx.io affects every *.jbnx.io host. All 17 probed on 2026-08-11 already serve HTTPS, so this should be safe — confirm nothing is intentionally plain HTTP first.

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
# then log in once by hand and confirm SSO framing still works

While you're in there: CREDITS_PER_HOUR appears 9 times in this file. The directive says it lives in 4 places and that a figure off by 100× is a defect. Every occurrence should reference one const CREDITS_PER_HOUR = 6000;, never a repeated literal.

Apply the same header block to jbnx/cipherdeck-apps and the jbnx.io landing service — both also send nothing. nodedough.com is the reference implementation; copy its set.


Task 4 — cipherdeck.com has no <h1> · gated (TEST-IT)

Repo jbnx/cipherdeck, index.html — 179 lines, 7,826 bytes, <h1> count = 0, first heading is <h2>Where to shop.

Add a real <h1> as the opening heading. Keep the existing <h2>s as section headings. If the design cannot change, minimum acceptable:

<h1 class="sr-only">CipherDeck — graded trading cards, shipped protected and tracked</h1>
.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>. Verify: curl -s https://cipherdeck.com | grep -c '<h1' → 1.


Task 5 — cipherdeck-apps accessibility · gated (TEST-IT)

Measured on the live 745 KB bundle: 35 icon-only <button> elements with no accessible name, and ~6 <img> built inside JS template strings with no alt. Screen-reader users hear "button" 35 times on a paid product.


Task 6 — FedM8 legal pages and hostnames · gated (TEST-IT), you hold this claim

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 refunds. Neither sends any security header. This is the product handling veteran PII, CAGE/UEI and Stripe.

  1. Publish /terms, /privacy, /refunds. NodeDough's /legal/* is the model.
  2. Pick one hostname; 301 the other. Two independent copies will drift.
  3. Front end is GitHub Pages behind Cloudflare, so the app can't set headers — use a Cloudflare Transform Rule on the zone instead.

Handling veteran PII with no published privacy notice is a compliance problem independent of whether you're charging yet.


Task 7 — sync the directive's repo copy · PROD-IT

The directive is a database row (agent_ops.policy key project_llm_directive), and it was updated to v36 today. Its source still points at sql/directive-v35.md, so the repo copy is stale and the runbook's "confirm /md matches the repo source file" check would now fail.

  1. Pull the live text: curl -s https://hi.jbnx.io/md > sql/directive-v36.md
  2. Commit it, delete sql/directive-v35.md.
  3. Clear the drift marker:
   update agent_ops.policy
   set value = value - 'source_drift'
             || jsonb_build_object('source','sql/directive-v36.md')
   where key = 'project_llm_directive';
   

Backup of v35 is at policy key project_llm_directive_backup_v35 — leave it.


Two database traps that cost real time today

1. Revoking function EXECUTE takes both halves. This reports success and changes nothing:

revoke execute on function public.f() from anon, authenticated;   -- anon still inherits via PUBLIC

Postgres grants EXECUTE to PUBLIC by default and Supabase grants it to the roles directly. You need:

revoke execute on function public.f() from public, anon, authenticated;

Always re-check has_function_privilege afterwards — asserting "no exception" is not asserting the effect.

2. Never blanket set search_path = ''. It breaks any function whose body uses unqualified names. Pin to the function's own schema instead: alter function s.f() set search_path = s, public;

3. Check the call site before revoking from anon. validate_invite_code on NodeDough must stay anon-callable — signup calls it before the user authenticates. An audit recommended revoking it; that would have broken signup.


Already done today — do not redo

Full detail: /framework/estate-review-2026-08-11, /framework/estate-review-remediation-2026-08-11, /framework/patch-pack-2026-08-11.