Date: 2026-08-11 · Scope: all JBNX / CipherDeck projects · Method: live read-only probes against Supabase (5 projects), Railway (6 projects), and every public HTTP surface. Every claim below carries the command or query that produced it.
| # | Finding | Severity | Effort |
|---|---|---|---|
| 1 | The projects service deploys production and test from the same branch (main). The PR → test → chat-approve → promote gate described in the directive is not enforced by any infrastructure. | Critical | Low |
| 2 | bill.jbnx.io — the customer-facing billing portal with a login — serves zero security headers. No HSTS, no CSP, no X-Frame-Options. It is framable and therefore clickjackable. | Critical | Low |
| 3 | projects.jbnx.io / hi.jbnx.io ship 371 KB uncompressed on every request. The server negotiates no gzip/brotli at all. This is the endpoint every agent hits on every boot. | High | Low |
| 4 | RAILWAY_TOKEN is present in both the test and production environments of the projects service; mkt.jbnx.io — a marketing page — holds SUPABASE_SERVICE_KEY; mkt.jbnx.io also serves an invalid TLS certificate. | High | Low |
| 5 | scan.fedm8.com — highest-sensitivity product (veteran PII, CAGE/UEI, Stripe) — has no terms, privacy or refund pages and no security headers. | High | Medium |
The directive (v35) and the runbook both describe a hard human gate: "main IS the test branch", "Production deploys from a separate production branch moved only by PROMOTE.cmd", "Do not auto-merge to prod."
Railway does not implement this.
get-service-config projects / production → source.branch = "main"
get-service-config projects / test → source.branch = "main"
get-service-config jbnx.io / production → source.branch = "main"
Both environments of the service that serves projects.jbnx.io and hi.jbnx.io build from main. A push to the test lane is a push to production. Confirmed live: production and test return byte-identical payloads (371,068 B each) and both report healthy from the same build.
https://projects.jbnx.io/healthz → {"ok":true,...,"host":"projects.jbnx.io"}
https://test.projects.jbnx.io/healthz → {"ok":true,...,"host":"test.projects.jbnx.io"}
This is the highest-leverage defect in the estate: the entire approval ceremony in the directive — chat review packs, approve/reject, PROMOTE.cmd — is procedural only. Any agent that follows the directive's other standing instruction ("main IS the test branch. Push to it") deploys straight to production, including to the master directive that every other agent depends on.
Recommendation. Point the production environment of projects (and jbnx.io) at a production branch and leave test on main. This is a one-field change per service and it converts the written gate into an enforced one. Until it lands, the directive's deploy section is describing a control that isn't there — which is worse than having no control, because agents are trusting it.
Measured with curl -sSI -L:
| Surface | HSTS | CSP | X-Frame-Options | nosniff | Referrer | Verdict |
|---|---|---|---|---|---|---|
| nodedough.com | ✅ 2 yr | ✅ full | ✅ DENY | ✅ | ✅ | Reference implementation |
| projects.jbnx.io | ✅ 1 yr | ❌ | ✅ DENY | ✅ | ✅ | Good, missing CSP |
| bill.jbnx.io | ❌ | ❌ | ❌ | ❌ | ❌ | Nothing at all |
| app.cipherdeck.com | ❌ | ❌ | ❌ | ❌ | ❌ | Nothing at all |
| scan.fedm8.com | ❌ | ❌ | ❌ | ❌ | ❌ | Nothing at all (GitHub Pages) |
| www.jbnx.io | ❌ | ❌ | ❌ | ❌ | ❌ | Nothing at all |
bill.jbnx.io is the one customers log into and pay through. With no X-Frame-Options and no CSP frame-ancestors, it can be embedded in a hostile page and its login overlaid. The service already carries a BILL_SSO_FRAME_ANCESTORS variable, which implies framing is intentionally allowed for SSO — but an allowlist you intend to enforce and a header you never send are different things. Right now the allowlist is *.
The directive already warns that bill.jbnx.io must stay grey-clouded on Cloudflare, so Cloudflare will not add these headers for you. They have to come from the app.
Recommendation. Lift nodedough.com's header block verbatim into jbnx-bill, cipherdeck-apps, and the jbnx.io landing service. For bill.jbnx.io specifically, send Content-Security-Policy: frame-ancestors <the values already in BILL_SSO_FRAME_ANCESTORS> rather than X-Frame-Options: DENY, so SSO framing keeps working. nodedough already proves the pattern ships without breaking anything.
curl -H 'Accept-Encoding: br,gzip,deflate' -I <url>
projects.jbnx.io → NO content-encoding (371,068 B)
hi.jbnx.io → NO content-encoding ( 32,259 B)
bill.jbnx.io → NO content-encoding ( 32,776 B)
nodedough.com → NO content-encoding ( 36,962 B)
app.cipherdeck.com → content-encoding: br (745,389 B → 215,382 B)
app.cipherdeck.com compresses and gets a 3.5× reduction. The raw Node http server in projects-portal/server.js does not negotiate encoding at all, so the portal ships 371 KB of mostly-inline JavaScript uncompressed on every load — to browsers and to every agent that boots.
Recommendation. Add zlib.createBrotliCompress / createGzip keyed off Accept-Encoding in server.js. For a single hand-rolled server this is roughly fifteen lines and should take the portal to the 60–80 KB range. Same change fixes hi.jbnx.io, which is served by the same service. Do this before any further work on portal payload size — it is the cheapest win available anywhere in the estate.
Second-order: the portal is one 371 KB HTML document with the SPA inlined. That is a deliberate choice (data embedded server-side) and it does buy a fast first paint, so compression is the fix, not a rewrite.
From get-service-config (names only — no values were read):
projects service, both environments**, carries RAILWAY_TOKEN, SUPABASE_SERVICE_KEY, OPENAI_API_KEY, BILL_SSO_SECRET, PORTFOLIO_DATABASE_URL. A Railway API token inside a service means compromise of that one service is compromise of the Railway account — and the token is in the test environment too, which by finding #1 is the same build as production.mkt service (mkt.jbnx.io, a marketing site) carries SUPABASE_SERVICE_KEY — the RLS-bypassing key — with nothing else. A brochure page does not need a key that can read every row in the jbnx project.bill service carries GITHUB_TOKEN and ADMIN_PASSWORD alongside DATABASE_URL. A billing service holding a GitHub token widens a payments breach into a source-code breach.Recommendation, in order: (a) remove RAILWAY_TOKEN from the projects test environment today — nothing in a test build should be able to redeploy production; (b) determine what mkt actually reads and swap it to the publishable key with an RLS policy, or move the read behind the portal API; (c) move GITHUB_TOKEN out of bill into the GitHub Actions secret store where the repo work belongs.
Also worth noting: one Railway project (ai.jbnx.io) now hosts projects, bill, litellm, langfuse, mkt, mockups, web, Postgres and Redis — nine services including the directive, the billing system, and the model gateway. The runbook's infrastructure map does not mention bill, mkt, mockups, langfuse, litellm, or the hub.jbnx.io domain at all.
mkt.jbnx.io is brokenhttps://mkt.jbnx.io → curl (60) SSL: no alternative certificate subject name
matches target host name 'mkt.jbnx.io'
Every other host in the estate returns 200 with a valid certificate, including www.jbnx.io and the apex jbnx.io — so the certificate problem the runbook records against www.jbnx.io is fixed and the runbook is stale. mkt.jbnx.io has inherited it. Per the runbook's own hard-won note, this is almost certainly the missing TXT validation record that the Railway API does not return; it has to be copied from the dashboard.
security_definer_view ERRORs on the jbnx project are not exploitable. All 48 are pf_* views. A direct privilege check shows none of them grants SELECT to anon or any DML to authenticated — they are reachable only by the service key the portal holds. The advisor is correct that they are SECURITY DEFINER and will keep reporting ERROR; the exposure it implies is absent.admin_search_users is anon-executable and contains no guard of its own, but it delegates to admin_list_subscribers, which calls admin_require() — which raises not authenticated when auth.uid() is null. The chain is safe.admin_* functions reachable by anon carry an internal require_platform_admin / is_platform_admin / admin_guard_target check.anon. Every policy on nodes, node_edges, recurring_items, institutions, institution_members gates on my_role(institution_id), which resolves through auth.uid() and yields null for anonymous callers.undo_node_deletion is correctly scoped — where id = p_snapshot and deleted_by = auth.uid().a. update_recurring has two live overloads with different security models. (NodeDough — data integrity, not a privilege bypass)
update_recurring(p_item, p_name, p_amount, p_unit, p_count,
p_variability, p_account, p_direction)
→ prosecdef = false, anon+authenticated EXECUTE, NO membership guard
update_recurring(… , p_amount_mode, p_funded_from)
→ prosecdef = true, anon+authenticated EXECUTE, guarded by require_member_institution
The 8-argument version is the repo-lineage leftover the runbook predicted. Because it is SECURITY INVOKER, RLS still protects it — so this is not an authorisation hole. But it skips every validation the 10-argument version added: no direction/unit whitelist, no interval bounds, no amount check, no transfer-group refusal, and it will silently create orphan flow nodes. PostgREST resolves overloads by the parameter names supplied, so an older client — or a cached bundle — lands on the unvalidated path and writes junk that the newer code assumes cannot exist.
Recommendation. Drop the 8-argument overload. Confirm no live client sends that exact parameter set first. This is the clearest single instance of the two-lineage problem doing real harm, and it is a one-line migration.
b. recurring_items read and write policies disagree about scope.
items_read USING (node_id IN (SELECT my_visible_node_ids()))
items_write USING (my_role(institution_id) IN ('owner','member'))
Reads are gated on node visibility; writes are gated on institution membership. Where visibility is narrower than membership, a member can write a row they then cannot read — and can attach a recurring item to a node outside their visibility. Align items_write's WITH CHECK to the same my_visible_node_ids() predicate.
c. validate_invite_code is an unauthenticated enumeration oracle. (NodeDough) It is anon-executable and returns a bare boolean for any code, with no rate limit at the database layer. Low severity — invite codes should be high-entropy — but it costs nothing to require authentication or to move the check behind the app.
d. Trigger functions are exposed as RPCs. trg_recurring_transfer_delete_pair and trg_recurring_transfer_end_pair are anon-executable over /rest/v1/rpc/. Trigger functions have no business being callable directly. revoke execute … from anon, authenticated on both.
e. 138 SECURITY DEFINER functions on FedM8, 130 on NodeDough are API-reachable. Each one is safe only because of a guard inside its own body. That is a large surface defended entirely by convention, on the project holding veteran PII. Per the runbook's own rule — revoking EXECUTE too broadly breaks portal views — the fix is not a blanket revoke. It is a checklist: for each function, does it need to be in the exposed API at all? Most admin_* functions should be called by a server holding the secret key, not exposed to anon and defended by an internal if.
f. function_search_path_mutable: 49 on jbnx, 7 on FedM8, 3 on NodeDough. Mostly in agent_ops (34) and portfolio (14). A mutable search_path on a SECURITY DEFINER function is a privilege-escalation primitive. alter function … set search_path = '' is mechanical and safe; batch it.
g. rls_enabled_no_policy: 64 on jbnx, 19 on NodeDough, 11 on FedM8. These fail closed, so they are not exposures — but three on FedM8 are in the billing schema (checkout_attempts, plan_catalog, webhook_events), which means those tables are reachable only by the service key. Confirm that is intentional rather than a policy someone forgot to write.
Structural probe of the delivered HTML/CSS of each surface.
| Surface | Size | ARIA | Roles | Skip link | Focus styles | Reduced motion | Verdict |
|---|---|---|---|---|---|---|---|
| projects.jbnx.io | 371 KB | 28 | 15 | ✅ | 28 | ✅ | Strongest a11y in the estate |
| nodedough.com | 37 KB + 60 KB CSS | 82 | 35 | ❌ | 7 | ✅ | Strong |
| app.cipherdeck.com | 745 KB | 66 | 6 | ❌ | 15 | ✅ | Weak for a paid product |
| bill.jbnx.io | 33 KB | 2 | 2 | ❌ | 1 | ❌ | Weakest customer-facing surface |
| ai.jbnx.io | 7 KB | 0 | 0 | ❌ | 0 | ❌ | 20 buttons, 9 inputs, no affordances |
| cipherdeck.com | 8 KB | 3 | 0 | ❌ | 0 | ❌ | No <h1> at all |
Specific defects worth fixing:
cipherdeck.com has no <h1>. Its heading tree starts at <h2>Where to shop. On the public storefront this costs both SEO and screen-reader orientation. One line.app.cipherdeck.com has 35 icon-only <button> elements with no accessible name, and roughly six <img> tags built inside JS template strings with no alt attribute. Screen-reader users get 35 buttons announced as "button". This is the largest concrete a11y debt in the estate and it sits on a paid product.bill.jbnx.io has 2 ARIA attributes, 1 focus rule, 2 media queries, no dark mode and no reduced-motion support across the whole customer billing portal. Customers who pay you meet this page. It deserves at least what nodedough already has.ai.jbnx.io renders 20 buttons and 9 inputs with zero ARIA, zero roles and zero focus styling. Keyboard users cannot see where they are.fonts.googleapis.com (portal, bill, cipherdeck, app.cipherdeck, jbnx.io). That is a render-blocking third-party request on first paint and, for EU visitors, a third-party transfer with a documented history of DPA findings. Self-hosting the woff2 files removes both problems; nodedough already self-hosts its CSS.The good news: projects.jbnx.io already does this well — skip link, focus-visible rules, prefers-reduced-motion, prefers-color-scheme, 25 media queries, clamp() type scaling. It is a working in-house reference. The gap is that none of it has propagated to the other surfaces.
| Product | Terms | Privacy | Refunds | Verdict |
|---|---|---|---|---|
| nodedough.com | ✅ /legal/terms | ✅ /legal/privacy | ✅ /legal/refunds | Blocker cleared |
| app.cipherdeck.com | ✅ /terms | ✅ /privacy | ❌ | Add refunds before charging |
| scan.fedm8.com | ❌ | ❌ | ❌ | Not ready to charge |
The runbook records NodeDough's paid-launch blocker as "no ToS/privacy/refund pages at all." That is resolved — all three exist and are linked from the shell.
scan.fedm8.com is the exposure. It is the highest-sensitivity product in the estate — veteran PII, CAGE/UEI, Stripe — it is served as a static bundle from GitHub Pages behind Cloudflare (not Railway), it carries no security headers, and it publishes no terms, privacy notice or refund policy. Handling veteran PII without a published privacy notice is a compliance problem independent of whether you are charging yet.
Six facts in jbnx-ops are now wrong. Each one would send an agent the wrong way:
| Runbook says | Actually |
|---|---|
| directive is v26 | live is v35 |
| FedM8 (not on Railway) | Railway project fedm8 exists — fedm8-com, scan-worker, litellm |
www.jbnx.io serves a mismatched certificate | fixed; mkt.jbnx.io is the broken one now |
jbnx.io service holds SUPABASE_SERVICE_KEY, ANTHROPIC_API_KEY | now only AGENT_GATE_TOKEN, ANCIENT_GATE_TOKEN |
| undo-deletion and referral RPCs "still missing live" on NodeDough | all present: delete_node_with_undo, undo_node_deletion, discard_node_undo, claim_referral, validate_invite_code |
| NodeDough has "no ToS/privacy/refund pages at all" | all three live under /legal/ |
| infrastructure map lists 7 projects | estate is larger: bill/hub.jbnx.io, mkt, mockups, langfuse, litellm, nguy.in, tensuite.net, scytherdeck.com, demo.jbnx.io, plus a nodedough-test Supabase project |
There is also an undocumented nguy schema on the jbnx Supabase project holding webauthn_credentials, webauthn_challenges and sessions — a personal/family app sharing the database that holds billing and agent_ops. That may be deliberate, but it is not written down anywhere, and it means a mistake in a side project touches the company's billing data.
Recommendation. The runbook says "Last reviewed 2026-08-01" and it has drifted in ten days. Add a scheduled drift check that diffs the documented infrastructure map against list-projects / list-services / get-service-config and files a request when they disagree — the same argument the runbook already makes for the migration-lineage CI check.
This week — all low-effort, high-consequence
projects production at a production branch; leave test on main. (finding 1)RAILWAY_TOKEN from the projects test environment. (finding 4)bill, cipherdeck-apps, jbnx.io; use frame-ancestors on bill. (finding 2)projects-portal/server.js. (finding 3)mkt.jbnx.io TXT record from the Railway dashboard. (finding 5)<h1> to cipherdeck.com. (finding 7.1)This month
update_recurring overload; align items_write with items_read. (6a, 6b)anon/authenticated on the two trigger functions and on validate_invite_code. (6c, 6d)alter function … set search_path = '' across the 59 flagged functions. (6f)scan.fedm8.com; add refunds to app.cipherdeck.com. (finding 8)<img> tags in cipherdeck-apps. (7.2)SUPABASE_SERVICE_KEY off mkt; move GITHUB_TOKEN off bill. (finding 4)This quarter
if. (6e)All findings come from live read-only probes: Supabase advisors and pg_catalog queries across 5 projects, Railway get-service-config (names only — no variable values were read), and HTTP probes of 18 public hosts. No repository source was read — GitHub access was not available in this session, so server.js, agent.sh, the jbnx-bill credits arithmetic and the NodeDough client bundle were assessed only through their live behaviour and their database surface. A follow-up pass with repo access would sharpen findings 3, 6a and the credits-constant risk the directive flags in four places.
Nothing in this review was changed. Every item above is a recommendation.