fable-mode skill (five-gate discipline β this is a security + live-data migration)base44-workflow skill (safe read/test/edit/deploy + the RLS shorthand trap + functions-deploy-LAST)ponytail full (smallest change that fixes each thing)reference/vibe-coding-safety-net.md + reference/fable-mode-money-path-audit.md6a46ad7a89bffa371e7637da β live https://alluring-scout-your-leads.base44.app β sandbox app, git_remote_source: s3.scan-campaign, verify-leads, push-to-ghl. Entities: Campaign, Lead, ScrapeJob, UsageEvent, WorkspaceSettings, Invite (+ User).LeadScout Copy 6a46b10699d9700eef138ce8 (Sondra's pre-existing copy β untouched, confirmed alive).6a52ddc053d0beaf82471882 ("before city-scan + campaign-detail fixes").PROJECTS/LeadScout DFY/sandbox-work/ (scaffold; base44/functions/ pulled incl. edited scan-campaign/entry.ts), PROJECTS/LeadScout DFY/clone/ (ejected frontend + entities, node_modules), scratch/leadscout-app-definition.json, scratch/leadscout-fns/.js (all 3 fn sources).---
Goal was "fix 3 broken things." All done, deployed to the master, verified by a real run.
src/pages/CampaignDetail.jsx + route /campaigns/:id in App.jsx. Opens a campaign β its leads (filtered), scan status, counts (Scanned/No-website/Social-only/Leads-saved), Launch-scan + Verify + Push-to-GHL + Export scoped to it.Home.jsx stat tiles navigate (Campaigns/Leads/No-Websiteβ/leads?site=no_website/Social-Only); recent-campaign rows open detail. StatCard.jsx gained optional onClick. Leads.jsx reads ?site=/?campaign= initial filters. Campaigns.jsx rows are clickable. AppLayout.jsx nav stays active on /campaigns/*.Campaigns.jsx New-Campaign form has a 3-way toggle. Entity Campaign changed: zip_codes now OPTIONAL (required is [name, niche]), added search_areas (array) + location_mode (enum zips/city/county) β back-compatible (old zip campaigns still open). scan-campaign builds the Outscraper query from search_areas β else zip_codes β else location_label; stamps zip only when the area is a real 5-digit zip.6a52df17f052b16b0860363f, no zips, limit 3) β scan returned 3 real Stuart florists, correct city + real zips, scored. Live bundle = index-BjIA-6jH.js, master id baked in, all change-strings present. Entities intact (6), all 3 functions re-registered. Eject copy 6a52e0b6β¦ deleted.created_by: campaigns = sondra (filter-proven), leads = none. The "TEST - Stuart FL florists" campaign + its 3 leads are safe to delete (mine, not real prospects) β left in place as working proof; delete on Sondra's word.---
Sondra: "I have to be able to sell this... make sure everything is working." She chose HANDOFF β fresh session for this because it's a security + live-data migration on her 154 real leads and this session crossed her 250K freshness line.
Two CRITICAL holes, both confirmed by actually running them:
public_settings = "public_without_login" + RLS is null on ALL 6 entities. Anonymous GET to .../entities/Campaign and .../entities/Lead returned 200 + full records (pulled a real business name/phone/address with zero auth). Every buyer's leads would be public.push-to-ghl + verify-leads got inside the function (400 "No leads selected" = past the door). Real IDs β burns Outscraper/Firecrawl/GHL credits + mutates others' leads. All 3 functions run base44.asServiceRole with no auth.me() gate and no ownership check.WorkspaceSettings (fields daily_outscraper_cap, daily_firecrawl_cap, max_results_per_scan) is only read/written by SettingsPage.jsx. UsageEvent (kind, units) is never written. No function checks a cap β unlimited spend./privacy.user.role === 'admin', first-signup-is-admin β cloner becomes their own admin; no hardcoded email); no Sondra branding in code; functions fail gracefully when a key is missing; secrets are app-level and DON'T travel to clones (buyers add their own keys β buyers never spend Sondra's money).created_by is a SYSTEM field: honored on create, IMMUTABLE on update (proven: updating a real lead's created_by β filter count 0β0). So you cannot backfill the 157 leads' owner via created_by.owner_email field on Lead for ownership; backfill it via update.created_by = sondraverva@gmail.com (filter-proven) β they can use created_by directly.hide_entity_created_by = False. base44 secrets list = OUTSCRAPER_API_KEY, FIRECRAWL_API_KEY, GHL_API_KEY, GHL_LOCATION_ID (all app secrets).npx base44 sandbox checkpoint --name "before sellable-security fixes".public_settings off public_without_login (try npx base44 auth β¦; if not CLI-exposed, Sondra toggles "require login" in the builder). Verify: anon GET /entities/Lead β 401.owner_email (string) to Lead (sandbox edit base44/entities/Lead.jsonc, syncs live). Add it to ScrapeJob + UsageEvent too if you scope those.base44/functions/*/entry.ts, they're pulled in sandbox-work):const me = await base44.auth.me().catch(()=>null); if (!me) return Response.json({error:"Sign in required"},{status:401});scan-campaign: require campaign.created_by === me.email || me.role === "admin"; on each Lead.create add owner_email: campaign.created_by.verify-leads / push-to-ghl: for each lead require lead.owner_email === me.email || me.role === "admin" before acting.daily_outscraper_cap/daily_firecrawl_cap, and write a UsageEvent per run (kind = outscraper/firecrawl/ghl, units = count). Respect max_results_per_scan.base44.asServiceRole for the writes (it bypasses RLS by design) β identity/authz is the me check above.Lead.update(id, { owner_email: <its campaign's created_by> }) (campaign owner = sondra for all current data). Custom field β update works.rls, not row_level_security) on every data entity, sandbox-editing each .jsonc:read/update/delete {created_by:"{{user.email}}"}, create {created_by:"{{user.email}}"}. Add an admin-sees-all clause if the platform supports OR-ing role==admin.read/update/delete {data.owner_email:"{{user.email}}"}; block anon create (users never create leads directly β only the service-role function does, which bypasses RLS).created_by works).VITE_BASE44_APP_ID=6a46ad7a89bffa371e7637da npm run build β verify bundle has master id + no copy id β site deploy with base44/entities set aside β functions deploy LAST (site deploy wipes the fn registry). Delete the eject "Copy"./entities/Lead β 401; (b) anon POST to each function β 401; (c) account B's dashboard empty + B cannot read A's campaign/lead by URL or API; (d) B cannot verify/push A's leads. Sondra (admin) still sees her 157.is_remixable ON + write NEW-OWNER-SHEET.md (buyer adds their own OUTSCRAPER/FIRECRAWL/GHL secrets; first signup = admin; publish once; functions re-register on their first builder publish).reference_canonical_legal_docs / context/legal/ β entity is a lead tool, not health data).A stranger who clones LeadScout: logs in (becomes admin), sees ONLY their own campaigns/leads, the internet can't read the data API, anon can't fire the paid functions, caps are enforced, and the 3 UX fixes + full scanβwebsite-checkβscoreβpush path all work β proven by running it with two accounts.
---
PROJECTS/LeadScout DFY/ are scaffolds (heavy node_modules) β not worth committing beyond this handoff + fn source.git add -A./app-safety-check again to confirm π’, then update the CEO Dashboard LeadScout card + wiki/projects/base44-apps.md.Generated for the CEO Dashboard · source: PROJECTS/LeadScout DFY/handoffs/leadscout-safety-audit-and-sellable-fix-plan-2026-07-11.md π€