JBNX · projects.jbnx.io/framework/fedm8

FedM8 Scan — Production & Subscription Readiness Audit

Target scan.fedm8.com · Framework Readiness Framework v3.0 · Tier assessed T1 (MVP Launch) · 31 July 2026

Method: live unauthenticated probing of the public surface, client-bundle inspection, and read-only inspection of the FedM8 Supabase project — grants, RLS policies, view definitions, function bodies and row counts.

5.00 / 10
Not cleared for paid T1 launch.
Gates failed: Billing 4 (needs ≥7), Legal 5 (needs ≥7), and six pillars below the universal floor of 6. Security passes at 7.

Bottom line

That headline is less bad than it looks, and the reason matters. FedM8 Scan is not a product that is charging money and failing; it is a product that has deliberately not started charging. The site says so in its own copy: “Free is live today. Basic and Pro are waitlists — Notify me, not a charge.” The free tier is genuinely live and the data behind it is real — 20,985 opportunities, 692,699 prime awards, 59,462 opportunity documents.

So the honest reading is this. The two pillars the framework treats as non-negotiable gates alongside Security — Billing and Legal — are exactly the two that only bind at the moment you take a card. Neither is broken; both are unfinished, and both are unfinished in ways that are cheap to close.

What is genuinely impressive is the part most audits find rotten: database authorization. Every paid surface is gated server-side through a single non-forgeable function, and every negative test run as an anonymous caller came back denied. That is a better result than most launched SaaS products would return.

What is genuinely weak is everything between the browser and that database: no security headers of any kind, and no integrity pinning on the one external script that has full access to the user session.

Scores

PillarWeightScoreWeighted
1. Reliability & Infrastructure15%60.90
2. Security & Compliance15%71.05
3. Observability & Operations10%40.40
4. Performance & Scalability10%60.60
5. Billing & Subscription Infrastructure15%40.60
6. Pricing & Packaging10%50.50
7. Conversion Funnel & Growth10%40.40
8. Retention & Revenue Health5%30.15
9. Legal & Trust5%50.25
10. Support & Customer Success5%30.15
Composite100%5.00

The five findings that matter

1. No subresource integrity on the one external script highest risk

Every page loads https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2 with no integrity attribute and a floating major-version tag. Zero occurrences of integrity= appear anywhere in the served HTML.

That script holds the authenticated session and every database call. A compromised CDN, or simply a bad release published under the @2 tag, executes with full user privilege on a site that handles veteran PII, CAGE codes and UEIs. The strong database authorization described below does not help here at all, because the attacker would be operating as the signed-in user.

Pin to an exact version and add an SRI hash. One line, and the highest-value fix in this report.

2. No security headers at all structural

Checked live on /. All six absent: strict-transport-security, content-security-policy, x-frame-options, x-content-type-options, referrer-policy, permissions-policy. The only CSP anywhere on the domain belongs to GitHub’s own 404 page, not to the app.

The cause is structural: the site is served by GitHub Pages (confirmed via server: GitHub.com and Fastly cache headers), which does not let you set response headers. There is no configuration fix — closing this means putting Cloudflare or equivalent in front of the origin, which also gets you HSTS and a CSP that would meaningfully contain finding #1.

Same layer, lower severity: access-control-allow-origin: * on all static assets, and robots.txt advertises /portal/, /_internal/VOICE_SCRIPTS.md and /supabase/ — a disallow line is a signpost, not a control.

3. Billing is well-architected, completely unexercised, and cannot currently take money

The design is better than most. billing.webhook_events is keyed by the Stripe event id, giving idempotency by primary key rather than by hopeful application logic; it carries an attempts counter whose column comment explicitly notes that a value above 1 means Stripe retried and the handler re-processed rather than swallowing it. billing.checkout_attempts has a rejected_client_price status, meaning prices are resolved server-side and client-supplied amounts are refused. Card data never touches your servers. Whoever built this understood the failure modes.

None of it has ever run. billing.webhook_events has 0 rows. billing.checkout_attempts has 0 rows. And neither plan in billing.plan_catalog has a stripe_price_id — both NULL — so billing-checkout cannot open a session today.

The framework requires a test-mode event log and a replay test as T1 evidence, and neither can exist until the thing runs once. The score of 4 reflects absent evidence, not bad design.

4. The Pro revenue gate is blocked by a note that is now stale revenue

billing.plan_catalog has Pro marked sellable = false with this reason, dated 2026-07-28:

“HELD by seat 21 … Award Intel, the whole Pro tier, is a hardcoded 25-row JavaScript literal and the awards table has 0 rows. Wired and tested end to end; will not open a checkout session until the product behind it is real. Release with sellable=true once FM-3 is done.”

That was the right call when it was written. It appears to have been overtaken by events. prime_awards now holds 692,699 rows, award_runs shows 20 completed loads, award_naics_state covers 605 NAICS codes — and the app no longer contains a hardcoded array. The client reads live, with a comment in the source stating the intent plainly: “a row in public.prime_awards or it is not on the screen.”

So the condition the hold was waiting on looks satisfied, and a $119/mo tier is switched off by a flag nobody went back to flip. Confirm against the FM-3 ticket before flipping — but commercially this is the highest-value item here.

