diff --git a/.okf/log.md b/.okf/log.md index 48afef33d..3aaef307b 100644 --- a/.okf/log.md +++ b/.okf/log.md @@ -856,3 +856,74 @@ durable+urgent+in-authority item - and patching Rails alone is NOT sufficient, libvips must go to >=8.13 and secrets must be rotated. "Vibe code rescue" is no longer an uncontested category: justinmckelvey.com (cited in our own strategy as proof the model works) now sells it at $25K-$50K under the same name. + +## 2026-08-08 — 20.09 execution: funnel links, auth consolidation, CVE post; sourcing blocked + +Executed the 20.09 priority stack. Three of four landed. + +P1: /services/vibe-code-rescue/ now has inbound links from the six rescue-intent +posts. It previously had zero from 608 posts. + +P2: the 4-post Rails-8-auth cluster consolidated to one canonical with three +aliases. Merging also fixed a real bug the canonical carried - its password-reset +action branched on "Email not found", leaking account existence. + +P3: first durable-news post shipped (CVE-2026-66066). The review loop caught four +blockers; the load-bearing one inverted the post's thesis. The draft said the +patched gem's guard silently no-ops on libvips < 8.13. The advisory says the +opposite verbatim: "Active Storage will raise an exception during boot in such an +unsecurable environment." It fails CLOSED. As drafted, the runbook (bump gem, +deploy, fix libvips later) would crash a reader's app at boot on Ubuntu 22.04, +which ships libvips 8.12.1. Runbook now lands libvips >= 8.13 first. + +Two review lessons worth keeping: +1. The fact-check critic marked a date VERIFIED that does not exist in its cited + source (Rails 7.1 EOL "2025-10-01"; the announcement gives no date and is + itself published 2025-10-29). The cold-eyes gate caught it structurally - an + announcement cannot announce a date 28 days before its own publication - and + correctly routed it back instead of editing fact territory. Verifiers can + over-trust secondary sources; a structural reader catches the smell. +2. Second consecutive technical post where voice-tuned review passed something + that would break a reader's production. The source-verifying practitioner + critic is now load-bearing, not optional, for developer-targeted posts. + +P0 sourcing: BLOCKED, and the diagnosis moved. F5Bot is retired - it matches +literal substrings and is passive, so it can never backfill the <=30-day window +card #29 needs. Its replacement (p7-search-sweep.md, ~30 site:+after: queries) +produced zero rows for venue-specific reasons, in two distinct classes. +Search-surface limits: bare site:reddit.com queries had the operator dropped +(returned Substack and Goodreads), while the tool's native domain filter did +return indiehackers.com and x.com URLs; after: filtering was ineffective +everywhere tried (IH results dated 2025-07 came back). Venue access: reddit +rejected the agent user agent outright (hard 400), and thread opens on IH, HN, +old.reddit, and lobste.rs all returned EGRESS_BLOCKED - so IH was searchable but +not openable, reddit neither. Card #12's blocker is no longer a 2-minute keyword +swap; it is venue reachability (thread opening above all), and the query bank +needs a human browser. + +## 2026-08-08 — 3-column markdown tables overflow at 390px; two posts shipped + +Published `rails-7-eol-unpatched-security-exposure` and +`migrate-lovable-replit-app-to-rails`. + +**Durable layout finding.** A 3-column markdown table breaks the mobile scroll +gate in this theme. Measured at 390px: the article container is 354px wide and +tables render `table-layout: auto` with no `overflow-x` wrapper, so minimum +content width wins. The migration post's 3 columns computed to 116+132+129 = +377px and pushed documentSscrollWidth to 396. Trimming cell prose did NOT fix it +- min-width is set by the longest unbreakable token per column, not by sentence +length, and a first trim actually made it 1px worse. Collapsing to 2 columns +fixed it: the sibling EOL post's 2-column table lands at exactly 354px. + +Rule: **2 columns maximum in blog tables**, or fold the extra column's value into +the last cell in bold. Code blocks are unaffected - they already carry +`overflow-x: auto` and stay inside the container. The systemic fix (wrapping +tables in a scroll container in the theme) is a CSS change and would trigger the +full visual regression pair, so it stays a content rule until someone takes that +on deliberately. + +**Also swept**: `` was shipping in published +HTML on five posts, three of them already live (kamal-2-multi-server, +ai-code-ownership-accountability, claude-code-xp-team-workflow). It is internal +review metadata from the writer brief. Removed sitewide; writers should report +cadence in the handback, never in the file. diff --git a/content/blog/47-startups-failed-same-coding-mistake/index.md b/content/blog/47-startups-failed-same-coding-mistake/index.md index 79811da13..d26eab18d 100644 --- a/content/blog/47-startups-failed-same-coding-mistake/index.md +++ b/content/blog/47-startups-failed-same-coding-mistake/index.md @@ -113,7 +113,7 @@ At this point, your dev shop knows how to build features but not how to build ma ### Month 18: the last window before rewrite -If you're here, your options are limited. A rewrite buys you time, but the rewrite needs discipline or you repeat the pattern. This is where it's worth hiring a rescue-focused team to install the practices that prevent the 25-month timeline from repeating, with [our exit guide](/blog/fire-dev-shop-guide/) covering the contract side. +If you're here, your options are limited. A rewrite buys you time, but the rewrite needs discipline or you repeat the pattern. This is where it's worth hiring a rescue-focused team to install the practices that prevent the 25-month timeline from repeating, with [our exit guide](/blog/fire-dev-shop-guide/) covering the contract side. For AI-built codebases, that engagement is our [vibe code rescue](/services/vibe-code-rescue/) - audit first, then a fixed-price repair plan. ## When to rewrite vs. rescue diff --git a/content/blog/ai-code-ownership-accountability/index.md b/content/blog/ai-code-ownership-accountability/index.md index 429076ef7..c5315d2dd 100644 --- a/content/blog/ai-code-ownership-accountability/index.md +++ b/content/blog/ai-code-ownership-accountability/index.md @@ -110,4 +110,3 @@ You don't have to fire anyone or switch agencies. The work fits on a postcard: a Torvalds didn't change the world. He codified what the kernel had been enforcing informally for a year - a human signature on every patch, AI or not. Your codebase runs the same rule whether your contract says so or not. Find out on a weekend audit, or find out from your diligence partner. - diff --git a/content/blog/claude-code-xp-team-workflow/index.md b/content/blog/claude-code-xp-team-workflow/index.md index 487875f48..3fdfa2edf 100644 --- a/content/blog/claude-code-xp-team-workflow/index.md +++ b/content/blog/claude-code-xp-team-workflow/index.md @@ -105,4 +105,3 @@ Cost on a small slice (one AC item, 50-100 lines of diff) runs $4-6 on Sonnet or Send us one sentence (which role your solo Claude Code agent keeps skipping - PM, designer, tech lead, QA, or critic panel) plus your repo URL. We'll reply with the actual `.claude/agents/.md` file we run for that seat, plus a one-page note naming the next two seats to staff for your stack. [Reach us at /contact-us/](/contact-us/). - diff --git a/content/blog/dev-shop-red-flags-checklist/index.md b/content/blog/dev-shop-red-flags-checklist/index.md index e99677e52..e7d9ee604 100644 --- a/content/blog/dev-shop-red-flags-checklist/index.md +++ b/content/blog/dev-shop-red-flags-checklist/index.md @@ -68,7 +68,7 @@ But the developers who generated it never wrote tests, never handled errors, and The vibe coding shops skip tests entirely because the AI output "looks like it works." It does - until real users show up and everything breaks at once, leaving you paying emergency rates to fix code that should never have shipped. -If your agency can't tell you their test coverage number, they might not have tests at all. And if they don't have tests but they're shipping fast, they're probably vibe coding - and you should be looking for an exit. +If your agency can't tell you their test coverage number, they might not have tests at all. And if they don't have tests but they're shipping fast, they're probably vibe coding - and you should be looking for an exit. If you want more than a suspicion, our [vibe code rescue](/services/vibe-code-rescue/) service starts with a 48-hour code audit: a one-page, plain-English scorecard of what's solid, what's fragile, and whether to salvage or rebuild. ## 5. Can't Explain Technical Decisions in Plain English diff --git a/content/blog/fire-dev-shop-guide/index.md b/content/blog/fire-dev-shop-guide/index.md index 8fe2cdf51..e23169901 100644 --- a/content/blog/fire-dev-shop-guide/index.md +++ b/content/blog/fire-dev-shop-guide/index.md @@ -121,7 +121,7 @@ Then real users showed up. A user with a `+` in their email tried to sign up on We opened the codebase and found 2% test coverage, zero error handling, and **14 critical security vulnerabilities**. We had to rewrite the entire thing. -We're seeing this in about half the rescue projects that come through our door now. AI-generated code *looks* professional - good variable names, consistent formatting, reads well. But under the hood you find no tests, no error handling, and [security holes that only show up when real people use the product](/blog/from-what-start-stop-delivering-bugs-when-there-no-time-for-changes-management-agile/). [Qodo's 2025 report](https://www.qodo.ai/reports/state-of-ai-code-quality/) found AI code produces **1.7x more issues** than human-written code. And that's when someone reviews it. When nobody does? It's a time bomb. +We're seeing this in about half the rescue projects that come through our door now. AI-generated code *looks* professional - good variable names, consistent formatting, reads well. But under the hood you find no tests, no error handling, and [security holes that only show up when real people use the product](/blog/from-what-start-stop-delivering-bugs-when-there-no-time-for-changes-management-agile/). [Qodo's 2025 report](https://www.qodo.ai/reports/state-of-ai-code-quality/) found AI code produces **1.7x more issues** than human-written code. And that's when someone reviews it. When nobody does? It's a time bomb. That's why [vibe code rescue](/services/vibe-code-rescue/) became its own service line for us - it opens with a code audit rather than a rebuild pitch. We've watched this enough times to see the split. The shops that last [write tests before code](/blog/test-driven-development-tdd-in-ruby-step-by-guide-tutorial-bestpractices/) and have a second person [review every change](/blog/ai-powered-code-reviews-transforming-development-workflows/) before it goes live. The shops that burn out paste AI output and ship without anyone checking whether it actually works. diff --git a/content/blog/hiring-dev-shop-questions/index.md b/content/blog/hiring-dev-shop-questions/index.md index 66b50837d..38c027545 100644 --- a/content/blog/hiring-dev-shop-questions/index.md +++ b/content/blog/hiring-dev-shop-questions/index.md @@ -106,7 +106,7 @@ What they delivered was a Rails app that pushed untested, unreviewed code straig A developer writing code produces 5-15 commits per day, each one tied to a specific change with a message that explains the reasoning. Pull requests get review comments from teammates, tests grow alongside the feature they cover, and you can trace any production bug back to the commit that introduced it. [A 2025 code quality analysis](https://www.qodo.ai/reports/state-of-ai-code-quality/) found that **AI-generated code produces 1.7x more defects when merged without human review**. -If the git history looks like a robot wrote it, it probably did - or the shop skipped the review process to ship faster. Learn more about [AI-generated code risks](/blog/vibe-coding-crisis-ai-code-debt/) and how to spot them. +If the git history looks like a robot wrote it, it probably did - or the shop skipped the review process to ship faster. Learn more about [AI-generated code risks](/blog/vibe-coding-crisis-ai-code-debt/) and how to spot them. And if you're reading this after already signing with one, our [vibe code rescue](/services/vibe-code-rescue/) service covers the cleanup. ## When These Questions Don't Apply diff --git a/content/blog/kamal-2-multi-server-deployment-complete-guide/index.md b/content/blog/kamal-2-multi-server-deployment-complete-guide/index.md index 06b3dda2f..fc8242996 100644 --- a/content/blog/kamal-2-multi-server-deployment-complete-guide/index.md +++ b/content/blog/kamal-2-multi-server-deployment-complete-guide/index.md @@ -337,4 +337,3 @@ Versions move: everything here was verified against Kamal 2.12.0 (June 2026). Th *Single-server setup is covered in [Deploying Rails with Kamal](/blog/deploying-ruby-on-rails-applications-with-kamal-devops-docker/), and CI wiring in [Kamal 2 with GitHub Actions](/blog/automate-your-deployments-with-kamal-2-github-actions-devops-development/).* - diff --git a/content/blog/migrate-lovable-replit-app-to-rails/cover.png b/content/blog/migrate-lovable-replit-app-to-rails/cover.png new file mode 100644 index 000000000..422a61beb Binary files /dev/null and b/content/blog/migrate-lovable-replit-app-to-rails/cover.png differ diff --git a/content/blog/migrate-lovable-replit-app-to-rails/index.md b/content/blog/migrate-lovable-replit-app-to-rails/index.md new file mode 100644 index 000000000..363199780 --- /dev/null +++ b/content/blog/migrate-lovable-replit-app-to-rails/index.md @@ -0,0 +1,222 @@ +--- +title: "Migrate a Lovable or Replit App to Rails" +description: "Your Lovable, Bolt, or Replit MVP works in the demo and breaks with real users. How to migrate a vibe coded app to Rails: schema, auth, payments, front end." +date: 2026-08-08 +draft: false +author: "JetThoughts" +tags: ["rails", "vibe-coding", "migration", "supabase", "startups", "mvp"] +keywords: ["migrate lovable app to rails", "vibe coded app to production", "lovable to rails migration", "replit to rails", "supabase to rails migration", "rebuild vibe coded mvp"] +categories: ["Development", "Rails", "AI"] +canonical_url: "https://jetthoughts.com/blog/migrate-lovable-replit-app-to-rails/" +slug: "migrate-lovable-replit-app-to-rails" +cover_image: "cover.png" +metatags: + image: cover.png +cover_image_alt: "Dark technical cover for migrating a Lovable or Replit app to Rails. JetThoughts ENGINEERING brand mark, Ruby on Rails 2026 pill, low-poly ruby gem, stat chips for schema, auth, and payments, MIGRATE status indicator." +--- + +Before you migrate a Lovable app to Rails, open the network tab on the app you have today. You'll usually find the Supabase URL and a key labeled `anon` sitting in the client bundle. That key is meant to be public. What makes it dangerous is what's behind it: if row-level security was never turned on, that key reads the whole table. + +```bash +curl 'https://.supabase.co/rest/v1/profiles?select=*' \ + -H "apikey: " +``` + +A researcher ran a version of that check across Lovable's own showcase in early 2025 and found [303 endpoints on 170 projects returning data to anyone with the public key](https://www.superblocks.com/blog/lovable-vulnerabilities) - emails, addresses, in some cases API keys. The finding became [CVE-2025-48757](https://nvd.nist.gov/vuln/detail/CVE-2025-48757) in May 2025, a record the vendor disputes and one the NVD scopes to Lovable-generated sites through April 15, 2025. So read the scan as context, not a verdict on today's Lovable. What it demonstrates is what a missing row-level security policy looks like from the outside, and that part applies to any Supabase-backed app. If you're reading this, you probably already know your app has a problem like it, and you're deciding whether to move to production-grade Rails or keep patching. + +## First figure out what to keep + +The honest answer to "how much of this transfers" depends on which part you're looking at, and the parts age very differently. + +Your database schema and the data in it are the durable part. Tables, columns, foreign keys, the actual rows your users created - that ports cleanly, because it's just Postgres underneath. The generated React front end is often worth keeping: it renders, it's typed, and rebuilding pixel-perfect UI by hand is a poor use of a rescue budget. + +Auth and payments almost never survive. Not because the tools can't wire them up, but because "looks wired up" and "actually enforces the rule" are different states that look identical in a demo. + +Here's how it usually splits: + +| Layer | Verdict | +|---|---| +| Database schema | **Transfers.** It's Postgres, so `pg_dump` and you're done. | +| Data (rows) | **Transfers.** Same export. Watch the auth foreign keys. | +| Uploaded files | **Transfers separately.** They're in Storage, not the dump. | +| Business logic | **Sometimes.** Read every line, port what's real. | +| Front end (React) | **Often.** It renders. Point it at a new API. | +| Auth | **Rarely.** Stubbed or hardcoded, with no real sessions. | +| Payments | **Rarely.** Stripe checkout exists; webhooks don't. | + +One decision drives everything else. If the schema is sane and the data is real, you're doing a backend transplant and keeping the UI. If the schema is a mess and the "logic" is a thin wrapper over AI-generated CRUD, you're rebuilding, and the old app is a spec, not a codebase. + +We wrote a longer field guide to that call in [the vibe coding crisis: AI code debt](/blog/vibe-coding-crisis-ai-code-debt/). Read the schema before you decide. Everything downstream forks here. + +This is also the point where you decide whether to run the migration yourself. If your team reads Postgres comfortably and has shipped auth before, keep going. If not, [our vibe code rescue service](/services/vibe-code-rescue/) opens with a 48-hour audit and quotes a fixed price from it. + +## What these tools actually generate + +The popular tools cluster into three shapes, and the shape decides how much of this playbook applies. + +Lovable, Bolt, and Replit generate a real, ownable codebase. [Lovable ships a React + Vite front end with Supabase behind it](https://docs.lovable.dev/introduction/faq) - Postgres, Supabase Auth, Storage, and Deno edge functions. Bolt defaults to React in a StackBlitz WebContainer and [connects Supabase as the backend](https://support.bolt.new/integrations/supabase). Replit gives you a full Linux container with whatever the agent wrote. + +In all three, you can export the code and take the Postgres database with you. + +Base44 is the one to check carefully. It's a hosted platform ([Wix acquired it in June 2025](https://www.wix.com/press-room/home/post/wix-further-expands-into-vibe-coding-with-acquisition-of-base44-a-hyper-growth-startup-that-simplif)), and [its data export is per-collection CSV files](https://docs.base44.com/Building-your-app/Managing-your-app-data), not a Postgres dump - the auth and backend services keep running on Base44's infrastructure. The exported code still calls those services through the Base44 SDK, so migrating means replacing managed auth, data, and backend functions, not just porting files - you rebuild the backend from the UI and the CSVs. + +v0 is frontend-first - [it can scaffold Next.js route handlers and database integrations now](https://v0.app/docs/full-stack-apps), but the database and auth are still yours to bring, so there's less to export and less to untangle. + +## Get the schema and data out first + +Supabase is Postgres, so `pg_dump` does the work. + +Grab the direct connection string from the Supabase dashboard under Settings, then Database. Use port 5432, not the pooled 6543 - [pg_dump breaks through the transaction pooler](https://supabase.com/docs/guides/platform/migrating-within-supabase/backup-restore). One catch: the direct hostname `db..supabase.co` is IPv6-only, so on an IPv4-only network use the session pooler instead - same port 5432, on the pooler hostname. + +Take two dumps: the schema to read, the data to load. Keep the password out of the connection URI - embedded there it lands in shell history and `ps` output - so put it in `~/.pgpass` (mode 600) or let `pg_dump` prompt for it: + +```bash +pg_dump "postgresql://postgres@db..supabase.co:5432/postgres" \ + --schema=public --schema-only \ + --no-owner --no-privileges \ + --file=schema.sql + +pg_dump "postgresql://postgres@db..supabase.co:5432/postgres" \ + --schema=public --data-only \ + --no-owner --no-privileges \ + --file=data.sql +``` + +The split matters because the two files have different destinies. Your new schema comes from Rails migrations, so `schema.sql` never touches the new database - replaying Supabase DDL into a Rails-managed schema would either fight the migrations or bypass them. `data.sql` is the only file you load. It's also your users' production data sitting in plaintext on your laptop: keep it off shared drives and delete it when the migration is done. + +The `--schema=public` flag matters too. Supabase keeps its own machinery in `auth`, `storage`, and `realtime` schemas, and you don't want that machinery - you're replacing it. Dump `public`, read `schema.sql` in a text editor, and you'll have your real data model in front of you for the first time. + +Those dumps have one hole in them: your users. Supabase stores accounts - emails and bcrypt password hashes - in `auth.users`, which `--schema=public` skips. [Export them separately](https://supabase.com/docs/guides/troubleshooting/migrating-auth-users-between-projects) before you go further, from `psql` on the same connection string: + +```sql +\copy (SELECT id, email, encrypted_password, created_at FROM auth.users) + TO 'auth_users.csv' WITH CSV HEADER +``` + +Two restore traps follow from that split. Rows in `public` tables still reference `auth.users` ids, so import the accounts into your new `users` table before you load `data.sql` - the foreign keys your Rails migrations declare will reject the orphaned rows otherwise. And keep the exported `id` values: they're the UUIDs every other table references. + +Replit apps take the same route with fewer detours. Replit hands the app its own Postgres connection string as `DATABASE_URL`, so `pg_dump` runs against that one and the `--schema=public` filter stops mattering - there's no separate Supabase `auth` schema holding the accounts apart. Whatever table the agent wrote users into comes out with the rest of the dump, so you can skip the CSV export and both restore traps above. + +Then translate `schema.sql` to Rails migrations. The tables map almost one-to-one; the friction is at the edges. Supabase uses UUID primary keys by default, so tell Rails the same instead of fighting it: + +```ruby +create_table :projects, id: :uuid do |t| + t.references :owner, type: :uuid, foreign_key: { to_table: :users } + t.string :name, null: false + t.timestamps +end +``` + +Watch two things. The foreign keys that pointed at `auth.users` get re-created here against your new `users` table - that seam is where the old auth hands off to the new one, and the `users` table itself arrives in the next section, with a UUID primary key you have to ask for. And columns Supabase filled with `auth.uid()` defaults need a Rails-side equivalent, usually set in the model or controller. + +Load `data.sql` with `psql` once the migrations have built the schema and the users are imported, then run the app in a console and confirm the counts match before you touch anything else. + +Files are a separate export. Lovable apps lean on Supabase Storage for uploads - avatars, attachments, anything users added through the UI - and none of it is in the SQL dump. Pull each bucket down with the Supabase CLI or a script against the Storage API, move the files into Active Storage or straight to S3, and rewrite the stored URLs as you load the rows. Skip this step and the rescued app boots with every image broken. + +## Rebuild the auth they faked + +Auth bites hardest, so budget for it honestly. Behind the open endpoints from the intro, nothing was enforcing who-can-see-what - the RLS policies meant to do it were never written. + +Rails moves that enforcement to the server, which is where a founder can actually reason about it. Rails 8 ships a built-in authentication generator - no gem required for the common case: + +```bash +bin/rails generate authentication +``` + +That gives you a `User` model with `has_secure_password`, a `Session` model, sign-in and sign-out, and password reset wired to the mailer. + +Make two edits before you run its migration. The generated `users` table uses a bigint primary key - if your dump used UUIDs, change it to `id: :uuid`, or every foreign key you just migrated points at nothing. And the generator ships sign-in and password reset but no sign-up flow: fine for the rows you're importing, but new users can't register until you build that screen. + +Now load the accounts - from Supabase, that's the `auth_users.csv` you exported earlier. The `encrypted_password` column holds bcrypt hashes, and they move straight into `password_digest` because Rails uses bcrypt too - users keep their passwords and never notice. If any hashes are a format `has_secure_password` can't read, force a password reset on first login rather than trying to translate them. + +If you need OAuth, roles, or multi-tenancy beyond what the generator covers, that's the Devise conversation. We compared the [Rails 8 authentication generator against Devise](/blog/rails-8-authentication-generator-devise-migration/) for exactly this decision - start on the generator and reach for Devise when a real requirement shows up. + +The test that matters: log in as user A and try to read user B's data by guessing an ID. In the old app that curl worked. After the migration, the controller should refuse it, because a scope like `current_user.projects.find(params[:id])` now decides what's visible. + +## Payments: the webhooks nobody wired up + +Payments fail the same way auth does - the checkout flow is real and the accounting behind it was never built. + +A Stripe Checkout button is a redirect to a page Stripe hosts, so the generated version works: the customer pays and comes back. + +What's missing is the webhook handler, the part where Stripe tells your server that a payment cleared or a subscription ended. Without it, someone can pay and get nothing, or cancel and keep access, and your database never learns the difference. Nothing in the UI shows the gap; it surfaces when someone reconciles the app against the Stripe dashboard. + +Rails handles the webhook as a plain controller action. The two rules that keep it honest: verify the signature so nobody can forge events, and make it idempotent because Stripe retries: + +```ruby +class StripeWebhooksController < ApplicationController + # raise: false - in API mode the CSRF filter may not exist to skip + skip_before_action :verify_authenticity_token, raise: false + + def create + event = Stripe::Webhook.construct_event( + request.body.read, + request.env["HTTP_STRIPE_SIGNATURE"], + Rails.application.credentials.stripe[:webhook_secret] + ) + + # stripe_events has a unique index on event_id - the idempotency guard + StripeEvent.create!(event_id: event.id, payload: event.to_h) + StripeWebhookJob.perform_later(event.id) + + head :ok + rescue ActiveRecord::RecordNotUnique + head :ok # Stripe retried an event we already accepted + rescue Stripe::SignatureVerificationError + head :bad_request + end +end +``` + +The unique index is the whole idempotency story: a retried delivery raises `RecordNotUnique` and gets a 200 without running anything twice. The job is where `Subscriptions::Activate` and `Subscriptions::Revoke` actually run - the controller only records the event and acknowledges it, so slow work can't make Stripe time out and retry a delivery you're still processing. The [quality tax of an AI-built MVP](/blog/quality-tax-ai-mvp-cost/) puts payments at the center for a reason. This is also where a real test suite earns its cost, because you cannot manually click your way through "card declined on renewal after three successful months." + +## The front end: keep it or replace it + +Here the default advice is often wrong. The generated React works, and your users already know it. + +If you're keeping React, run Rails in API mode and point the front end at it. Swap the `@supabase/supabase-js` calls for `fetch` to your Rails endpoints, move auth to the session cookie your new backend issues, and delete the Supabase client. The UI keeps working against a new data source. + +One catch: `rails new --api` strips the exact middleware that cookie advice needs - no `ActionDispatch::Cookies`, no session store, no CSRF protection. Add them back: + +```ruby +# config/application.rb - API mode leaves these out +config.middleware.use ActionDispatch::Cookies +config.middleware.use ActionDispatch::Session::CookieStore, + key: "_app_session", same_site: :none, secure: true + +# config/initializers/cors.rb - credentialed CORS for the SPA origin +Rails.application.config.middleware.insert_before 0, Rack::Cors do + allow do + origins "https://app.yourdomain.com" + resource "*", headers: :any, methods: %i[get post put patch delete], + credentials: true + end +end + +# app/controllers/application_controller.rb - CSRF is off in API mode +class ApplicationController < ActionController::API + include ActionController::Cookies + include ActionController::RequestForgeryProtection + protect_from_forgery with: :exception +end +``` + +The `same_site: :none, secure: true` pair and the CORS block exist only because the SPA lives on a different domain than the API - serve both from one domain and the defaults do the job. + +If you'd rather consolidate to one framework and one deploy, Hotwire lets you rebuild the interface in server-rendered Rails without a separate frontend build. That's the right call when the team is Ruby-first and the React was mostly forms and tables. It's the wrong call when the front end is genuinely interactive and rewriting it burns weeks to remove a working thing. + +Either way you now own a deployable app. [Our Rails 8 Docker production guide](/blog/rails-8-docker-deployment-production-guide/) covers containerizing it, and when one box stops being enough, [the Kamal 2 multi-server guide](/blog/kamal-2-multi-server-deployment-complete-guide/) takes it across hosts. + +## When not to migrate to Rails + +Rails is the right rebuild target often enough that it's worth naming when it isn't, because forcing it costs more than picking correctly. + +Skip it if the app is a static marketing site with a form. That's a landing page and a form handler; Rails is a heavy answer to a light question. + +It's also the wrong move when the product is genuinely realtime-first - a live collaborative editor, a multiplayer canvas, a trading view where every millisecond of push latency shows. Rails does realtime, but a design built around it from day one may be better served elsewhere, and honesty here saves a painful second migration. + +The third reason to hold off: a team with zero Ruby experience and no runway to learn. A rescue that hands you a stack nobody can maintain just relocates the problem. One [pattern behind failed rebuilds](/blog/47-startups-failed-same-coding-mistake/) is choosing technology the team can't operate. + +Rails wins when you have data worth keeping, business logic worth enforcing on a server, and someone who can read Ruby. Not every app that outgrew its AI builder clears that bar. + +The migration itself is boring in the good way. Start with the dump; everything else follows from what you find in it. diff --git a/content/blog/new-methods-that-help-implement-authentication-in-ruby-on-rails-71/cover.png b/content/blog/new-methods-that-help-implement-authentication-in-ruby-on-rails-71/cover.png deleted file mode 100644 index fb8590c82..000000000 Binary files a/content/blog/new-methods-that-help-implement-authentication-in-ruby-on-rails-71/cover.png and /dev/null differ diff --git a/content/blog/new-methods-that-help-implement-authentication-in-ruby-on-rails-71/file_0.png b/content/blog/new-methods-that-help-implement-authentication-in-ruby-on-rails-71/file_0.png deleted file mode 100644 index fb8590c82..000000000 Binary files a/content/blog/new-methods-that-help-implement-authentication-in-ruby-on-rails-71/file_0.png and /dev/null differ diff --git a/content/blog/new-methods-that-help-implement-authentication-in-ruby-on-rails-71/index.md b/content/blog/new-methods-that-help-implement-authentication-in-ruby-on-rails-71/index.md deleted file mode 100644 index 9ba4b0088..000000000 --- a/content/blog/new-methods-that-help-implement-authentication-in-ruby-on-rails-71/index.md +++ /dev/null @@ -1,100 +0,0 @@ ---- -remote_url: https://dev.to/jetthoughts/new-methods-that-help-implement-authentication-in-ruby-on-rails-71-1eb2 -source: dev_to -remote_id: 1673299 -dev_to_id: 1673299 -dev_to_url: https://dev.to/jetthoughts/new-methods-that-help-implement-authentication-in-ruby-on-rails-71-1eb2 -title: New methods that help implement authentication in Ruby on Rails 7.1 -description: The new version of Ruby on Rails 7.1 has many new features, some of which make writing your own... -date: 2023-11-21 -created_at: '2023-11-21T08:01:09Z' -edited_at: '2024-11-25T15:39:46Z' -draft: false -tags: -- ruby -- rails -- webdev -- tutorial -canonical_url: https://jetthoughts.com/blog/new-methods-that-help-implement-authentication-in-ruby-on-rails-71/ -cover_image: https://raw.githubusercontent.com/jetthoughts/jetthoughts.github.io/master/content/blog/new-methods-that-help-implement-authentication-in-ruby-on-rails-71/cover.png -metatags: - image: cover.png -slug: new-methods-that-help-implement-authentication-in-ruby-on-rails-71 ---- - -![Image description](file_0.png) - -The new version of Ruby on Rails 7.1 has many new features, some of which make writing your own authorization system easier. Let’s take a closer look at these helpers: `normalizes`, `generates_token_for` and `authenticate_by`. - -## normalizes - -The `normalizes` method in Ruby on Rails 7.1 is added to simplify the process of normalizing attribute values of a model. This method allows you to specify a list of attributes that need to be normalized before saving them to the database. - -Example usage of the normalizes method: - -```ruby -class User < ApplicationRecord - normalizes :email, with: -> email { email.strip.downcase } - normalizes :phone, with: -> email { phone_number.gsub(/\D/, '') } -end -``` - -In the example above, when saving an instance of the User class, the values of the email and phone_number attributes will be automatically normalized, i.e., brought to a specific format. - -Previously, before the introduction of the normalizes method, you had to use before_save or before_validation callbacks to normalize attribute values: - -```ruby -class User < ApplicationRecord - before_save :normalize_values - def normalize_values - self.email = email.downcase.strip - self.phone_number = phone_number.gsub(/\D/, '') - end -end -``` - -## generates_token_for - -The `generates_token_for` method in Ruby on Rails 7.1 is added to automatically generate unique tokens for a specified attribute of a model. Tokens are commonly used to create unique identifiers or secret keys. - -Example usage of the `generates_token_for` method: - -```ruby -class User < ApplicationRecord - generates_token_for :auth_token -end -``` - -In the example above, when creating a new instance of the User class, a unique token will be automatically generated for the auth_token attribute. - -Previously, before the introduction of the generates_token_for method, you had to use before_create callbacks to generate unique tokens: - -```ruby -class User < ApplicationRecord - before_create :generate_auth_token - def generate_auth_token - self.auth_token = SecureRandom.urlsafe_base64 - end -end -``` - -## authenticate_by - -The authenticate_by method in Ruby on Rails 7.1 is added to simplify the authentication process for users. This method allows you to specify the attribute to authenticate_by and automatically creates the necessary methods for checking the correctness of the entered password. - -Example usage of the authenticate_by method: - -```ruby -@user = User.authenticate_by(username: "...", password: "...") -``` - -In the example above, when calling the authenticate method on an instance of the User class, it will perform the authentication check by comparing the entered password with the value of the password attribute. - -Previously, before the introduction of the authenticate_by method, you had to implement this logic manually: - -```ruby -@user = User.find_by(email: params[:email]) -@user.authenticate(params[:password]) if @user.present? -``` - -These are just a few changes and new methods introduced in Ruby on Rails 7.1. It is recommended to refer to the documentation for more detailed information. diff --git a/content/blog/quality-tax-ai-mvp-cost/index.md b/content/blog/quality-tax-ai-mvp-cost/index.md index 3a43cad32..9d8251bfc 100644 --- a/content/blog/quality-tax-ai-mvp-cost/index.md +++ b/content/blog/quality-tax-ai-mvp-cost/index.md @@ -72,7 +72,7 @@ A typical AI-first quote for a B2B SaaS MVP lands at **$25K-$35K** for a 3-4 wee - Production debugging time the founder eats internally: 80-200 engineer-hours - **All-in cost (cash + internal): $60K-$90K, or 2-3x the original quote** -Cash line items add $29K-$55K to a $30K starting point. Internal engineer hours add $15K-$35K at market rates. **The all-in cost of making an AI-built MVP production-ready lands at $60K-$90K, or 2x to 3x the original quote.** +Cash line items add $29K-$55K to a $30K starting point. Internal engineer hours add $15K-$35K at market rates. **The all-in cost of making an AI-built MVP production-ready lands at $60K-$90K, or 2x to 3x the original quote.** That drift is the reason our [vibe code rescue](/services/vibe-code-rescue/) engagements quote a fixed price after the initial audit, so the remediation bill can't creep the way these line items did. The healthtech founder from the top of this post is the worked example. Her $28K Rails 8 MVP turned into $61K of remediation work over four months: $14K for the security pass, $19K rebuilding the test suite, $11K resolving three different implementations of the same appointment-conflict check, and $17K of tracked engineering hours her in-house engineer logged in Linear once we asked her to start tracking. **Her $28K MVP became an $89K MVP**, and the BAA letter from her HIPAA attorney demanded a 60-day breach timeline plus audit log retention proof - costs that never touched the engineering invoice. diff --git a/content/blog/rails-7-eol-unpatched-security-exposure/cover.png b/content/blog/rails-7-eol-unpatched-security-exposure/cover.png new file mode 100644 index 000000000..2a094f0f0 Binary files /dev/null and b/content/blog/rails-7-eol-unpatched-security-exposure/cover.png differ diff --git a/content/blog/rails-7-eol-unpatched-security-exposure/index.md b/content/blog/rails-7-eol-unpatched-security-exposure/index.md new file mode 100644 index 000000000..b280baec0 --- /dev/null +++ b/content/blog/rails-7-eol-unpatched-security-exposure/index.md @@ -0,0 +1,141 @@ +--- +title: "Rails 7 End of Life: Pick Your Exit" +description: "Rails 7.1 got no patch for CVE-2026-66066, a CVSSv4 9.5 pre-auth RCE. How to find every app of yours on a dead branch and pick between four realistic exits." +date: 2026-08-08 +draft: false +author: "JetThoughts" +slug: "rails-7-eol-unpatched-security-exposure" +keywords: "rails 7 end of life, rails 7.1 security support, rails eol, rails 7.1 no patch, rails lts, CVE-2026-66066, rails upgrade" +tags: +- rails +- security +- cve +- upgrade +categories: +- Rails +- Security +cover_image: cover.png +metatags: + image: cover.png +cover_image_alt: "Rails 7 end of life cover - no patch for CVE-2026-66066 on 7.1, four exit paths compared" +canonical_url: https://jetthoughts.com/blog/rails-7-eol-unpatched-security-exposure/ +--- + +On July 29 the Rails security team fixed a CVSSv4 9.5 vulnerability in Active Storage - an unauthenticated upload that reads files off your server and [may enable code execution](https://github.com/rails/rails/security/advisories/GHSA-xr9x-r78c-5hrm). Three branches got a patched release that day: 7.2, 8.0, and 8.1. One of those three, 7.2, reaches the end of its security support on August 9, 2026 - eleven days after carrying the patch, upstream stops shipping it fixes. Rails 7.1 and everything below it got nothing. + +That's the concrete version of Rails 7 end of life. Rails 7.1 had [finished its security support period](https://rubyonrails.org/2025/10/29/new-rails-releases-and-end-of-support-announcement) by October 2025, so when CVE-2026-66066 landed, the backport list stopped at 7.2. + +If your app sits on 7.1 or lower and processes image variants, there's no version on your branch to bump to. The fix lives on branches you're not on. + +The CVE itself already has a [full writeup](/blog/rails-cve-2026-66066-active-storage-rce/): mechanism, detection inside a running app, the libvips 8.13 requirement, the secret rotation order. This post covers the problem that outlives this CVE. You're on an unsupported Rails, the next critical will skip you the same way, and there are four ways out. + +## Skipped on schedule + +The [maintenance policy](https://rubyonrails.org/maintenance) gives a minor series bug fixes for one year and security fixes for two, counted from the series' first release. 7.1 shipped on October 5, 2023. Its two years ran out, and the October 2025 announcement closed the series at 7.1.6 - final release, no successor. + +March already showed how this plays out in practice. [CVE-2026-41316](/blog/rails-cve-2026-41316-founder-guide/), the ERB/Marshal deserialization RCE, got backports for 7.2, 8.0, and 8.1 in the [March 23 release](https://rubyonrails.org/2026/3/23/Rails-Versions-7-2-3-1-8-0-4-1-and-8-1-2-1-have-been-released) and nothing for 7.0 or 7.1. Two criticals in four months skipping the same versions is the policy running exactly as written, and it'll run the same way on the next advisory. + +## Find every app on a dead branch + +One app is a one-minute check. The fleet is where the work hides - a dozen client apps under one agency contract, or repos you inherited that nobody has opened since 2024. + +Start with the lockfiles. Here's a sweep that prints the resolved Rails version for every app under a directory, however deeply nested: + +```bash +find . -name Gemfile.lock -not -path '*/vendor/*' | while read -r lock; do + printf '%-40s %s\n' "$(dirname "$lock")" \ + "$(awk '$1 == "rails" && $2 ~ /^\([0-9]/ { gsub(/[()]/, "", $2); print $2; exit }' "$lock")" +done +``` + +Anything starting 7.1, 7.0, or 6.x goes on the list. For repos you don't have cloned, GitHub code search runs the same sweep across an org - once per dead series: + +```bash +for v in "6." "7.0" "7.1"; do + gh search code --owner your-org --filename Gemfile.lock "\"rails ($v\"" +done +``` + +Then let [bundler-audit](https://github.com/rubysec/bundler-audit) tell each app which advisories hit its exact gem set - it reads the lockfile in the directory it runs from, so run it once per app: + +```bash +gem install bundler-audit +find . -name Gemfile.lock -not -path '*/vendor/*' \ + -execdir bundle-audit check --update \; +``` + +On a 7.1 app it flags GHSA-xr9x-r78c-5hrm with a solution list - 7.2.3.2, 8.0.5.1, 8.1.3.1 - containing no version you can reach without leaving the branch. Paste that output into the ticket. + +Last, sort that list by whether this specific CVE can reach each app. The [CVE writeup](/blog/rails-cve-2026-66066-active-storage-rce/) carries the exposure checks - which libvips the running process links, whether an attachment path takes uploads from someone who never logged in. Run them per app and let the answers rank the queue. Anything that takes public uploads and turns them into image variants is a P0. + +## Exit 1: upgrade, and aim past 7.2 + +Upgrade if you can. Every other exit on this list buys time and leaves the branch dead. + +Pick the target with the support clock in view. 7.2 is the smallest hop from 7.1, but its [security support ends August 9, 2026](https://rubyonrails.org/2025/10/29/new-rails-releases-and-end-of-support-announcement), so on its own it trades one dead branch for another; paired with the paid 7.2 LTS line in exit 2 it holds up. 8.0 holds until November 7, 2026, three months away. + +8.1 is the only hop that buys real time - security fixes run to October 2027. Budget the Ruby bump into the same plan: Rails 8 needs Ruby 3.2 or newer, and a 7.1 app often isn't there yet. + +How long the move takes is dominated by your app, not by Rails - test coverage decides most of it, and every pinned gem or private-API monkey patch adds its own detour. Plan in weeks. + +The per-hop mechanics haven't changed since the Rails 6 era: update the Gemfile, run `rails app:update`, clear the deprecation list against the old version, keep the app deployable the whole way. Our [Rails 6-to-7 migration guide](/blog/rails-7-upgrade-guide-step-by-step-migration/) walks that sequence step by step; a 7.1-to-8.1 move repeats it for each version hop with a fresh deprecation list. + +If you're rebuilding images along the way, the [Rails 8 Docker production guide](/blog/rails-8-docker-deployment-production-guide/) covers pinning system libraries so a base-image rebuild doesn't quietly undo your work. + +## Exit 2: pay a vendor to patch a dead branch + +[HeroDevs](https://www.herodevs.com/blog-posts/cve-2026-66066-rails-active-storage-arbitrary-file-read-and-rce) sells drop-in replacements for Rails 6.x, 7.0, and 7.1, and shipped a remediation for CVE-2026-66066 on those lines. [makandra's Rails LTS](https://railslts.com/en) covers 2.3 through 6.1 plus a [7.2 LTS line](https://makandracards.com/railslts) that tracks the latest 7.2 release, delivered as a Gemfile source swap. For an app on 7.1 the practical pick is HeroDevs - makandra's coverage jumps from 6.1 to 7.2, so reaching its LTS means doing the 7.2 hop first and then paying to stay. + +Both sell what upstream stopped shipping: CVE fixes without a migration. Neither stops the rest of the ecosystem from moving on. Gem releases keep raising the minimum Rails version, so you pin dependencies at their last compatible release one by one, and each pin is one more thing the eventual upgrade has to unwind. + +Used as a bridge - six months of cover while the upgrade gets staffed - that's a fair trade. Settling in permanently just grows the bill for the migration you'll still run someday. + +## Exit 3: remove the surface this CVE needs + +CVE-2026-66066 runs through variant processing in libvips. Two ways to take your app off that path: + +- Check whether you still need variants at all. Some apps carry `image_processing` from an old scaffold and never call `.variant` - dropping the gem ends the exposure and shortens the Gemfile. +- If you do need variants, the advisory ships a workaround that runs on an unpatched gem, and swapping the image processor is the other lever. The [CVE writeup](/blog/rails-cve-2026-66066-active-storage-rce/) has both, with the library versions each one requires and the trade-off each one carries. + +This closes one CVE and leaves the branch where it was. The next unpatched critical could land in Action Pack or Active Record, and you can't delete those. + +Either move ships in an afternoon and needs no budget line, which is why exit 3 goes first even when exit 1 is already funded. + +## Exit 4: accept it and build walls + +For an internal tool or an app already scheduled for decommission, accepting the risk can be the honest call. + +Moving uploads behind authentication cuts the attacker pool from "anyone with the URL" to "people with accounts", and admin-only upload cuts it to staff. For this CVE that's a real reduction, since the exploit has to deliver a file into variant processing. + +A WAF is thin cover here. The exploit is a correctly formed multipart upload whose hostile part is the image bytes themselves, so a ruleset inspecting request shapes has nothing to match on. Edge rules that restrict upload content types and rate-limit endpoints trim opportunistic scans; they won't stop a targeted attempt. + +None of it changes what a SOC 2 auditor or an acquirer's diligence checklist sees: an EOL framework with a known unpatched critical. You can hold that position - expect to write the explanation memo more than once. + +## Which exit, which app + +Match each app from your fleet sweep to a row - a mixed portfolio lands in several. + +| Situation | Move | +|---|---| +| Public uploads + variants, 7.1 or below | Exit 3 today, exit 1 starting this sprint | +| Revenue app, upgrade can't start this quarter | Exit 2 as the bridge, exit 1 on the calendar with a date | +| 5.2/6.1 app, long life, no budget | Exit 2, with the renewal cost budgeted year over year | +| Internal tool behind SSO, no public uploads | Exit 4 now, fold exit 1 into next quarter's maintenance | +| App with a real decommission date | Exit 4, with the date and the accepted risk written down | + +## Put the dates where you'll see them + +The support deadlines from exit 1 belong in the calendar, each with an owner - treat them like TLS certificate expiries, not facts you rediscover inside an advisory email. + +Then open the ticket. The bundle-audit output from the fleet sweep is the body, and the [migration guide](/blog/rails-7-upgrade-guide-step-by-step-migration/) is the first link in it. + +## Sources + +- [GHSA-xr9x-r78c-5hrm - official Rails advisory](https://github.com/rails/rails/security/advisories/GHSA-xr9x-r78c-5hrm) +- [Rails 7.2.3.2 / 8.0.5.1 / 8.1.3.1 release announcement](https://rubyonrails.org/2026/7/29/Rails-Versions-7-2-3-2-8-0-5-1-and-8-1-3-1-have-been-released) +- [New Rails releases and end of support announcement (October 2025)](https://rubyonrails.org/2025/10/29/new-rails-releases-and-end-of-support-announcement) +- [Rails maintenance policy](https://rubyonrails.org/maintenance) +- [HeroDevs: CVE-2026-66066 remediation for EOL Rails](https://www.herodevs.com/blog-posts/cve-2026-66066-rails-active-storage-arbitrary-file-read-and-rce) +- [Rails LTS by makandra](https://railslts.com/en) +- [bundler-audit](https://github.com/rubysec/bundler-audit) + diff --git a/content/blog/rails-8-authentication-generator-complete-guide/cover.png b/content/blog/rails-8-authentication-generator-complete-guide/cover.png deleted file mode 100644 index 048d2e5c7..000000000 Binary files a/content/blog/rails-8-authentication-generator-complete-guide/cover.png and /dev/null differ diff --git a/content/blog/rails-8-authentication-generator-complete-guide/index.md b/content/blog/rails-8-authentication-generator-complete-guide/index.md deleted file mode 100644 index 736adce6f..000000000 --- a/content/blog/rails-8-authentication-generator-complete-guide/index.md +++ /dev/null @@ -1,347 +0,0 @@ ---- -title: "Rails 8 Authentication Generator: Complete Guide" -description: "Replace Devise with Rails 8's built-in authentication. Sessions table, password reset, email verification, rate limiting, and production hardening. Working code." -date: 2026-07-29 -draft: false -tags: ["rails", "rails-8", "authentication", "devise", "security", "tutorial"] -canonical_url: "https://jetthoughts.com/blog/rails-8-authentication-generator-complete-guide/" -slug: "rails-8-authentication-generator-complete-guide" -cover_image: "cover.png" -metatags: - image: cover.png -cover_image_alt: "Dark technical cover for Rails 8 Authentication Generator. JetThoughts TUTORIAL brand mark, Ruby on Rails 2026 pill, low-poly ruby gem, stat chips for sessions/resets/rate-limits, PRODUCTION READY status indicator." ---- - -Every Rails app you've built in the last decade started with `gem "devise"`. The generator spits out 18 files. You delete the ones you don't need, override the ones you do, and spend the next three years hoping the monkey-patches in `config/initializers/devise.rb` survive the next major version bump. - -Rails 8 ships a built-in authentication generator that replaces most of what Devise does with code you own. One command. Zero gems. Full control. - -## TL;DR - -Rails 8's `bin/rails generate authentication` creates a complete auth system: `User` and `Session` models, session controllers, password reset flow, email verification, and a `current_user` helper. The code lives in your app - no engine, no initializer, no monkey-patching. Use it when you want full control over authentication. Keep Devise when you need omniauth, account locking, or trackable out of the box. - -**Quick start**: `rails new myapp && cd myapp && bin/rails generate authentication` → migrate → start your server. The login, signup, and password reset flows all work. Customize them by editing controllers you actually own. - -## What the generator gives you - -Run `bin/rails generate authentication` in a fresh Rails 8 app and it creates: - -| File | Purpose | -|---|---| -| `app/models/user.rb` | `has_secure_password` model with validations | -| `app/models/session.rb` | Database-backed sessions (not cookie-only) | -| `app/models/current.rb` | Thread-safe `Current` attributes for the request cycle | -| `app/controllers/sessions_controller.rb` | Login and logout | -| `app/controllers/passwords_controller.rb` | Forgot password + reset flow | -| `app/controllers/registrations_controller.rb` | Signup (if generated) | -| `app/controllers/confirmations_controller.rb` | Email verification | -| `app/controllers/application_controller.rb` | Updated with `include Authentication` | -| `app/controllers/concerns/authentication.rb` | `authenticate!`, `current_user`, `user_signed_in?` helpers | -| `app/mailers/passwords_mailer.rb` | Password reset emails | -| `app/views/sessions/` | Login form, partials | -| `app/views/passwords/` | Forgot password, reset, edit views | -| `db/migrate/*_create_users.rb` | `users` table with `password_digest` | -| `db/migrate/*_create_sessions.rb` | `sessions` table tied to `users` | - -Here's what Devise generates for comparison: 18 files including an engine, an initializer with 200+ config options, and a router DSL you don't control. The Rails 8 generator writes less code that does less - and that's the point. Every line lives in your app where you can read it and change it without fighting an engine. - -```ruby -# app/models/user.rb - what the generator produces -class User < ApplicationRecord - has_secure_password - has_many :sessions, dependent: :destroy - - normalizes :email_address, with: ->(e) { e.strip.downcase } - - validates :email_address, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP } - validates :password, length: { minimum: 8 }, if: -> { new_record? || changes[:password_digest] } -end -``` - -The password validation only fires when the password digest changes - not on every save. If you add a `bio` column later and call `user.update(bio: "...")`, it won't reject the update because the password hasn't changed. Devise requires you to configure this separately; the generator gets it right by default. - -## `has_secure_password` - what it does and doesn't do - -`has_secure_password` adds three things to your model: - -1. **Password hashing via bcrypt.** The `password_digest` column stores a bcrypt hash. The plaintext password never touches the database. -2. **Virtual `password` and `password_confirmation` attributes.** They exist on the model instance but aren't persisted - only the digest is. -3. **An `authenticate` method.** Call `user.authenticate("mypassword")` and it returns `self` on match, `false` on mismatch. - -```ruby -# The bcrypt check behind user.authenticate(password) -user = User.find_by(email_address: "founder@example.com") -if user.authenticate(params[:password]) - # password matched - create a session -else - # password didn't match - redirect with error -end -``` - -What it doesn't do: rate limiting, password complexity rules, account locking, password history, or multi-factor auth. Each of those is a decision you make for your app, not a default you inherit from a gem. We'll cover the ones that matter for production below. - -## `authenticate_by` - the safer lookup - -Rails 7.1 shipped `authenticate_by` as a class method. The generator uses it everywhere. The difference from `find_by` + `authenticate` matters: - -```ruby -# What you'd write without authenticate_by - two queries and a timing leak -user = User.find_by(email_address: params[:email]) -if user&.authenticate(params[:password]) - # ... -end - -# authenticate_by - one query, constant-time comparison -if (user = User.authenticate_by(email_address: params[:email], password: params[:password])) - start_new_session_for user - redirect_to after_authentication_url -else - render :new, alert: "Invalid email or password." -end -``` - -`authenticate_by` runs the lookup and the bcrypt comparison in one step. It uses a constant-time comparison to prevent timing attacks on the password - someone can't measure response latency to figure out whether an email exists in the database. It returns the user on success and `nil` on failure with the same timing profile either way. - -If you're still writing `find_by` + `authenticate` in custom controllers, switch to `authenticate_by`. The generator already does. - -## Sessions: database-backed, not cookie-only - -The biggest architectural decision the generator makes is storing sessions in a database table instead of just a signed cookie. - -```ruby -# app/models/session.rb -class Session < ApplicationRecord - belongs_to :user - before_create { self.user_agent = Current.user_agent } - before_create { self.ip_address = Current.ip_address } -end - -# db/migrate/*_create_sessions.rb -create_table :sessions do |t| - t.references :user, null: false, foreign_key: true - t.string :ip_address - t.string :user_agent - t.timestamps -end -``` - -The cookie-only approach stores a signed user ID in the browser. It works. Until you need to log someone out of all their devices and realize you can't - the cookie is valid until it expires, period. The user changed their password after a breach? Every device with the old cookie is still logged in. - -Database-backed sessions fix this. Each login creates a `Session` row. The cookie stores a session token, not a user ID. To log out everywhere, you delete the rows: - -```ruby -# Log out from all devices -Current.user.sessions.destroy_all -``` - -That's it. Every device's cookie becomes invalid on the next request because the server-side session no longer exists. - -The session cookie itself is configured in the generator: - -```ruby -# app/controllers/concerns/authentication.rb -module Authentication - extend ActiveSupport::Concern - - included do - before_action :set_current_request_details - before_action :authenticate! - end - - private - - def authenticate! - if (session_record = Session.find_by(id: cookies.signed[:session_token])) - Current.session = session_record - else - redirect_to new_session_path, alert: "Please sign in." - end - end - - def start_new_session_for(user) - session = user.sessions.create! - cookies.signed.permanent[:session_token] = { value: session.id, httponly: true, secure: Rails.env.production? } - end - - def terminate_session - Current.session.destroy - cookies.delete(:session_token) - end -end -``` - -The `httponly` flag prevents JavaScript from reading the cookie - XSS can't steal the session token. The `secure` flag ensures the cookie only travels over HTTPS in production. The `signed` cookie prevents tampering. None of this requires configuration because it's spelled out in code you can read. - -## Password reset - the flow that trips up every hand-rolled auth - -The generator includes a complete forgot-password flow. The pattern it uses is the same one every production app eventually converges on: - -```ruby -# app/controllers/passwords_controller.rb - abridged -class PasswordsController < ApplicationController - allow_unauthenticated_access - - def new - # Show the "forgot password?" form - end - - def create - if (user = User.find_by(email_address: params[:email_address])) - PasswordsMailer.reset(user).deliver_later - end - # Always redirect with the same message - don't leak whether the email exists - redirect_to new_session_path, notice: "Check your email for reset instructions." - end - - def edit - # Validate the reset token from the email link - @user = User.find_by_token_for!(:password_reset, params[:token]) - rescue ActiveSupport::MessageVerifier::InvalidSignature - redirect_to new_session_path, alert: "Reset link is invalid or has expired." - end - - def update - @user = User.find_by_token_for!(:password_reset, params[:token]) - if @user.update(password_params) - redirect_to new_session_path, notice: "Password reset. Sign in." - else - render :edit, status: :unprocessable_entity - end - end -end -``` - -Three details that matter: - -**1. Don't leak email existence in the `create` action.** The generator redirects with the same message whether the email exists or not. If you say "email not found," an attacker can enumerate your user base. If you say "check your email" for both cases, they can't. The timings differ slightly - the email delivery path takes longer - so the generator always calls `redirect_to` outside the conditional. - -**2. The reset token expires.** The generator uses `generates_token_for` with an expiry window. The token embedded in the email link is only valid for a limited window (default 15 minutes). After that, `find_by_token_for!` raises `InvalidSignature` and the user starts over. - -**3. The token self-invalidates on password change.** The `generates_token_for` API signs the token against `password_salt`. When the user changes their password, bcrypt generates a new salt, and the old token becomes invalid automatically. Someone who intercepted the reset email can't reuse the same link after the password has been changed. - -## Email verification - confirm before allowing access - -The generator's confirmation flow ensures the email address belongs to the person who signed up: - -```ruby -# app/models/user.rb - additions for email verification -class User < ApplicationRecord - has_secure_password - - generates_token_for :email_confirmation, expires_in: 24.hours do - email_address - end - - before_create :generate_confirmation_token -end - -# app/controllers/confirmations_controller.rb -class ConfirmationsController < ApplicationController - allow_unauthenticated_access - - def new - # Show "check your email" page - end - - def create - @user = User.find_by_token_for!(:email_confirmation, params[:token]) - @user.confirm! - start_new_session_for @user - redirect_to root_path, notice: "Email confirmed." - rescue ActiveSupport::MessageVerifier::InvalidSignature - redirect_to new_session_path, alert: "Confirmation link is invalid or has expired." - end -end -``` - -The `generates_token_for` API is worth understanding because it replaces a lot of hand-rolled token logic. It produces a signed, time-limited token tied to a specific attribute. If the email address changes, the old token becomes invalid - even if it hasn't expired. This prevents someone from signing up with `attacker@evil.com`, changing their email to `victim@company.com`, and confirming the attacker's original email to get access. - -```ruby -# generates_token_for is worth understanding: -user = User.find_by(email_address: "new@example.com") -token = user.generate_token_for(:email_confirmation) -# token is valid for 24 hours, tied to this user's email_address - -user.update(email_address: "changed@example.com") -# The old token is now invalid - even if it hasn't expired. -# Changing the email changes what the token signs. -``` - -## Rate limiting - the generator doesn't do this, so here's what to add - -The generator ships without rate limiting. A determined attacker can hammer `/passwords` or `/sessions` with a script, enumerating users or brute-forcing passwords. The fix is minimal: - -```ruby -# config/initializers/rack_attack.rb -class Rack::Attack - # Throttle login attempts by IP - 5 per 20 seconds - throttle("logins/ip", limit: 5, period: 20.seconds) do |req| - req.ip if req.path == "/sessions" && req.post? - end - - # Throttle password reset requests by IP - 3 per hour - throttle("passwords/ip", limit: 3, period: 1.hour) do |req| - req.ip if req.path == "/passwords" && req.post? - end - - # Throttle signups by IP - 3 per hour - throttle("signups/ip", limit: 3, period: 1.hour) do |req| - req.ip if req.path == "/registrations" && req.post? - end - - # Blocklist after repeated violations - Rack::Attack.blocklist("repeat offenders") do |req| - Rack::Attack::Allow2Ban.filter(req.ip, maxretry: 10, findtime: 10.minutes, bantime: 1.hour) do - req.path.match?(/^\/(sessions|passwords|registrations)/) && req.post? - end - end -end -``` - -Add `gem "rack-attack"` to your Gemfile, add this initializer, and your auth endpoints are protected. The blocklist fires after 10 violations in 10 minutes from the same IP - a one-hour ban. Adjust the thresholds for your traffic patterns. - -## When to keep Devise - -The Rails 8 auth generator handles 80% of what Devise does. The 20% where Devise still wins: - -**OmniAuth integration.** The generator has no built-in OAuth. Adding Google, GitHub, or Apple sign-in requires wiring up the omniauth gem and callback controller yourself. Devise gives you `devise :omniauthable` with a one-line config per provider. If your app relies on social login, Devise saves you a day of integration work. - -**Account locking.** Brute-force protection beyond rate limiting - locking an account after N failed attempts, requiring admin or email-based unlock - is built into Devise via `devise :lockable`. The generator delegates this to `rack-attack`, which is IP-based, not account-based. An attacker with a botnet can rotate IPs and keep trying passwords against the same account despite rate limiting. If your threat model includes targeted credential attacks, Devise's account-level locking is the right primitive. - -**Trackable and timeoutable.** Devise ships with `trackable` (sign-in count, timestamps, IP address) and `timeoutable` (auto-logout after inactivity). The generator provides neither out of the box. You can add them - the `sessions` table already has `ip_address` and timestamps - but it's code you write instead of a config flag you set. - -**Enterprise features.** LDAP, SAML, two-factor via TOTP, password history enforcement, password complexity rules - Devise has gems for all of these. The generator is a starting point. If you need three enterprise auth features on day one, starting from the generator means building all three from scratch. - -If you're migrating from Devise, see [our Devise to Rails 8 auth migration guide](/blog/rails-8-authentication-generator-devise-migration/). If you're starting a new app and none of the Devise-only features above are requirements, use the generator. You can always add Devise later - removing it is harder than adding it. - -## Production hardening checklist - -Before deploying the generated auth to production: - -- [ ] **Enable `config.force_ssl = true`** in `config/environments/production.rb`. Session cookies marked `secure: true` (as the generator does) will be rejected by browsers over plain HTTP. Force SSL prevents misconfigurations from becoming security gaps. -- [ ] **Install and configure `rack-attack`** with the throttle rules above. The generator has no built-in rate limiting. -- [ ] **Configure ActionMailer** with a production email service (Postmark, SendGrid). Password reset and email confirmation emails need to actually arrive. Test this in staging before going live. -- [ ] **Set `Rails.application.config.session_store :cookie_store, expire_after: 2.weeks`**. The generator's session cookie is permanent by default. A 2-week idle timeout is a reasonable default - users who disappear for a month shouldn't stay logged in. -- [ ] **Audit `config/credentials.yml.enc`** for the `secret_key_base`. The signed cookies in `cookies.signed[:session_token]` depend on it. Rotating `secret_key_base` invalidates all sessions. -- [ ] **Add a `before_action :refresh_session`** to `ApplicationController` that bumps the session cookie's expiry on each authenticated request. Without this, a user who logs in, works for 30 minutes, and walks away will be logged out at exactly `expire_after` from their initial login - not from their last activity. -- [ ] **Set `SameSite=Strict`** on the session cookie if your app doesn't need cross-site requests. The generator uses Rails defaults. Add `same_site: :strict` to the `cookies.signed.permanent` line in `start_new_session_for` if your login form is on the same origin as your app. - -## What we shipped with the generator - -A client came to us in Q1 2026 with a Rails app running Devise. They had three custom modules: an email verification that didn't verify, a password reset that was broken in production (ActionMailer misconfiguration), and a session timeout that had been commented out in 2024 because it kept logging out mobile users. - -None of these were Devise's fault. Devise was doing exactly what the original developers had configured it to do three years ago. The problem was that nobody on the current team understood all 200 lines of `config/initializers/devise.rb`. The developer who wrote it had left. The remaining team was afraid to touch it. - -We migrated them to the generator in two days. The password reset started working because the code was explicit - `PasswordsMailer.reset(user).deliver_later` with a visible `ActionMailer` configuration, not a Devise module that dispatched emails through an invisible pipeline. The session timeout was four lines in `Authentication#authenticate!` that anyone on the team could read and adjust. The email verification worked because `generates_token_for :email_confirmation` is self-documenting. - -The takeaway isn't that Devise is bad. It's that authentication code you can't read is a liability. The Rails 8 generator gives you authentication code you can read - and therefore fix - without opening a gem's source. - -## The decision in 60 seconds - -Use the Rails 8 authentication generator when you want to own your auth code, when your needs are standard (email/password, password reset, email verification), and when your team values simplicity over feature completeness. - -Keep Devise when you need omniauth, account locking, or enterprise features on day one. Devise is battle-tested. The generator is newer. Both are valid choices. The question is whether the features you're not using are worth the lines of code you can't touch. - -For most new Rails 8 apps, the answer is no. - -*For the full migration guide from Devise, see [Devise to Rails 8 Auth: Migration Guide](/blog/rails-8-authentication-generator-devise-migration/). For deployment patterns, see [Rails 8 Docker Production Deployment](/blog/rails-8-docker-deployment-production-guide/) and our [Solid Queue migration guide](/blog/rails-8-solid-queue-migration-guide/). For performance tuning on the same stack, see [Falcon in Production: Benchmarks, Memory & Worker Count](/blog/falcon-web-server-production-tuning-benchmarks/). If you're weighing a Devise migration on a real codebase, [our Rails team can help](/services/app-web-development/).* diff --git a/content/blog/rails-8-authentication-generator-devise-migration/index.md b/content/blog/rails-8-authentication-generator-devise-migration/index.md index 48e922484..ee94e4c9f 100644 --- a/content/blog/rails-8-authentication-generator-devise-migration/index.md +++ b/content/blog/rails-8-authentication-generator-devise-migration/index.md @@ -8,6 +8,10 @@ tags: ["rails", "authentication", "devise", "security", "rails8"] canonical_url: "https://jetthoughts.com/blog/rails-8-authentication-generator-devise-migration/" cover_image: "cover.png" slug: "rails-8-authentication-generator-devise-migration" +aliases: + - /blog/rails-8-authentication-generator-complete-guide/ + - /blog/rails-8-introducing-built-in-authentication-generator-ruby/ + - /blog/new-methods-that-help-implement-authentication-in-ruby-on-rails-71/ author: "JetThoughts Team" metatags: image: cover.png @@ -119,7 +123,7 @@ For teams struggling with Devise complexity and seeking to modernize their authe ## Understanding Rails 8's Built-In Authentication -Rails 8 auth asks one question: what do you actually use? For most apps, the answer is surprisingly little. If you haven't read the [overview of Rails 8's authentication generator](/blog/rails-8-introducing-built-in-authentication-generator-ruby/), start there for the high-level picture. +Rails 8 auth asks one question: what do you actually use? For most apps, the answer is surprisingly little. ### Core Philosophy: Convention Over Framework @@ -137,7 +141,7 @@ $ rails generate authentication # - Migrations for users and sessions tables ``` -That's it. No complex configuration files, no mysterious modules, no hidden behaviors. The foundation builds on [authentication helpers introduced in Rails 7.1](/blog/new-methods-that-help-implement-authentication-in-ruby-on-rails-71/) -- `generates_token_for`, `authenticate_by`, and `normalizes` -- so the patterns will feel familiar if you've already adopted those. +That's it. No complex configuration files, no mysterious modules, no hidden behaviors. The foundation builds on authentication helpers introduced in Rails 7.1 -- `generates_token_for`, `authenticate_by`, and `normalizes` -- so the patterns will feel familiar if you've already adopted those. ### Architecture: Simple and Transparent @@ -220,6 +224,20 @@ end Transparent, understandable, and easy to customize. No hidden behaviors. +The `find_by` + `authenticate` pair above works, but it runs two separate steps and leaks timing information: a lookup that misses returns instantly, one that hits and fails the password check takes slightly longer. `authenticate_by`, shipped in Rails 7.1, collapses both into one call that runs a password digest even when no record matches, so a miss no longer returns instantly: + +```ruby +if (user = User.authenticate_by(email: params[:email], password: params[:password])) + session[:user_id] = user.id + redirect_to root_path, notice: "Signed in successfully" +else + flash.now[:alert] = "Invalid email or password" + render :new, status: :unprocessable_entity +end +``` + +Same outcome, and the instant-return tell is gone. That mitigates timing-based email enumeration rather than eliminating it -- `authenticate_by` is not a constant-time guarantee -- but it closes the gap that matters most. Prefer `authenticate_by` in new code; the `find_by` + `authenticate` pattern above still works and you'll see it in older codebases. + #### Current User Pattern ```ruby @@ -259,17 +277,12 @@ class PasswordsController < ApplicationController user = User.find_by(email: params[:email]) if user - # Generate secure token using Rails 7.1+ generates_token_for token = user.generate_token_for(:password_reset) - - # Send password reset email UserMailer.password_reset(user, token).deliver_later - - redirect_to root_path, notice: "Password reset instructions sent" - else - flash.now[:alert] = "Email not found" - render :new, status: :unprocessable_entity end + + # Same message whether the email exists or not -- see below + redirect_to root_path, notice: "Password reset instructions sent" end def edit @@ -300,6 +313,8 @@ class PasswordsController < ApplicationController end ``` +Two details worth getting right here. First, `create` above always redirects with the same message regardless of whether the email matched -- if you branch on "email not found," an attacker can enumerate your user base one request at a time. Second, the `:password_reset` token defined earlier embeds the last ten characters of `password_digest` in its payload, so a token issued before a password change is invalid after it: someone who intercepts an old reset email can't reuse the link once the password has actually been reset. That property comes from the block you pass to `generates_token_for`, not from the API itself -- a token whose block returns nothing survives a password change. + #### Email Confirmation ```ruby @@ -451,6 +466,12 @@ end #### Session Management and Device Tracking +The default `session[:user_id]` approach stores a signed user ID in a cookie -- it works, but you can't revoke it. If a user's laptop is stolen or a password leaks, that cookie stays valid until it expires on its own. Moving sessions into a database table fixes this: the cookie holds a session token instead of a user ID, and logging a user out everywhere is a single `destroy_all` call: + +```ruby +user.sessions.destroy_all # every device's cookie stops working on its next request +``` + ```ruby # db/migrate/[timestamp]_create_sessions.rb class CreateSessions < ActiveRecord::Migration[8.0] @@ -1036,6 +1057,8 @@ Rails.application.config.session_store :cookie_store, expire_after: 2.weeks # Session expiration ``` +Two things break this quietly if you skip them. Without `config.force_ssl = true` in `config/environments/production.rb`, a `secure: true` cookie gets silently rejected the first time a request lands on plain HTTP. And `expire_after` counts from login, not from last activity -- a user who logs in and works for three hours gets logged out mid-session unless you also bump the cookie's expiry on each authenticated request (`before_action :refresh_session` that rewrites `cookies.signed.permanent[:session_token]` is enough). + #### Password Strength Enforcement ```ruby @@ -1332,6 +1355,6 @@ If you're starting fresh on Rails 8, skip Devise entirely. Run `rails generate a If you're migrating, start with Phase 1: audit your Devise usage and map it to Rails 8 equivalents. The password hashes are compatible -- that's the hardest part already solved. Run dual auth in production for at least two weeks before cutting over. -For related reading: our [Argon2 migration guide](/blog/rails-argon2-has-secure-password-migration-guide/) covers upgrading password hashing beyond BCrypt, and the [Rails 8 authentication generator overview](/blog/rails-8-introducing-built-in-authentication-generator-ruby/) walks through the generated code in detail. +For related reading: our [Argon2 migration guide](/blog/rails-argon2-has-secure-password-migration-guide/) covers upgrading password hashing beyond BCrypt. For teams undertaking auth migrations or needing security guidance, our [Rails development team](/services/app-web-development/) has done this migration three times in production -- we can help you avoid the sharp edges. diff --git a/content/blog/rails-8-introducing-built-in-authentication-generator-ruby/cover.png b/content/blog/rails-8-introducing-built-in-authentication-generator-ruby/cover.png deleted file mode 100644 index d0bd8adaf..000000000 Binary files a/content/blog/rails-8-introducing-built-in-authentication-generator-ruby/cover.png and /dev/null differ diff --git a/content/blog/rails-8-introducing-built-in-authentication-generator-ruby/index.md b/content/blog/rails-8-introducing-built-in-authentication-generator-ruby/index.md deleted file mode 100644 index 2f898aa34..000000000 --- a/content/blog/rails-8-introducing-built-in-authentication-generator-ruby/index.md +++ /dev/null @@ -1,177 +0,0 @@ ---- -remote_url: https://dev.to/jetthoughts/rails-8-introducing-the-built-in-authentication-generator-1l77 -source: dev_to -remote_id: 2126561 -dev_to_id: 2126561 -dev_to_url: https://dev.to/jetthoughts/rails-8-introducing-the-built-in-authentication-generator-1l77 -title: 'Rails 8: Introducing the Built-in Authentication Generator' -description: 'Ruby on Rails developers face a common task: building user login systems. Rails 8 brings a built-in...' -created_at: '2024-11-29T09:11:43Z' -edited_at: '2024-12-07T14:02:07Z' -date: 2024-11-29 -draft: false -tags: -- rails -- ruby -- tutorial -- changelog -canonical_url: https://jetthoughts.com/blog/rails-8-introducing-built-in-authentication-generator-ruby/ -cover_image: https://raw.githubusercontent.com/jetthoughts/jetthoughts.github.io/master/content/blog/rails-8-introducing-built-in-authentication-generator-ruby/cover.png -metatags: - image: cover.png -slug: rails-8-introducing-built-in-authentication-generator-ruby ---- -Ruby on Rails developers face a common task: building user login systems. Rails 8 brings a built-in solution. This guide helps Rails developers create authentication without extra gems. - -## What Ruby Developers Will Build - -As a Rails developer, you'll create: - -- A secure authentication system -- Public and private pages -- A test suite for user flows -- Database migrations for users - -## Set Up Your Rails Environment - -Ruby on Rails developers start here: - -```bash -rails new auth-example -cd auth-example -``` - -This creates a fresh Rails application. - -## Rails 8 Authentication Generator - -Rails developers love simple solutions. Run: - -```bash -bin/rails generate authentication -``` - -The generator creates Rails files: - -- ERB view templates -- Rails controllers -- Ruby models -- RSpec test files - -## Database Setup for Rails - -Rails developers need a working database: - -```bash -bin/rails db:create db:migrate -``` - -Create a test user in Rails console: - -```bash -rails c -``` - -Add user data: - -```ruby -User.create(email_address: "you@example.com", password: "test-password-123") -``` - -## Rails Controllers - -Rails developers work with two types of access: - -- Public pages for visitors -- Private pages for users - -Generate Rails controllers: - -```bash -rails g controller home index -rails g controller dashboard show -``` - -## Ruby on Rails Routes - -Rails routing connects URLs to Ruby code. Update `config/routes.rb`: - -```ruby -Rails.application.routes.draw do - get "home/index", as: :home - get "dashboard/show", as: :dashboard - root "home#index" -end -``` - -## Public Controller in Rails - -Ruby developers handle public access: - -```ruby -class HomeController < ApplicationController - allow_unauthenticated_access(only: :index) - def index - end -end -``` - -## Authenticated Controller - -Rails developers protect private pages: - -```ruby -class DashboardController < ApplicationController - before_action :resume_session, only: [:show] - def show - end -end -``` - -## Test Your Rails App - -Start your Rails server: - -```bash -rails s -``` - -Test these Rails routes: - -1. `/home` - Public access -2. `/dashboard` - Login required - -## Rails Security Tips - -Ruby developers follow these practices: - -- Use secure passwords -- Keep Rails updated -- Monitor logs -- Back up data - -## Rails Authentication Code - -View the Ruby code: [Rails Authentication Source](https://github.com/rails/rails/blob/main/railties/lib/rails/generators/rails/authentication/templates/app/controllers/concerns/authentication.rb.tt) - -## Tasks for Rails Developers - -Try these Ruby tasks: - -1. Build a signup flow -2. Create a password reset -3. Add session persistence -4. Style your ERB templates - -## Next Steps for Rails Developers - -You can extend this: - -- Add OAuth support -- Create admin roles -- Add API authentication -- Build user profiles - -Remember: Now, the Rails developer starts with basic authentication. Build more. Learn more. - -Need Ruby on Rails help? Drop questions below. diff --git a/content/blog/rails-argon2-has-secure-password-migration-guide/index.md b/content/blog/rails-argon2-has-secure-password-migration-guide/index.md index c431d2f8f..e37d03368 100644 --- a/content/blog/rails-argon2-has-secure-password-migration-guide/index.md +++ b/content/blog/rails-argon2-has-secure-password-migration-guide/index.md @@ -288,7 +288,7 @@ Be honest about when this isn't worth the effort: Start with the hybrid verifier and metrics. Ship it behind a feature flag if your team does staged rollouts. Monitor the BCrypt-to-Argon2 conversion rate daily; on the two production apps we have shipped this on so far, daily-active users had rotated within fourteen days, and weekly-active users by week six. -If you're also modernizing your auth stack, the [Rails 8 authentication generator](/blog/rails-8-introducing-built-in-authentication-generator-ruby/) pairs well with this migration. And if you're hardening more than just passwords, our post on [authentication patterns in Rails 7.1](/blog/new-methods-that-help-implement-authentication-in-ruby-on-rails-71/) covers the broader picture. +If you're also modernizing your auth stack, our [Rails 8 authentication generator guide](/blog/rails-8-authentication-generator-devise-migration/) covers the full migration from Devise, including the `normalizes`, `generates_token_for`, and `authenticate_by` helpers this migration builds on. For teams handling sensitive data, pair this with [encrypted data compression in Rails 8](/blog/ruby-on-rails-8-custom-compression-for-encrypted-data/) so the at-rest layer matches the new password discipline. And if you're containerizing your deploys, our [Rails 8 Docker production guide](/blog/rails-8-docker-deployment-production-guide/) covers how to handle migrations safely during rollout. diff --git a/content/blog/rails-cve-2026-66066-active-storage-rce/cover.png b/content/blog/rails-cve-2026-66066-active-storage-rce/cover.png new file mode 100644 index 000000000..8a5994023 Binary files /dev/null and b/content/blog/rails-cve-2026-66066-active-storage-rce/cover.png differ diff --git a/content/blog/rails-cve-2026-66066-active-storage-rce/index.md b/content/blog/rails-cve-2026-66066-active-storage-rce/index.md new file mode 100644 index 000000000..633630d8d --- /dev/null +++ b/content/blog/rails-cve-2026-66066-active-storage-rce/index.md @@ -0,0 +1,167 @@ +--- +title: "Rails CVE-2026-66066: Patch Isn't Enough" +description: "CVE-2026-66066 lets an unauthenticated upload read server files via Active Storage. Patching Rails is not enough - libvips must go to 8.13 and secrets rotate." +date: 2026-08-08 +draft: false +author: "JetThoughts" +slug: "rails-cve-2026-66066-active-storage-rce" +keywords: "CVE-2026-66066, rails active storage rce, kindarails2shell, libvips 8.13, active storage arbitrary file read, secret_key_base rotation" +tags: +- rails +- security +- cve +- active-storage +- devops +categories: +- Rails +- Security +cover_image: cover.png +metatags: + image: cover.png +cover_image_alt: "Active Storage CVE-2026-66066 cover - Patch Isn't Enough, with CVSSv4 9.5 severity, libvips 8.13 or newer, and possible pre-auth RCE" +canonical_url: https://jetthoughts.com/blog/rails-cve-2026-66066-active-storage-rce/ +--- + +You bumped Rails to 8.1.3.1 on a stock Ubuntu 22.04 image and deployed. The app refused to boot. + +That's the patch working as designed. **CVE-2026-66066** - the Active Storage bug the researchers at Ethiack named "KindaRails2Shell" - can't be fixed by the gem alone. The patched releases check your libvips version at boot and raise when the library is too old. The gem bump is half the job. Getting the container's libvips to 8.13 and rotating every secret the process could've leaked is the rest. + +```text +Affected: Active Storage < 7.2.3.2 · >=8.0 <8.0.5.1 · >=8.1 <8.1.3.1, with variant processing +Fixed in: 7.2.3.2 · 8.0.5.1 · 8.1.3.1 (7.1 and older: no patch) +Also required: libvips >= 8.13 +CVE-2026-66066 · CVSSv4 9.5 · GHSA-xr9x-r78c-5hrm +``` + +Ethiack and GMO Flatt Security reported it, and the Rails security team disclosed it 2026-07-29 in [GHSA-xr9x-r78c-5hrm](https://github.com/rails/rails/security/advisories/GHSA-xr9x-r78c-5hrm). + +## Are you exposed? + +The advisory sets three conditions, and together they are the whole test: + +1. Active Storage uses the **libvips** variant processor. `config.active_storage.variant_processor = :vips` has been the default since `load_defaults 7.0`, so a 7.x or 8.x app meets this condition without a line of config. +2. The app accepts **image uploads from untrusted users**. +3. The environment lacks protection against unfuzzed libvips operations: **libvips older than 8.13**, or 8.13+ with the untrusted-operation block left off. + +The advisory closes one door explicitly: "Generating variants is not a separate requirement." Grepping the codebase for `.variant(` calls and finding none does not clear you. If the three conditions hold, you're exposed. + +Two of the three are checkable by command. Run these inside the running app; laptop versions prove nothing. + +For the first condition and the gem itself, the versions actually loaded: + +```bash +bin/rails runner 'puts Rails.version' +bundle list | grep -E 'activestorage|ruby-vips|image_processing' +``` + +For the third, the libvips your Ruby process links, which is the version that decides everything - the exploit path runs through the library `ruby-vips` loads in-process: + +```bash +bin/rails runner 'require "vips"; puts Vips::LIBRARY_VERSION' +# 8.12.x -> vulnerable today, and the patched gem will refuse to boot here +# 8.13.0+ -> the patched gem's guard works +``` + +`vips --version` is a reasonable secondary check where the CLI exists, but slim runtime images often ship `libvips42` without `vips-tools`, so a missing binary tells you nothing about the library your app loaded. + +The second condition - untrusted image uploads - is a judgment call about your app, not a grep. A call-site scan still helps you order the work: + +```bash +rg -n 'has_one_attached|has_many_attached' app/models/ +rg -n '\.variant\(|\.representation\(|\.processed\b' app/ +``` + +Treat those hits as a priority map - where uploads enter, which paths generate images today - not as the exposure gate. An unauthenticated avatar cropper goes to the top of the remediation list; an empty second search changes nothing about whether you patch. + +## The remediation runbook + +Work top to bottom. The patched gem raises at boot when it finds libvips older than 8.13, so a gem bump that reaches production ahead of the library upgrade takes the app down. Land the libvips upgrade in the same image build as the gem bump, or in the build before it. + +**1. Get libvips to 8.13 or newer in the image that runs in production.** + +This is where base images bite. `apt-get install libvips` on Ubuntu 22.04 (Jammy) installs [libvips 8.12.1](https://packages.ubuntu.com/jammy/libvips42) - one minor version short, and short in exactly the release that [first shipped the untrusted-loader block](https://github.com/libvips/libvips/releases/tag/v8.13.0). Debian and Ubuntu bases below 8.13 need a newer source than the default apt repo, and the Dockerfile should prove it got one: + +```dockerfile +# Fails the build unless the installed libvips is >= 8.13 +RUN dpkg --compare-versions "$(vips --version | cut -d- -f2)" ge 8.13 +``` + +That gate needs `vips-tools` in the build stage, and it only proves what the image installed - the `Vips::LIBRARY_VERSION` check above is what confirms the running process picked the new library up. The [Rails 8 Docker production guide](/blog/rails-8-docker-deployment-production-guide/) covers pinning a library version so the next base rebuild doesn't quietly regress you to 8.12. + +**2. Patch the gem - same image build as step one, or after it.** + +```ruby +# Gemfile - pick your line +gem "rails", "7.2.3.2" # or 8.0.5.1, or 8.1.3.1 +``` + +```bash +bundle update rails +``` + +If you can't bump the gem yet but the image already carries libvips >= 8.13, the advisory's workaround is to turn the block on yourself: export `VIPS_BLOCK_UNTRUSTED=1`, or call `Vips.block_untrusted(true)` from an initializer with `ruby-vips` >= 2.2.1. Below libvips 8.13 the advisory is blunt: no workaround exists short of removing the libvips dependency from the app. + +**3. Rotate every secret the app process can read.** This isn't optional if the app was reachable before you patched. + +Akamai spelled out the chain: with `secret_key_base` in hand, an attacker forges session cookies and signs Global IDs, then hands Rails serialized payloads it will trust. That's how a file read can become code execution. The advisory hedges the RCE - an attacker "may be able to" invoke an unfuzzed operation - but the file read and the secret exposure behind it are the dependable part of the chain. And the read may have already happened - there are no reliable indicators of compromise for it, so you assume it did. + +Rotate, in this order: + +- `RAILS_MASTER_KEY` - the read targets `/proc/self/environ`, which is exactly where it lives. Rotate the key and re-encrypt `config/credentials.yml.enc`, or an attacker holding the old key just decrypts whatever new credentials you put in the file. +- `secret_key_base` +- Active Storage service credentials (S3, GCS, Azure keys) +- Database credentials +- Any third-party API token the app process holds - Stripe, SendGrid, internal service keys + +Rotating `secret_key_base` invalidates more than live sessions. All outstanding signed data dies with it: `signed_id` links in password-reset and magic-link emails already sent, signed Active Storage blob and service URLs cached in emails or a CDN, signed and encrypted cookies, signed GlobalIDs. Expect dead links in mail that's already out, and write the support notice before you rotate. + +There were no confirmed reports of in-the-wild exploitation at disclosure; a full exploit chain went public around 2026-08-03. + +## What the bug actually does + +Active Storage generates image variants through libvips by default. `config.active_storage.variant_processor = :vips` has been the default since `load_defaults 7.0`, and no later default changed it, so a 7.x or 8.x app runs it whether the config file mentions it or not. Our [Active Storage variants walkthrough](/blog/preview-ui-changes-with-ruby-on-rails-variants/) shows what that processing looks like under normal use. + +libvips ships loaders its own maintainers mark "unfuzzed" - safe for images you produced, not for bytes a stranger uploaded. Active Storage handed uploads to those loaders in-process: libvips is linked into your Ruby process through `ruby-vips`. An attacker uploads a crafted file, your app calls `.variant(resize_to_limit: ...)` on it, and one of those loaders reads a file off disk that has nothing to do with images. + +The file it reads can be `/proc/self/environ` - the process environment, which is why the rotation list starts with `RAILS_MASTER_KEY`. Nothing in that chain checks whether the uploader ever logged in. + +## Why the patched gem fails closed + +The patched gems (7.2.3.2, 8.0.5.1, 8.1.3.1) tell libvips to block untrusted operations. libvips had no way to disable the unfuzzed loaders until 8.13, so on anything older the guard has nothing to switch. Rails handles that case by refusing to run: the advisory states that "Active Storage will raise an exception during boot in such an unsecurable environment." + +That failure mode is a feature. You find out about the version gap at deploy time, from a crash loop in your own output. It's also the reason the runbook puts the library ahead of the gem - `bundle update rails` against an 8.12 base image ships a boot crash. + +## Rails 7.1 and older: no patch is coming + +The security team backported to 7.2, 8.0, and 8.1. Rails 7.1 got nothing; it had [finished its security support period](https://rubyonrails.org/2025/10/29/new-rails-releases-and-end-of-support-announcement) by October 2025, and 7.0 and earlier were already out. Same stagger we hit with [CVE-2026-41316 three months ago](/blog/rails-cve-2026-41316-founder-guide/) - EOL branches are skipped by policy. + +If you're on 7.1 or below, you have three moves: + +- **Upgrade to 7.2 or 8.x.** A single-major jump is a project with real calendar cost, and the thinner your test coverage, the longer it runs. This CVE is the business case that gets it prioritized. +- **Buy backports** from a maintained-EOL vendor like [HeroDevs](https://www.herodevs.com/blog-posts/cve-2026-66066-rails-active-storage-arbitrary-file-read-and-rce) if the upgrade can't land this quarter. +- **Break the chain now.** The step-2 workaround applies here too once libvips is >= 8.13, or switch the processor off vips: `config.active_storage.variant_processor = :mini_magick`. MiniMagick shells out to ImageMagick, which carries its own history of upload CVEs, so treat it as a stopgap while the real upgrade lands. + +## When this doesn't apply to you + +Not every Rails app is in scope, and pretending otherwise wastes a maintenance window. + +Apps that configured `variant_processor = :mini_magick` before this disclosure never hand uploads to libvips, so the vulnerable path is off. Apps where every image upload sits behind authentication and admin-only forms carry a narrower risk: the only person who can trigger the read is a logged-in insider. Still worth patching, less worth paging someone at 2am. + +Apps that already pin libvips >= 8.13 and run a patched gem are done after the secret rotation. What does not take you out of scope: never calling `.variant` in your own code - the advisory is explicit that generating variants is not a separate requirement. The one group that can't opt out: any app taking image uploads from logged-out users on the :vips default. + +## Where to go next + +Rebuild the production image with libvips >= 8.13 and the patched gem together, confirm the version gate passes inside the container, then rotate. If you deploy with Kamal, the image swap and the secret rotation ride the same release - the [multi-server Kamal guide](/blog/kamal-2-multi-server-deployment-complete-guide/) covers pushing a rebuilt base across a fleet. + +After that, wire the `rubyonrails-security` list into a channel your on-call reads, and put a deploy-log alert on failed variant processing so a future crafted upload shows up as signal - [Rails 8.1 structured logging](/blog/rails-event-structured-logging-8-1/) is the second half of that setup. + +## Sources + +- [GHSA-xr9x-r78c-5hrm - official Rails advisory](https://github.com/rails/rails/security/advisories/GHSA-xr9x-r78c-5hrm) +- [Rails security discussion: CVE-2026-66066](https://discuss.rubyonrails.org/t/cve-2026-66066-possible-arbitrary-file-read-and-remote-code-execution-in-active-storage-variant-processing/91432) +- [Akamai: Defending against KindaRails2Shell](https://www.akamai.com/blog/security-research/rails-active-storage-rce-cve-2026-66066) +- [Rapid7: KindaRails2Shell analysis](https://www.rapid7.com/blog/post/etr-kindarails2shell-cve-2026-66066-critical-arbitrary-file-read-and-possible-remote-code-execution-in-ruby-on-rails/) +- [BleepingComputer: Rails patches critical Active Storage flaw](https://www.bleepingcomputer.com/news/security/rails-patches-critical-active-storage-flaw-with-rce-potential/) +- [The Hacker News: Critical Rails flaw](https://thehackernews.com/2026/07/critical-rails-flaw-could-let.html) +- [HeroDevs: CVE-2026-66066 arbitrary file read and RCE](https://www.herodevs.com/blog-posts/cve-2026-66066-rails-active-storage-arbitrary-file-read-and-rce) + diff --git a/content/blog/solid-queue-advanced-patterns-retries-concurrency/index.md b/content/blog/solid-queue-advanced-patterns-retries-concurrency/index.md index 2c8fd9802..3fd28f076 100644 --- a/content/blog/solid-queue-advanced-patterns-retries-concurrency/index.md +++ b/content/blog/solid-queue-advanced-patterns-retries-concurrency/index.md @@ -327,4 +327,4 @@ Solid Queue replaces Sidekiq for 80% of Rails apps. The 20% where it doesn't: For the rest of us - the apps processing 10K-200K jobs/hour, running on reasonable hardware - Solid Queue eliminates a Redis dependency, simplifies the stack, and gives you job visibility in the same database you already query. The trade-off is real but the simplification wins for most workloads. -*For the migration guide and benchmarks, see [Rails 8 Solid Queue: Migration Guide for Production Apps](/blog/rails-8-solid-queue-migration-guide/). For deployment patterns that pair well with Solid Queue, see [Falcon in Production: Benchmarks, Memory & Worker Count](/blog/falcon-web-server-production-tuning-benchmarks/). For replacing Devise in the same stack, see [Rails 8 Authentication Generator: Complete Guide](/blog/rails-8-authentication-generator-complete-guide/). If you're running Solid Queue at scale, [our Rails team can help](/services/app-web-development/).* +*For the migration guide and benchmarks, see [Rails 8 Solid Queue: Migration Guide for Production Apps](/blog/rails-8-solid-queue-migration-guide/). For deployment patterns that pair well with Solid Queue, see [Falcon in Production: Benchmarks, Memory & Worker Count](/blog/falcon-web-server-production-tuning-benchmarks/). For replacing Devise in the same stack, see [Rails 8 Authentication Generator: Complete Migration from Devise](/blog/rails-8-authentication-generator-devise-migration/). If you're running Solid Queue at scale, [our Rails team can help](/services/app-web-development/).* diff --git a/content/blog/vibe-coding-crisis-ai-code-debt/index.md b/content/blog/vibe-coding-crisis-ai-code-debt/index.md index 3f9c67b4c..b33c4b2fe 100644 --- a/content/blog/vibe-coding-crisis-ai-code-debt/index.md +++ b/content/blog/vibe-coding-crisis-ai-code-debt/index.md @@ -54,7 +54,7 @@ We worked with a fintech founder last year who couldn't figure out why customers Over four months those errors compounded across thousands of transactions. The founder found out when one of his users filed a complaint with their bank. -Nothing about it made the news. There was no breach to report and no money technically stolen, just a quiet drift in a number that nobody had bothered to test. That's the more typical way vibe-coded apps fail in production - slowly and expensively, over enough time that the original developer is long gone by the time anyone notices. +Nothing about it made the news. There was no breach to report and no money technically stolen, just a quiet drift in a number that nobody had bothered to test. That's the more typical way vibe-coded apps fail in production - slowly and expensively, over enough time that the original developer is long gone by the time anyone notices. That quiet, expensive failure mode is the one our [vibe code rescue](/services/vibe-code-rescue/) service was built around. ## How we ship AI-assisted code now diff --git a/docs/projects/2510-seo-content-strategy/20-29-strategy/20.09-content-plan-revision-aug-2026.md b/docs/projects/2510-seo-content-strategy/20-29-strategy/20.09-content-plan-revision-aug-2026.md index 4d764e664..961ce1bbf 100644 --- a/docs/projects/2510-seo-content-strategy/20-29-strategy/20.09-content-plan-revision-aug-2026.md +++ b/docs/projects/2510-seo-content-strategy/20-29-strategy/20.09-content-plan-revision-aug-2026.md @@ -197,6 +197,12 @@ Why this one clears the bar when almost nothing else does: - **Rails ≤7.1 has no patch** (security support ended 2025-10-01), which turns row N3 into a live-exposure post rather than a housekeeping one. +**Shipped 2026-08-08** as `rails-cve-2026-66066-active-storage-rce` (PR #439). +The review loop caught four blockers, one of which inverted the post's thesis: +the draft said the patched gem silently no-ops on old libvips, but the advisory +says Active Storage raises at boot. As drafted the runbook would have taken a +reader's production down on Ubuntu 22.04. Runbook now puts libvips first. + Sources: [Rapid7](https://www.rapid7.com/blog/post/etr-kindarails2shell-cve-2026-66066-critical-arbitrary-file-read-and-possible-remote-code-execution-in-ruby-on-rails/) · [Akamai](https://www.akamai.com/blog/security-research/rails-active-storage-rce-cve-2026-66066) · [BleepingComputer](https://www.bleepingcomputer.com/news/security/rails-patches-critical-active-storage-flaw-with-rce-potential/) · [HeroDevs](https://www.herodevs.com/blog-posts/cve-2026-66066-rails-active-storage-arbitrary-file-read-and-rce) --- diff --git a/docs/projects/2607-vibe-code-rescue/backlog.md b/docs/projects/2607-vibe-code-rescue/backlog.md index 70cec4d85..b684e4a6b 100644 --- a/docs/projects/2607-vibe-code-rescue/backlog.md +++ b/docs/projects/2607-vibe-code-rescue/backlog.md @@ -39,9 +39,11 @@ Strategy/gates/Paul's desk live in [`operation-runbook.md`](operation-runbook.md - **seeds**: heydev.us · modall.ca · attributex.ai · softdevdigital.com/blog/fix-vibe-coded-app · rockingtech.co.uk/products/platform-rescue · getautonoma.com/blog/vibe-coding-failures. - **done**: ≥3 commenter rows + VoC; NO competitor/author logged. -### P7 · F5Bot setup + first pull → `prospects/p7-f5bot.md` -- **do**: log into F5Bot (Paul's account — creds passed separately in-session, NEVER write them to any file), add keywords (`vibe coded broke`, `Lovable app broken`, `Replit app broke`, `can't access my code`, `agency won't give code`, `dev shop ghosted`) scoped to Reddit+HN+Lobsters; on next digest, read the JSON/RSS feed and log matching ICP posts. -- **done**: keywords live; first feed pulled; ≥3 rows if any matched. (If not yet set up, mark Blocked-on-Paul and skip — chain doesn't block.) +### P7 · Date-filtered search sweep → `prospects/p7-search-sweep.md` +- **supersedes** the F5Bot card (RETIRED 2026-08-08 — literal-substring matching, and passive-only so it can never backfill the ≤30-day window). `prospects/p7-f5bot.md` is kept banner-marked for its keyword derivation only; do not run it. +- **do**: run the query bank in `prospects/p7-search-sweep.md` §3 — `site:` operators across reddit/IH/HN/x/lobste.rs crossed with the corpus-derived v2 keywords and `after:{TODAY-30}` (compute the date per §2, never eyeball). Open each non-Reddit candidate ONCE, read the real timestamp + all replies, fill `verified date` + `thread health`. **Reddit is excerpt-only (wrapper rule) and P7 lead rows require an opened-thread timestamp, so Reddit hits feed the excerpt queue + VoC only - never P7 lead rows (sweep doc §3.1).** Normalize URLs, then dedupe against `cold-prospect-list.md` by normalized thread URL (§5). +- **needs**: a thread-opening tool (`chrome-devtools`) + a search surface that honours `site:`/`after:` — sanity-check per §4 before trusting a sweep. +- **done**: rows all carrying `verified date` ≤30 days + `thread health`; stale/saturated threads harvested for VoC before closing. Quality over quota — log what defensibly qualifies, pad nothing. --- @@ -90,7 +92,15 @@ Batch-1 Phase-1 pre-research (send-runner-prompt.md, run against card #12's 5 op **This means the other 20 rows in `cold-prospect-list.md` (built the same day, same method) carry the same unverified risk** and should not be treated as send-ready until re-checked. -**Filed**: #28 ✓ **DONE 2026-07-26** — v2 rubric live in `rescue-sprint/t4-t5-grooming.md` Vote 3 (5 checks; verified-timestamp ≤30-day window with venue caps, post|comment routing field, thread-health scan; `verified date` + `thread health` are now P8 admission-gate columns; cold-eyes v2 PASS, verdict in-file) → #29 (re-source v2 against the fixed rubric — next Ready). Directive from Paul (2026-07-26): prioritize finding **actual users with a real, current problem** over hitting a row-count quota; a shorter list of genuinely fresh, unsaturated rows beats a padded one. +**Filed**: #28 ✓ **DONE 2026-07-26** — v2 rubric live in `rescue-sprint/t4-t5-grooming.md` Vote 3 (5 checks; verified-timestamp ≤30-day window with venue caps, post|comment routing field, thread-health scan; `verified date` + `thread health` are now P8 admission-gate columns; cold-eyes v2 PASS, verdict in-file) → #29 (re-source v2 against the fixed rubric). Directive from Paul (2026-07-26): prioritize finding **actual users with a real, current problem** over hitting a row-count quota; a shorter list of genuinely fresh, unsaturated rows beats a padded one. + +### Card #29 status — **BLOCKED-ON-TOOLING (2026-08-08). Method delivered, zero rows.** + +- **F5Bot RETIRED 2026-08-08** (Paul called it dead). Two structural flaws, not keyword tuning: it matches **literal substrings** (v1 matched 0 of our 25 hand-found posts), and it is **passive** — it only catches mentions from setup-time forward, so it can never backfill the ≤30-day window on demand. The keyword swap is no longer a Paul unblock; drop it from Sprint 3. `prospects/p7-f5bot.md` carries a RETIRED banner and is kept only for its v2 keyword derivation. +- **Replacement method shipped**: `rescue-sprint/prospects/p7-search-sweep.md` — date-filtered active search (`site:` + `after:{TODAY-30}`), with a copy-paste query bank across Reddit/IH/HN/X/Lobsters using the corpus-derived v2 keywords, the window date arithmetic, an X-snowflake date-derivation shortcut, honest limitations, a weekly cadence, and a URL-keyed dedupe + `Seen-and-skipped` protocol. +- **Sweep executed 2026-08-08 → ZERO qualified rows, ZERO VoC founder lines.** Cause is tooling, not the market - and it is venue-specific, two distinct failure classes: (1) **search-surface limits** - bare `site:reddit.com` queries had the operator dropped (off-domain Substack/Goodreads results), while the tool's native domain filter DID return `indiehackers.com` and `x.com` URLs; `after:` filtering was ineffective everywhere it was tried (IH results dated 2025-07 and 2026-03 came back). (2) **venue access** - `reddit.com` rejected the tool's user agent outright (hard 400), and every thread-open attempt (`indiehackers.com`, `news.ycombinator.com`, `old.reddit.com`, `lobste.rs`) returned `EGRESS_BLOCKED`. So IH was searchable but not openable; Reddit was neither. Vote 3 check 3 requires a timestamp **read from the opened thread**, so with no venue openable every candidate fails by construction. IH search returned only threads already in `cold-prospect-list.md` plus suppliers; X's freshest result was 40 days old and off-ICP. Nothing was padded or inferred. Full attempt-by-attempt log in `p7-search-sweep.md` §6. +- **Unblock needed**: `chrome-devtools` (the tool grooming Vote 2 actually names) plus egress to `indiehackers.com` and `reddit.com`. Until one venue is openable, #29 cannot produce verified-fresh rows by **any** method. +- **#12 remains blocked** — still zero sendable rows, unchanged since 2026-07-26. **Batch-1 status (updated 2026-07-26, flat lead-recency rule)**: **ALL 5 messages HOLD — zero sendable rows.** Paul's rule "leads = up to 1 month, no exceptions" flipped the two survivors too: SpecBuildLab (~9.5mo) and Joy Adamson (~5mo, flagged as Paul's one override candidate — still publicly unanswered). `SEND-SHEET.md` carries per-message ⛔HOLD banners so no runner can send anything. Card #12 is **blocked on #29's replacement rows** (or Paul's explicit Joy override). @@ -100,9 +110,9 @@ Batch-1 Phase-1 pre-research (send-runner-prompt.md, run against card #12's 5 op ### ▶ Sprint 3 — Fresh-Leads Sprint (planned 2026-07-26) -Sequence (WIP=1): **#29** re-source v2 (re-audit all 25 v1 rows with verified timestamps; drop stale/saturated **as leads** while harvesting their VoC; source new ≤30-day rows; IH-first, expand via F5Bot v2 keywords + X if thin — F5Bot swap is Paul's ~2-min unblock and is now **load-bearing**, not optional) → **P9-delta** openers for the new rows only (reuse `outbound-openers.md` templates) → **#12** batch-1 send (first ≤30-day verified rows, Paul approves) → **#20** daily reply-monitor. Batch-2/3 cards (#19, #25) depend on #29 — no more sends from the unverified v1 list. +Sequence (WIP=1): **#29** re-source v2 (re-audit all 25 v1 rows with verified timestamps; drop stale/saturated **as leads** while harvesting their VoC; source new ≤30-day rows; IH-first, expand via the date-filtered search sweep + X if thin — **F5Bot retired 2026-08-08**, its expansion role now belongs to `prospects/p7-search-sweep.md`) → **P9-delta** openers for the new rows only (reuse `outbound-openers.md` templates) → **#12** batch-1 send (first ≤30-day verified rows, Paul approves) → **#20** daily reply-monitor. Batch-2/3 cards (#19, #25) depend on #29 — no more sends from the unverified v1 list. -- **Done**: P1 ✓, P2 ✓, P3 retired (0 rows — HN is dev-dominated, no founder voices; venue invalid for this ICP, do NOT re-run), P4 ✓, P5 ✓ (1 row — low-yield as predicted; don't re-run without a new seed), P6 ✓, P7 ⚠ (email verified 2026-07-24 BUT v1 keywords produced zero mentions — composed phrases that never occur verbatim; **Paul: swap in the v2 keyword set from `rescue-sprint/prospects/p7-f5bot.md`** (~2 min), then digests arrive via the pftg.sof forward), P8 ✓ (25 deduped rows), P9 ✓ (25 openers), T9 ✓ (objection bank), P10 ✓ (pipeline sheet scaffolded) +- **Done**: P1 ✓, P2 ✓, P3 retired (0 rows — HN is dev-dominated, no founder voices; venue invalid for this ICP, do NOT re-run), P4 ✓, P5 ✓ (1 row — low-yield as predicted; don't re-run without a new seed), P6 ✓, P7 ✗ **RETIRED 2026-08-08** (F5Bot dead — literal-substring matching + passive-only, so it can never backfill the ≤30-day window; replaced by `rescue-sprint/prospects/p7-search-sweep.md`, which shipped its method but produced 0 rows on 2026-08-08, blocked on tooling), P8 ✓ (25 deduped rows), P9 ✓ (25 openers), T9 ✓ (objection bank), P10 ✓ (pipeline sheet scaffolded) - **A2 confirmed**: 3-tier pricing ($2,500 triage / $7,500 rescue / $10K foundation reset) - **Next**: browser-agent send run, gated on Paul's approval. The runner prompt is `rescue-sprint/send-runner-prompt.md` (pre-research every prospect → GO/HOLD table → Paul approves → send → log to `rescue-sprint/pipeline.md`). Booking link confirmed live by Paul 2026-07-24. After first sends, run `rescue-sprint/reply-monitor-prompt.md` each morning (classify replies, draft responses from the objection bank, update pipeline — never auto-reply). - **Mail routing (2026-07-24)**: `pftg.sof@gmail.com` auto-forwards everything to Paul's main inbox (and archives locally under its label) — F5Bot digests and NeetoCal notifications arrive via the forward. The reply-monitor prompt additionally searches this account by query (`from:f5bot.com` / `neetocal`) as a backup. diff --git a/docs/projects/2607-vibe-code-rescue/rescue-sprint/prospects/RUN.md b/docs/projects/2607-vibe-code-rescue/rescue-sprint/prospects/RUN.md index ff7893388..9b6f8d8dc 100644 --- a/docs/projects/2607-vibe-code-rescue/rescue-sprint/prospects/RUN.md +++ b/docs/projects/2607-vibe-code-rescue/rescue-sprint/prospects/RUN.md @@ -1,6 +1,6 @@ # RUN — copy-paste prompts (one per backlog task) -Each prompt is self-contained. Paste ONE into a fresh session. Stage-1 tasks (P1-P7) are parallel-safe — run any/all at once. P8 needs any subset of P1-P7 that produced rows (P7/F5Bot is additive, never blocks); P9 needs P8 + price. Repo: the current checkout root (`git rev-parse --show-toplevel`). +Each prompt is self-contained. Paste ONE into a fresh session. Stage-1 tasks (P1-P7) are parallel-safe — run any/all at once. P8 needs any subset of P1-P7 that produced rows (P7's search sweep is additive, never blocks); P9 needs P8 + price. Repo: the current checkout root (`git rev-parse --show-toplevel`). **Wrapper (all P-tasks share this spine):** > Run task **{ID}** from `docs/projects/2607-vibe-code-rescue/backlog.md`. Read that task row + `rescue-sprint/t4-t5-grooming.md` Vote 3 (qualification) once, then do ONLY {ID}. Use its seeded URLs. Tools: `web_search` + `chrome-devtools` only (Reddit = excerpt-only, don't open thread bodies). Append rows + `## VoC` lines to `rescue-sprint/prospects/{FILE}`. Qualify hard: non-technical FOUNDER only — never a dev venting or a supplier advertising rescue; every why-ICP must be a verbatim quoted sentence + URL; log ZERO supplier/agency posts. Set {ID} status in `backlog.md` when done. Don't commit. @@ -21,7 +21,7 @@ Each prompt is self-contained. Paste ONE into a fresh session. Stage-1 tasks (P1 **P6** — Run task P6 (competitor comment threads) per the wrapper. Open the seeded competitor rescue-post comment sections, log founders self-identifying in comments only. File: `prospects/p6-competitor-comments.md`. Target ≥3 rows. -**P7** — Run task P7 (F5Bot). Log into F5Bot with Paul's account (creds passed in-session — NEVER write them to any file), add the seeded keywords scoped to Reddit+HN+Lobsters, pull the first JSON/RSS feed, log ICP matches to `prospects/p7-f5bot.md`. If not set up yet, mark Blocked-on-Paul and stop. +**P7** — Run task P7 (date-filtered search sweep) per the wrapper. **F5Bot is RETIRED (2026-08-08) — do not run it.** Read `prospects/p7-search-sweep.md` §2-§5, compute `after:{TODAY-30}`, run the §3 query bank, normalize + dedupe by thread URL against `cold-prospect-list.md` first, then open each surviving non-Reddit candidate once to fill `verified date` + `thread health`. **Reddit stays excerpt-only per the wrapper, and P7 lead rows require an opened-thread timestamp - so Reddit hits feed the excerpt queue and VoC only, never P7 lead rows.** Append rows + VoC to `prospects/p7-search-sweep.md`. Needs `chrome-devtools` + a search surface that honours `site:`/`after:` (sanity-check per §4). Pad nothing — a short verified list beats a padded one. ## Stage 2 — merge diff --git a/docs/projects/2607-vibe-code-rescue/rescue-sprint/prospects/p7-f5bot.md b/docs/projects/2607-vibe-code-rescue/rescue-sprint/prospects/p7-f5bot.md index 22f19441e..28fd86467 100644 --- a/docs/projects/2607-vibe-code-rescue/rescue-sprint/prospects/p7-f5bot.md +++ b/docs/projects/2607-vibe-code-rescue/rescue-sprint/prospects/p7-f5bot.md @@ -1,8 +1,12 @@ +> ⛔ **RETIRED 2026-08-08. Superseded by [`p7-search-sweep.md`](p7-search-sweep.md).** +> Two structural flaws, not fixable by better keywords: (1) F5Bot matches **literal substrings**, so composed phrases never fire - v1 matched 0 of our 25 hand-found posts; (2) it is **passive**, catching only mentions from setup-time forward, so it can never backfill the ≤30-day window card #29 needs on demand. +> Kept for the v2 keyword derivation (§Keywords v2), which the replacement doc reuses. Do not run the F5Bot setup steps below. + # P7 F5Bot setup + first pull - prospect rows + VoC > Output of backlog task P7. Qualify per t4-t5-grooming.md Vote 3. Every why-ICP must be a verbatim quoted sentence. > F5Bot account: logged in (Paul's credentials, NOT stored in any file). -## Setup status +## Setup status (HISTORICAL - state as of retirement, do not act on) | Item | Status | |------|--------| @@ -14,7 +18,7 @@ ### Keywords configured (scoped to Reddit + Hacker News + Lobsters) **v1 set — RETIRED 2026-07-25, zero mentions.** Root cause is mechanical, not bad luck: F5Bot matches literal substrings, and these were 3-5-word composed phrases (`vibe coded broke`, `Lovable app broken`, `Replit app broke`, `can't access my code`, `agency won't give code`) that never occur verbatim — they match 0 of the 25 posts we found by hand. Do not restore. (`dev shop ghosted` never fit the 5-keyword free cap.) -### Keywords v2 (Paul: replace all five in F5Bot with these) +### Keywords v2 (the derivation `p7-search-sweep.md` reuses - do NOT configure these in F5Bot) Derived from phrase counts in our OWN captured verbatim quotes (voice-of-customer.md + cold-prospect-list.md) — keywords founders literally type: @@ -26,21 +30,14 @@ Derived from phrase counts in our OWN captured verbatim quotes (voice-of-custome | 4 | `replit app` | "replit" 16× | Trigger 1 | | 5 | `dev shop` | Trigger-3 hostage lane — grooming proved it unfindable via open search, so passive monitoring is its only coverage | Trigger 3 | -### Free plan limitation -F5Bot's free tier: 5 keywords, email digest alerts only. RSS/JSON feed requires paid plan. Paul will receive email alerts when matches appear on Reddit, HN, or Lobsters. The first digest should arrive within 24-48 hours of keyword setup. - -### Next step when alerts arrive -When Paul receives the first F5Bot email digest: -1. Open the email and click through to each matching thread -2. Qualify per Vote 3 (non-technical founder, not dev venting, not supplier) -3. Log ICP matches to this file with verbatim quotes -4. Target ≥3 rows if any matched +### Free plan limitation (HISTORICAL) +F5Bot's free tier: 5 keywords, email digest alerts only. RSS/JSON feed requires paid plan. This constraint is part of why the tool was retired; it no longer applies to anything. ## Rows | handle/channel | source URL | trigger# | why-ICP (verbatim quote) | verdict | |---|---|---|---|---| -*(No rows yet - awaiting first F5Bot digest. Keywords are live and monitoring.)* +*(None. F5Bot was retired before any digest produced a match. New rows go to `p7-search-sweep.md`.)* ## VoC (verbatim founder lines - force-tag Push/Pull/Anxiety/Habit + speaker tag + URL) -*(Awaiting first digest results.)* +*(None - see above.)* diff --git a/docs/projects/2607-vibe-code-rescue/rescue-sprint/prospects/p7-search-sweep.md b/docs/projects/2607-vibe-code-rescue/rescue-sprint/prospects/p7-search-sweep.md new file mode 100644 index 000000000..784f1731e --- /dev/null +++ b/docs/projects/2607-vibe-code-rescue/rescue-sprint/prospects/p7-search-sweep.md @@ -0,0 +1,236 @@ +# P7 Date-filtered active search sweep - method + prospect rows + VoC + +> Replaces `p7-f5bot.md` (RETIRED 2026-08-08). Output of backlog card #29's sourcing lane. +> Qualify per `t4-t5-grooming.md` **Vote 3 v2** (5 checks). Every why-ICP must be a verbatim quoted sentence + URL. +> **Created**: 2026-08-08 | **Role**: the load-bearing expansion mechanism for the ≤30-day lead window. + +--- + +## 1. Why this replaces F5Bot + +F5Bot failed for two structural reasons, not bad luck or bad keywords: + +| F5Bot | Date-filtered active search | +|---|---| +| **Passive** - only catches mentions from setup-time forward. Cannot backfill. | **Active** - queries the index on demand, reaches backwards across whatever is indexed. | +| Matches **literal substrings** only. v1's composed phrases (`vibe coded broke`) matched 0 of our 25 hand-found posts. | Ranked retrieval - partial and reordered matches still surface. | +| Free tier caps at **5 keywords**, no RSS/JSON (paid only). | No keyword cap. Run as many query permutations as the window needs. | +| **Email round-trip** - digest arrives on F5Bot's schedule, into Paul's inbox, then needs a human forward. | Agent runs it inline. Zero human hop. | +| Recency is an accident of when you happened to set it up. | **Recency is a first-class filter** (`after:`), which is exactly what card #29's ≤30-day gate demands. | + +The decisive point: card #29 needs rows verified inside a ≤30-day window **on demand**. A passive monitor can never satisfy an on-demand backfill - it only knows what arrived after you turned it on. That mismatch is unfixable by better keywords. + +--- + +## 2. Date arithmetic - regenerating the window + +Google's `after:YYYY-MM-DD` is inclusive of the named date. Paul's rule is **leads = ≤30 days, no exceptions**. + +```text +WINDOW_START = TODAY - 30 days +``` + +Compute it, never eyeball it: + +```bash +date -u -d '30 days ago' +%Y-%m-%d # GNU/Linux +date -u -v-30d +%Y-%m-%d # macOS/BSD +``` + +Worked example for this run: + +| Anchor | Value | +|---|---| +| TODAY | `2026-08-08` | +| WINDOW_START (-30d) | `2026-07-09` → operator `after:2026-07-09` | +| Tighter sweep (-14d) | `2026-07-25` → `after:2026-07-25` | +| Daily-watch sweep (-7d) | `2026-08-01` → `after:2026-08-01` | + +Substitute `after:2026-07-09` wherever the bank below writes `after:{WINDOW_START}`. + +--- + +## 3. Copy-paste query bank + +**Keyword set** (v2, derived from phrase counts in our own captured corpus - `voice-of-customer.md` + `cold-prospect-list.md`, not invented): +`non-technical founder` · `vibe coded` · `lovable app` · `replit app` · `dev shop` · `bolt.new` · `cursor` · `base44` + +Run each as-is in a Google-operator-honouring search surface. Replace the date token first. + +### 3.1 Reddit - highest founder density (excerpt queue + VoC only, never lead rows) + +> Reddit is excerpt-only per the shared wrapper (`t4-t5-grooming.md` lane split), and a P7 lead row requires `verified date` read from the opened thread - so a Reddit candidate can never clear Vote 3 check 3 inside P7. Run these queries to feed the excerpt queue and VoC capture; Reddit leads are out of P7's scope. + +```text +site:reddit.com "non-technical founder" ("broke" OR "broken" OR "stuck") after:{WINDOW_START} +site:reddit.com "vibe coded" ("can't fix" OR "no idea how to fix") after:{WINDOW_START} +site:reddit.com "lovable app" ("broke" OR "broken" OR "help") after:{WINDOW_START} +site:reddit.com "replit app" ("broke" OR "deleted" OR "help") after:{WINDOW_START} +site:reddit.com "dev shop" ("ghosted" OR "won't give" OR "still owns") after:{WINDOW_START} +site:reddit.com ("bolt.new" OR "base44") ("stuck" OR "broke" OR "not technical") after:{WINDOW_START} +``` + +Sub-scoped variants (narrower, cleaner - run when the broad sweep floods): + +```text +site:reddit.com/r/replit "not technical" after:{WINDOW_START} +site:reddit.com/r/nocode "non-technical founder" after:{WINDOW_START} +site:reddit.com/r/vibecoding ("paid a developer" OR "hired someone") after:{WINDOW_START} +site:reddit.com/r/SaaS "vibe coded" ("broke" OR "rebuild") after:{WINDOW_START} +site:reddit.com/r/startups "non-technical founder" ("agency" OR "freelancer") after:{WINDOW_START} +site:reddit.com/r/Solopreneur ("lovable app" OR "replit app") after:{WINDOW_START} +site:reddit.com/r/founder "dev shop" after:{WINDOW_START} +``` + +### 3.2 IndieHackers - run-first venue per grooming (real handles, low outreach hostility) + +```text +site:indiehackers.com "non-technical founder" ("stuck" OR "broke" OR "broken") after:{WINDOW_START} +site:indiehackers.com "vibe coded" ("can't fix" OR "falling apart") after:{WINDOW_START} +site:indiehackers.com ("lovable app" OR "replit app" OR "bolt.new") ("broke" OR "stuck") after:{WINDOW_START} +site:indiehackers.com "dev shop" ("ghosted" OR "owns my code" OR "won't hand over") after:{WINDOW_START} +site:indiehackers.com "cursor" "not a developer" after:{WINDOW_START} +``` + +### 3.3 Hacker News + +```text +site:news.ycombinator.com "vibe coded" ("broke" OR "rewrite") after:{WINDOW_START} +site:news.ycombinator.com ("lovable app" OR "replit app" OR "base44") after:{WINDOW_START} +``` + +> HN caveat: P3 was retired at 0 rows (2026-07-22) - HN is dev-dominated and produced no founder voices. Run these for **VoC only**, not leads. + +### 3.4 X / Twitter + +```text +site:x.com "vibe coded" ("broke" OR "can't fix") after:{WINDOW_START} +site:x.com "non-technical founder" ("lovable" OR "replit" OR "bolt.new") after:{WINDOW_START} +site:x.com "dev shop" ghosted after:{WINDOW_START} +``` + +**X date shortcut**: a status URL's numeric ID encodes its timestamp, so you can verify a date without opening the page. Snowflake epoch is `1288834974657` ms: + +```bash +id=2070153296862728515 +date -u -d @$(( ((id >> 22) + 1288834974657) / 1000 )) +%Y-%m-%d # -> 2026-06-25 +``` + +This is the one venue where `verified date` can be derived deterministically. Everywhere else, open the thread. + +### 3.5 Lobsters + +```text +site:lobste.rs "vibe coded" after:{WINDOW_START} +``` + +Lowest-yield venue. Run last, or skip when time-boxed. + +### 3.6 Trigger 3 (ownership/hostage) - no search lane + +Grooming proved open keyword search for Trigger 3 returns ~9/10 competitor SEO articles, and no `site:` query reliably lands on competitor-article *comments* rather than an OP or an unrelated thread - logging an OP from such a query would violate Vote 3. This sweep therefore carries **no Trigger-3 lane**. Trigger-3 coverage stays with P6 (seeded competitor rescue-post comment sections, commenters only, never the OP). + +--- + +## 4. Honest limitations + +Read these before trusting a zero-result sweep. + +1. **Index lag.** Google may not index a post for hours to days. A genuinely fresh distress post can be invisible to `after:` on the day it matters most. A zero-result sweep means "not indexed yet or not there" - it never means "nothing happened." +2. **`after:` is unreliable per-site.** The operator filters on Google's *inferred* document date. Reddit and X pages are frequently re-crawled and re-dated, and IH listing pages carry no stable date, so `after:` silently drops valid results and admits stale ones. Treat it as a **noise reducer, never as the verification**. Vote 3 check 3 still requires a timestamp read from the opened thread. +3. **Reddit is excerpt-only** per existing policy (`t4-t5-grooming.md` lane split), and P7 does not open Reddit threads - which means Reddit candidates can never fill `verified date` + `thread health` inside P7 and are **excluded from P7 lead rows by rule** (see §3.1). Capture sub + handle + URL + the excerpt quote for the excerpt queue and VoC only. +4. **Search surfaces vary in operator support.** Some tools accept `site:`/`after:` as plain text and quietly ignore them, returning semantically similar SEO articles instead - see §6 for exactly this failure. Sanity-check every sweep: if results contain off-domain marketing pages, your operators are being dropped and the sweep is invalid. +5. **Competitor SEO dominates the rescue keywords.** Six rescue shops own this vocabulary. Any result that reads like an article rather than a person is noise by default. +6. **`site:` does not reach login-walled content.** Whatever needs auth stays invisible. That is a permanent coverage hole, not a tuning problem. + +--- + +## 5. Cadence + dedupe + +**Cadence: weekly, every Monday.** Rationale, not a round number: + +- The lead window is 30 days. A weekly run means any given post is caught with ~23 days of usable life left - enough for opener drafting, Paul's approval, and the send. +- Weekly also absorbs limitation #1: a post missed for index lag on run *N* is caught on run *N+1*, still well inside the window. +- Use `after:{TODAY-7}` for the Monday sweep once the pipeline is steady-state. Use the full `after:{TODAY-30}` only on a cold start or after a skipped week. +- Do not run daily. Index lag means daily runs mostly re-return the same rows, and the venue-politeness cap from grooming (batch of 8-10, don't hammer one domain) still applies. + +**Dedupe - the normalized thread URL is the join key** (same rule as P8): + +1. Normalize the candidate URL FIRST - strip `?utm_*` params and trailing slashes, and rewrite `old.reddit.com` to `www.reddit.com`. The normalized form is what you grep for and what you log. +2. Then grep the merged list and every sibling file for it: + ```bash + cd docs/projects/2607-vibe-code-rescue/rescue-sprint + rg -F "" cold-prospect-list.md prospects/ + ``` +3. A hit anywhere = skip the row. Do not re-log under a different handle. +4. Keep a `### Seen-and-skipped` list in this file with the reason (`already row 14` / `stale, VoC only` / `supplier`) so the next sweep does not re-adjudicate the same URL from scratch. +5. Same thread, genuinely different commenter = a separate row, per P8's existing rule. Same person twice = one row. + +--- + +## 6. Run log - 2026-08-08 (first execution) + +**Result: ZERO qualified lead rows. The sweep could not be executed as designed in this environment.** + +This is a tooling failure, not a finding about the market. Recorded in full so the next session does not repeat it. + +### What was tried + +| # | Attempt | Outcome | +|---|---|---| +| 1 | `site:reddit.com "non-technical founder" app broke after:2026-07-09` | Operators **ignored**. Returned Substack essays, a university events page, Goodreads. Zero reddit.com results. | +| 2 | `site:indiehackers.com "non-technical founder" stuck app after:2026-07-09` | Partial - IH URLs returned, but `after:` had no effect; results included posts dated 2025-07 and 2026-03. | +| 3 | Search restricted to `reddit.com` via the tool's native domain filter | **Hard 400**: "The following domains are not accessible to our user agent: ['reddit.com']". Reddit is unreachable by search here, not merely excerpt-limited. | +| 4 | Search restricted to `indiehackers.com` (3 query variants, founder-voice phrasing) | Returned only threads **already in `cold-prospect-list.md`** (`looking-to-help-non-technical-founders-6e1d9b33ae` = rows 15-18; `i-am-frustrated-with-no-code-i-am-done-14a7ce60cb` = row 21) plus supplier/agency posts and generic advice articles. Zero new founder threads. | +| 5 | Search restricted to `x.com` | Returned 9 results. Dates derived via the §3.4 snowflake method: newest was **2026-06-29 (40 days old)**; the rest ranged 2025-03 to 2026-06. **Every result was a supplier, agency, or commentator**, not an ICP founder. Zero rows. | +| 6 | `WebFetch` on `indiehackers.com`, `news.ycombinator.com`, `old.reddit.com`, `lobste.rs`, and one unrelated marketing domain | **All `EGRESS_BLOCKED`** by the network proxy. | +| 7 | `curl "$HTTPS_PROXY/__agentproxy/status"` | Proxy healthy, `recentRelayFailures: []`. The blocks are a deliberate egress allowlist, not a transient fault. | + +### Why zero rows is the only defensible output + +Vote 3 check 3 requires the **actual post timestamp read from the opened thread, never inferred from a search excerpt** - that check exists specifically because batch-1's 60% failure came from eyeballing recency off excerpts. In this environment **no thread of any venue can be opened** (attempt 6). Every candidate therefore fails check 3 by construction. + +Even setting that aside, the sweep surfaced no new ICP candidates: attempt 4 returned only known threads and suppliers, and attempt 5's freshest item was 40 days old and off-ICP. Logging anything here would mean either (a) inventing a date, or (b) re-logging a row already in the merged list. Both are the exact defects card #28 was filed to stop. + +### What the next session needs + +The method in §1-5 is sound and unchanged - it needs a runner with the tools grooming already specifies: + +- **`chrome-devtools`** (`new_page` / `take_snapshot` / `close_page`) - the tool Vote 2 and the grooming tool list actually name. It is what makes check 3 and check 5 possible. It was not available here. +- **A search surface that honours `site:` and `after:`** - verify with the §4 sanity check before trusting any sweep. +- Egress access to `indiehackers.com` and `reddit.com` at minimum. + +Until at least one venue is openable, card #29 cannot produce verified-fresh rows by any method - this is a **blocked-on-tooling** state, not a "search harder" state. F5Bot's retirement is still correct: it was never going to deliver on-demand backfill either. + +--- + +## Rows + +| handle/channel | source URL | verified date | post\|comment | trigger# | why-ICP (verbatim quote) | verdict | thread health | best path | +|---|---|---|---|---|---|---|---|---| + +*(No rows. Zero candidates cleared Vote 3 - see §6. Nothing was padded, inferred, or carried over from the v1 list.)* + +### Seen-and-skipped (do not re-adjudicate) + +| URL | Reason | +|---|---| +| indiehackers.com/post/looking-to-help-non-technical-founders-6e1d9b33ae | Already rows 15-18 in `cold-prospect-list.md` | +| indiehackers.com/post/i-am-frustrated-with-no-code-i-am-done-14a7ce60cb | Already row 21 | +| indiehackers.com/post/agency-offering-backend-full-stack-dev-for-non-technical-founders-... | **Supplier** - agency advertising. Never log. | +| indiehackers.com/post/i-review-ai-built-codebases-for-a-living-heres-what-s-actually-inside-them-... | **Supplier** - competitor rescue offer. Mine commenters only (P6 lane). | +| indiehackers.com/post/from-prototype-to-production-how-replit-lovable-bolt-and-v0-power-your-mvp-... | **Supplier** - Vibeship vendor post. | +| x.com/PrajwalTomar_/status/2070153296862728515 | Supplier (agency, "60+ apps at the agency"). Derived date 2026-06-25 - outside window anyway. | +| x.com/leojr94_/status/1970481417009443229 | Supplier/acquirer ("we are taking over"). Derived date 2025-09-23. | +| x.com/GergelyOrosz/status/1946783581570736362 | Commentator, not founder. Derived date 2025-07-20. | + +## VoC (verbatim founder lines - force-tag Push/Pull/Anxiety/Habit + speaker tag + URL) + +**Zero `[VERBATIM-founder]` lines captured this run.** VoC has no age limit, so a stale thread would still have been harvested - but no thread could be opened, and the verbatim rule ("word-for-word, zero paraphrase") cannot be met from a search-result summary. + +The following surfaced as quoted strings in search-result titles. They are **not admitted to `voice-of-customer.md`** - all are non-founder speakers, and none is confirmed word-for-word against the source page. Listed only so the next session does not re-derive them: + +- `[UNVERIFIED - VERBATIM-dev/team]` "Most people are not Vibe Coding. They are panic prompting. They open Cursor, paste a vague idea, accept every diff, break the app, prompt harder, create a worse bug, then blame the model." - Ziwen (commentator), https://x.com/ziwenxu_/status/2057165083180929140 (derived date 2026-05-20) +- `[UNVERIFIED - VERBATIM-dev/team]` "Shipping the first working version as if it is production-ready is." - Louis-Francois Bouchard (commentator), https://x.com/Whats_AI/status/2071579616943239251 (derived date 2026-06-29) + +Neither is promotable to the phrase bank - the bank is built **only** from `[VERBATIM-founder]` lines.