HALO CRM — analysis and 10x research
Observed 2026-08-29 · repo pinned at 347329a · read-only clone at clients/halocrm/repo Private mirror: github.com/sisodias/halocrm-mirror
Method: seven parallel Opus lanes (domain map · OSS replacements · Lovable/block extraction · security · vertical gaps · platform multipliers · competitor teardown), each required to verify counts from source and every GitHub repo via gh api before citing it. Lane outputs are in lanes/. Findings I verified personally are in lanes/VERIFIED-BY-MAIN-AGENT.md. Where a lane corrected me, the correction is recorded rather than quietly absorbed.
What it is
A creator-talent-agency CRM, built solo by Camron Kellman with Claude Code over roughly one month, running live on halocrm.vercel.app.
| Stack | Vite + React 18 + TS + shadcn/Tailwind, Convex backend, Vercel |
| Scale | 622 files · src/ 68,706 LOC · convex/ 11,274 LOC · 57 tables · 39 pages · 37 Convex modules |
| Origin | A Lovable project — lovable-tagger still wired at vite.config.ts:4, README still stock |
| State | Mid-migration off Supabase (39 files still import the Supabase client; 12 in components/payroll) |
| Tests | None. No vitest/jest, no test files |
Domains: creator onboarding + intake queue · contracts/e-sign · multi-currency invoicing (creator + referral) · payment log · payroll/attendance · referrals · Google-Drive-backed content pipeline with taxonomy/scan/index/thumbnails · gamification for the chatter team · AI voice cloning · credential vault · messaging · RBAC · dashboard analytics · customs tracker.
Part 1 — The security position
Convex functions are public by default. Anything exported as query/mutation/action is callable by anyone holding the deployment URL, which ships inside the client bundle. The React app is not a security boundary.
ctx.auth / getUserIdentity appears 0 times across all 37 Convex modules. There is no server-side authentication anywhere in the backend.
| Public | Internal | |
|---|---|---|
| query | 73 | 8 |
| mutation | 132 | 9 |
| action | 16 | 5 |
| Total | 221 | 22 |
Correction on the record: I first reported "456 public" by counting the substring
query(, which also matchesctx.db.query(database calls. The security lane caught it. The correct figure is 221 (91% of the surface). Direction unchanged, number fixed.
The three that matter
C1 — Creator platform credentials, plaintext and unauthenticated. socialMediaLogins.password is a plain string (convex/schema.ts:502). listByCreatorEmail (convex/socialMediaLogins.ts:20) is a public query taking only an email, and the module contains zero identity checks. Creators' OnlyFans/Instagram/TikTok passwords are retrievable by an unauthenticated caller. The Secure Area gate in front of it is cosmetic: convex/secureArea.ts:7 publicly returns the gate's own password hash, and savePasswordHash (:17) lets anyone overwrite it. Assume already exposed → encrypt at rest and rotate.
C2 — resetAndSeed is a public mutation that wipes the business. convex/resetHalo.ts:84 — no args, no auth check, iterates 42 tables calling ctx.db.delete on every document. Six more public seed/demo mutations mutate or clear live data. Fix is one word each: mutation → internalMutation. Highest value-per-keystroke item in this document.
C3 — Identity is self-asserted from localStorage. src/context/SupabaseAuthContext.tsx:320-324 parses haloLocalAuthSession and trusts role/roles verbatim; haloAuth.login issues no token. Edit the value in devtools → full admin. Underneath, password hashing is single-round salted SHA-256 (convex/haloAuth.ts:4-9,28), no stretching, no rate limiting, and five employee hashes
- salts are committed in cleartext at
convex/resetHalo.ts:6-38.
Correctly done — credit where due
Contract and invite tokens use crypto.getRandomValues at 160/144 bits (convex/contracts.ts:6, convex/creatorInvitations.ts:6) and signing is single-use. Real secrets are properly server-side (ElevenLabs, currency API, Drive credentials). VITE_RUNPOD_API_KEY sits in .env.example but is read nowhere in src/, so Vite never inlined it — the key is not burned. No secrets in any commit in history.
Part 2 — The money problem
Every monetary field is v.number() — float64. No integer minor units, no decimal type. Across 11,274 lines of Convex there are 2 Math.round/toFixed calls, and none in invoicing.ts, referrals.ts, referralInvoicing.ts or payroll.ts.
Three consequences, all verified at source:
- Catastrophic cancellation on nearly-settled invoices.
convex/referralInvoicing.ts:138 — Math.max(0, amount - paid) * conversionRate. Subtracting two accumulated floats can yield 1.8e-12 rather than 0; Math.max(0,…) doesn't catch a positive residue. A fully-paid invoice can show a non-zero balance.
- The same commission computed two contradictory ways.
convex/referrals.ts:534
accumulates creatorShare += gross * (percentage/100) per entry; :546 computes (gross * percent) / 100 on the summed gross. Float arithmetic guarantees these disagree sometimes. The app displays both, and no ledger arbitrates.
- A business term hidden in a null-coalesce.
entry.percentage ?? 50at
invoicing.ts:34, :65, :250 — any creator whose rate was never set is silently billed at 50/50. This is a commercial question, not a code style one.
There is also no audit trail: no append-only history anywhere in the 57 tables (customStatusHistory is customs-only). updatedAt records that a row changed, never what, from what, or by whom — in an app where staff edit revenue records daily.
Fix path: dinerojs/dinero.js (6,793★, MIT, v2.0.0 stable since 2026-03) for integer minor units and allocate(), which splits a total across ratios and distributes the remainder deterministically so parts always sum to the whole. A full double-entry engine (Formance, TigerBeetle) is out of proportion for a small agency; correct representation plus an append-only journal table in Convex is the right size.
Part 3 — The 10x list
Ranked by leverage. Every repo verified via gh api on 2026-08-29.
Install first
| What | Repo / action | Why |
|---|---|---|
| Unified deploy | Convex preview deployments (config, no dep) | Fixes the exact incident in his CLAUDE.md — frontend shipping ahead of backend. npx convex deploy --cmd 'npm run build' makes the failure structurally impossible. ~30 min. |
| Auth | get-convex/better-auth 765★ Apache-2.0 | First-party Convex component; kills C1/C3 at the root. Engine: better-auth/better-auth 29,744★ MIT. |
| Tests | get-convex/convex-test 22★ Apache-2.0 | The only way to unit-test 57 tables of Convex functions. Zero-tests problem starts here. |
| Durable jobs | get-convex/workflow 80★ Apache-2.0 | Drive provisioning is scheduler.runAfter fire-and-forget with no retry (googleDrive.ts:344-380); a Drive 500 becomes silent manual work. |
| Dashboard cost | get-convex/aggregate 31★ Apache-2.0 | 166 .collect() calls; dashboard.ts:6 scans the whole creators table then filters seven times in JS. Gets slower and pricier per creator, then throws. |
| Error visibility | Convex→Sentry native integration | One dev, no tests, 221 public functions. Currently learns of backend errors from user complaints. |
| Authorization | stalniy/casl 7,063★ MIT | Role ladder + per-resource flags is exactly its shape; same rules run server- and client-side. |
| Secrets | jedisct1/libsodium.js ISC / FiloSottile/age BSD-3 | Don't adopt a vault product — envelope-encrypt with a KMS-held key. ~200 lines, not a service. |
Safety item worth an hour
convex/driveScan.ts:252-253 fetches Drive thumbnails and calls ctx.storage.store(blob) unprocessed. Phone photos routinely carry GPS EXIF; Convex file URLs are unguessable but public. For a creator agency that is a physical-safety issue, not a compliance checkbox. Audit ten stored thumbnails for GPS tags before building anything — Google may already strip them on thumbnailLink, in which case the work is unnecessary. If not, lovell/sharp (32,613★ Apache-2.0, needs a Node action) or mattiasw/ExifReader (997★ MPL-2.0, pure JS).
Traps — verified, do not adopt
lucia-auth/lucia repositioned as a learning resource, not a library · osohq/oso deprecated per its own README (last push 2025-02) · crater-invoice-inc/crater stale ~2yr · hashicorp/vault now BSL (use openbao/openbao MPL-2.0) · get-convex/convex-auth has no LICENSE file at all + 154 open issues · invoiceninja is ELv2 (hosting forbidden) · Vaultwarden/Passbolt are human password managers, wrong shape for app-level secrets.
Licence note: flagged as metadata, never used to eliminate — selection is on engineering quality per the standing rule. The e-sign lane is uniformly AGPL (Documenso, DocuSeal, OpenSign); Documenso is TypeScript and therefore the one whose patterns transfer.
Part 4 — What this repo teaches the Action Model programme
This is a rare natural experiment: a Lovable project driven by a competent developer until it became a real app. It is an honest record of where prompt-to-app stops.
The Lovable/human boundary is not recoverable from git — history begins at 6bf902e with 631 files already tracked, including 24 Convex modules. Any git-derived percentage would be invented, so the lane declined to offer one. What survives is artefact evidence: stock README, lovable-tagger still wired, public/lovable-uploads/ still serving load-bearing brand assets 14 files deep.
The most telling single fact: git diff 6bf902e HEAD -- src/components/ui/ is empty. 5,283 LOC across 57 shadcn components, untouched through a month that rewrote half the app. Only 4 of 57 were ever restyled. The generated floor was good enough to build a business on — the ceiling is elsewhere.
Where the ceiling is — each with a structural reason, not a quality complaint: a server-side scheduler (crons.ts:16-21 — creators upload via share link, "polling is the only way to notice"); OAuth under a restricted scope (Workspace-Internal consent because External needs CASA verification for auth/drive); a taxonomy whose source of truth lives outside the code (driveStructure.ts:23 — the folder is USED : POSTED, spaces around the colon, and a mismatch silently creates a duplicate); idempotent failure-tolerant provisioning; an index with a deletion invariant (delete the thumbnail blob before the row, or orphan it forever); frozen FX rates so a sent invoice can't silently change value; a temporal-ownership model for referral links, because "a single pointer cannot answer what we were owed in September."
None of those are code a generator writes badly. They are decisions with nowhere to live in a prompt-to-app model, each one learned from a production failure.
The block finding. Creator and referral invoicing are one concept implemented twice — 8,385 LOC across both. The repo says so itself three times (referralInvoicing.ts:5-9 "the money-out counterpart"; schema.ts:181 "mirrors the creator invoicing workflow"; RemittancePdf.ts:5-14 "two house styles would read as two different businesses"). They share a settlement spine — running paid total, receipt storage, frozen FX rate, send-status tracked separately from payment, 0.01 settle epsilon, derived-not-stored status, compound index on (subject, period) — and differ on six axes that map cleanly onto a block contract's parameters: direction, period strategy, amount function, settlement currency, send-state set, and partial-payment hooks.
That is the shape of the argument for assembly over generation: not "a model can write a CRUD page," but "these 8,385 lines are one parameterised block written twice by a competent developer who knew it."
Part 5 — The market gap: what the vertical considers table-stakes
Verified against live vendor product pages (Infloww, Supercreator, CreatorHero, OnlyMonster, Scrile Connect), not marketing summaries.
**The one-sentence version: HALO is an excellent agency back-office — money, people, contracts, content storage. The commercial products are revenue-floor operating systems — fans, conversations, attribution. He built the half nobody sells, and is missing the half everybody sells.**
The structural gap: HALO's CRM object is the creator. The market's is the fan.
| Missing capability | Severity | Note |
|---|---|---|
| Fan CRM — per-fan record, tags, lifecycle, spend history | Critical | The biggest structural gap |
| Per-chatter revenue attribution ($ per chatter per shift) | Critical | He pays chatters but cannot rank them; commission pay is impossible without it |
| Shift management + handover (he has attendance only) | Critical | |
| Mass-message / PPV campaign tooling with segmentation | Critical | |
| Content "already sent to this fan" tracking | High | One join table away from his existing Drive index — highest ROI-per-hour item |
| Whale / top-spender surfacing | High | |
| Traffic-source link attribution to revenue | High | |
| Compliance word-blocking on outbound messages | High | |
| 1099 / contractor tax-form tracking | High | |
| Credential-free delegated access | High | The market shares access; HALO shares secrets — architecturally weaker, and it is the same finding as C1 from the other end |
Two of these converge with findings reached independently by other lanes — the credential model (C1) and per-chatter attribution (which the missing audit trail also blocks). When three lanes reach the same conclusion from different directions, it is worth weighting.
Deliberately out of scope: competitors market ban-evasion and detection-avoidance features. Noted as market context only; not researched, not recommended.
Open questions for Shaan
- Camron note —
FOR-CAMRON-security-note.mdis drafted, not sent. C1 (plaintext
credentials) and C2 (public wipe) are live-production issues on a friend's app. My read: send the short version soon, separately from this research.
- Fan-layer question — Part 5 says the market sells the half he lacks. Is that a
direction he wants, or is HALO deliberately a back-office and the fan layer someone else's product? That is a business call, not a technical one.
- Scope — is this a study asset for Action Model block extraction, or is there a
build engagement behind it? That changes what happens next.