auditestate-review-2026-08-11 · v11099:claude-cowork-audit2026-08-11served from databaseAll documents

Estate review — code, data access, infrastructure and design (2026-08-11)

Estate review — code, data access, infrastructure and design

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.


BLUF — the five things worth doing first

#FindingSeverityEffort
1The 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.CriticalLow
2bill.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.CriticalLow
3projects.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.HighLow
4RAILWAY_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.HighLow
5scan.fedm8.com — highest-sensitivity product (veteran PII, CAGE/UEI, Stripe) — has no terms, privacy or refund pages and no security headers.HighMedium

1. Deploy governance — the gate does not exist

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.


2. Security headers — inconsistent, and weakest where it matters most

Measured with curl -sSI -L:

SurfaceHSTSCSPX-Frame-OptionsnosniffReferrerVerdict
nodedough.com✅ 2 yr✅ full✅ DENYReference implementation
projects.jbnx.io✅ 1 yr✅ DENYGood, missing CSP
bill.jbnx.ioNothing at all
app.cipherdeck.comNothing at all
scan.fedm8.comNothing at all (GitHub Pages)
www.jbnx.ioNothing 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.


3. Performance — the master-source service sends nothing compressed

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.


4. Credential blast radius

From get-service-config (names only — no values were read):

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.


5. TLS — mkt.jbnx.io is broken

https://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.


6. Database and data access

The good news, verified

What still needs attention

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.


7. Design and accessibility

Structural probe of the delivered HTML/CSS of each surface.

SurfaceSizeARIARolesSkip linkFocus stylesReduced motionVerdict
projects.jbnx.io371 KB281528Strongest a11y in the estate
nodedough.com37 KB + 60 KB CSS82357Strong
app.cipherdeck.com745 KB66615Weak for a paid product
bill.jbnx.io33 KB221Weakest customer-facing surface
ai.jbnx.io7 KB00020 buttons, 9 inputs, no affordances
cipherdeck.com8 KB300No <h1> at all

Specific defects worth fixing:

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. Four surfaces load Google Fonts from 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.


8. Launch readiness

ProductTermsPrivacyRefundsVerdict
nodedough.com/legal/terms/legal/privacy/legal/refundsBlocker cleared
app.cipherdeck.com/terms/privacyAdd refunds before charging
scan.fedm8.comNot 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.


9. The runbook is drifting from reality

Six facts in jbnx-ops are now wrong. Each one would send an agent the wrong way:

Runbook saysActually
directive is v26live is v35
FedM8 (not on Railway)Railway project fedm8 exists — fedm8-com, scan-worker, litellm
www.jbnx.io serves a mismatched certificatefixed; mkt.jbnx.io is the broken one now
jbnx.io service holds SUPABASE_SERVICE_KEY, ANTHROPIC_API_KEYnow only AGENT_GATE_TOKEN, ANCIENT_GATE_TOKEN
undo-deletion and referral RPCs "still missing live" on NodeDoughall 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 projectsestate 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.


Sequenced plan

This week — all low-effort, high-consequence

  1. Point projects production at a production branch; leave test on main. (finding 1)
  2. Remove RAILWAY_TOKEN from the projects test environment. (finding 4)
  3. Add the nodedough header block to bill, cipherdeck-apps, jbnx.io; use frame-ancestors on bill. (finding 2)
  4. Add brotli/gzip to projects-portal/server.js. (finding 3)
  5. Fix the mkt.jbnx.io TXT record from the Railway dashboard. (finding 5)
  6. Add an <h1> to cipherdeck.com. (finding 7.1)

This month

  1. Drop the 8-argument update_recurring overload; align items_write with items_read. (6a, 6b)
  2. Revoke EXECUTE from anon/authenticated on the two trigger functions and on validate_invite_code. (6c, 6d)
  3. alter function … set search_path = '' across the 59 flagged functions. (6f)
  4. Publish terms, privacy and refunds for scan.fedm8.com; add refunds to app.cipherdeck.com. (finding 8)
  5. Accessible names for the 35 icon buttons and the dynamic <img> tags in cipherdeck-apps. (7.2)
  6. Move SUPABASE_SERVICE_KEY off mkt; move GITHUB_TOKEN off bill. (finding 4)

This quarter

  1. Audit the ~270 API-reachable SECURITY DEFINER functions on FedM8 and NodeDough — decide per function whether it belongs in the exposed API at all, rather than defending each one with an internal if. (6e)
  2. Reconcile the NodeDough migration lineages and add the CI drift check already on record.
  3. Add the infrastructure drift check for the runbook. (finding 9)
  4. Self-host fonts; propagate the portal's a11y baseline to bill, ai.jbnx.io and cipherdeck-apps. (7.3–7.5)

Method and limits

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.