5. Zero product analytics

No Sentry, no Google Analytics, no PostHog, no Segment — the one match for “plausible” in the source is the English word in a UI string, not the analytics tool. Nothing is instrumented.

The framework treats end-to-end funnel instrumentation as a T1 item, so this is a zero rather than a gap. It also leaves a smaller thing unanswerable: the waitlist table has 0 rows while the live site prominently invites “Notify me”. Either nobody has signed up, or the form is silently failing — and right now there is no way to tell which.

Test that form manually today. A broken waitlist on a pre-revenue product is the most expensive possible bug, because it destroys the only asset currently being accumulated.

Where it is genuinely strong

Database authorization passed every negative test. As an anonymous caller holding the publishable key: opportunities, prime_awards, opportunity_documents and vet_directory all returned permission-denied. admin_data_health and top_opportunities denied. profiles, subscriptions, waitlist, saved_searches and dev_log returned HTTP 200 with empty arrays — the correct signature of RLS filtering rather than a missing grant.

Entitlements are enforced where they should be, in the database. Every paid surface routes through my_plan(), which reads auth.uid() and the subscriptions table and cannot be influenced by the caller: opportunities and opportunity_revisions require basic or pro; opportunity_documents and prime_awards require pro. my_plan() returned "free" for the anonymous call, as designed.

The veteran directory is handled with real care. vet_directory is SECURITY DEFINER, which Supabase’s linter flags as an ERROR — but reading the definition shows the flag is a false alarm here. It filters on listed = true, masks cage_code, uei, contact_email and contact_phone behind a per-user contact_visible flag, and gates the entire view on my_plan() = 'pro'. Opt-in, field-level, paywalled. It needs SECURITY DEFINER precisely so it can read past the base table’s RLS. Correct design that happens to trip a generic lint.

Supabase’s advisor also reports a critical finding that three billing.* tables have RLS disabled. Tested rather than repeated: PostgREST returns PGRST106 — Only the following schemas are exposed: public, graphql_public. The billing schema is not reachable through the Data API, so the finding is mitigated. Worth knowing the mitigation rests entirely on one exposed-schemas setting; enabling RLS on those three tables would make it defence in depth rather than a single point.

profiles.is_admin is protected the hard way — authenticated holds no UPDATE grant on profiles at all, so the classic self-elevation path is closed at the privilege layer. Note the fragility: the policies on profiles use ownership-only WITH CHECK, so the moment anyone grants UPDATE to close a feature request, is_admin becomes self-writable. Worth a comment on the table.

Performance is excellent. The landing page returns in 31 ms TTFB, completes load in 294 ms, and transfers 6.3 KB. Fastly CDN caching is working. app.html is a 247 KB unminified single file — heavy, but cached and not currently a problem.

Recommended sequence

The first three are hours, not days.

  1. Manually test the waitlist form — every day it stays broken, if it is broken, is a day of lost signups.
  2. Pin the Supabase script to an exact version with an SRI hash — one line, closes the worst security exposure.
  3. Confirm FM-3 and, if done, set Pro sellable = true and populate the two stripe_price_id values so checkout can open.
  4. Put Cloudflare in front of GitHub Pages for HSTS, CSP and frame protection.
  5. Add error tracking and basic funnel analytics.
  6. Run one Stripe test-mode subscription end to end and replay the webhook, producing the evidence Pillar 5 requires.
  7. Write the refund and cancellation policy with a working cancel flow before charging anyone.
  8. Run a restore drill — 692,699 rows of award data have never been proven recoverable.

Two smaller items worth logging: saved_searches.webhook_url accepts a user-supplied Slack/Teams URL that a server-side worker will POST to, a server-side request forgery vector worth allowlisting to known webhook hosts; and anon retains INSERT/UPDATE/DELETE grants on cert_checklist_progress, dev_log and saved_searches that no policy backs — harmless today because auth.uid() is null for anon, but exactly the kind of dead grant that becomes live the moment someone adds a policy.

Scope and limitations

This audit covers the public surface and the database layer. Three things were not verified, and should not be read as passes.

Authenticated flows were not exercised. No account was created and no credentials were entered. The authenticated conclusions above are derived from reading the actual grants, policies and function bodies — high confidence for authorization questions, but not the same as a live session test. To close this, run the probes against a free-tier login and confirm that opportunities, prime_awards and vet_directory all return empty.

CI/CD, dependency scanning and branch protection are unassessed — no GitHub access in this session, so Pillar 1’s deploy and rollback items and Pillar 2’s dependency-scanning item are scored on absence of evidence.

Edge function source was not read. stripe-webhook correctly runs with verify_jwt: false (necessary for Stripe signature verification), but the handler was not confirmed to verify the signature, nor which event types it covers. Given billing has never run, this needs a code review before first charge regardless.

Per the framework’s own rubric, items scored on self-attestation without evidence are capped at 6, and much of what is scored 4–6 here is undocumented rather than absent. Where evidence exists that this audit could not see, the scores move up.

This framework is not legal advice — have counsel review your policies.