bible270

A mountable Rails engine that adds a social, 270-day Bible reading plan to a host Rails application such as ComfortableMediaSurfer.

Each day presents three readings, sized by verse count so daily portions take roughly the same time to read. Chapters are kept whole — the plan never chops a chapter into verse ranges except for the one excessively long chapter, Psalm 119.

  • Old Testament — read once, cover to cover. Whole chapters grouped so each day is ~equal in verses (avg ~73/day, range 38–116).
  • New Testament — read twice. Each pass gets half the plan (135 days), so Revelation lands on day 135 and again on day 270. ~2 chapters/day, every day, verse-balanced within each pass.
  • Psalms & Proverbs — a whole-chapter companion that runs twice over the 270 days (135 portions x 2). Very short chapters merge with a neighbour so there are no trivial two-verse days (Psalm 117 reads as "Psalm 116–117"), and Psalm 119 (176 verses) is the sole chapter divided — into exactly two readings, 1–88 and 89–176.

Genesis→Malachi, the second pass through Revelation, and Proverbs 31 all finish together on day 270. A typical day runs ~157 verses across the three tracks (range 102–238).

Readers check off each track, leave reflections (comments) on any day, and can see everyone else's progress and reflections — similar to the social plans on Bible.com.

The plan itself is pure, deterministic Ruby (Bible270::Plan) — no rows are stored for the schedule. Daily portions are sized using per-chapter verse counts (Bible270::Versification, standard English/KJV versification; verse counts are facts about the text's structure, so nothing copyrighted is bundled). The database only holds readers, check-offs, and comments.

Requirements & compatibility

  • Ruby >= 3.0 (the gemspec's required_ruby_version). The plan logic is plain Ruby with no C extensions and no dependency on any gem that Ruby 4.0 unbundled. Installs and loads under Ruby 4.0.6; the test suite is exercised on 3.2 and passes with --enable-frozen-string-literal forced.
  • Ruby 4.0 notes. Audited against the 4.0 breaking changes: the gem does not use cgi (removed from default gems in 4.0 — URL escaping goes through URI.encode_www_form_component instead, which is byte-identical to CGI.escape), Set/SortedSet, Ractor, Net::HTTP, Process::Status, ObjectSpace, or any of the gems promoted from default to bundled (ostruct, logger, benchmark, pstore, irb, rdoc). Every file carries a # frozen_string_literal: true magic comment and no string literal is mutated.
  • Rails. The gemspec allows rails >= 7.0, but on Ruby 4.0 the binding constraint is your host app, not this engine: Rails 8.0/8.1 require Ruby >= 3.2, and ComfortableMediaSurfer is a "Rails 7.0+" engine. Verify your own lockfile (e.g. with RailsBump) before pairing an older Rails with Ruby 4.0.
  • A host application (this is an engine, not a standalone app).
  • Turbo is used for seamless check-offs and degrades to full-page redirects if absent.

Install

Add to the host app's Gemfile:

gem "bible270"
Other sources (unreleased changes, local development)
# straight from the repository
gem "bible270", git: "https://github.com/avonderluft/bible270.git", branch: "main"

# a local checkout you're editing
gem "bible270", path: "../bible270"

Then install, copy the migrations, and migrate:

bundle install
bin/rails bible270:install:migrations
bin/rails db:migrate

install:migrations copies four migrations into your db/migrate (readers, check-offs, comments, sign-in tokens). They belong to your application from then on: they get your timestamps, appear in your schema.rb, and are yours to run, roll back, or edit. The engine does not add its own migration directory to your app's paths, so the copies are the only definitions in play.

Mount it in config/routes.rb:

mount Bible270::Engine, at: "/reading-plan"

Optionally generate the initializers (see Authentication):

bin/rails generate bible270:install --mount-at=/reading-plan --providers=github

The plan is now live at /reading-plan.

Upgrading

When a new version adds a migration, re-run the copy step — already-copied migrations are skipped:

bundle update bible270
bin/rails bible270:install:migrations
bin/rails db:migrate

Authentication

Two built-in ways to sign in, so nobody is excluded:

  1. Email link (passwordless) — the reader types their address, gets a one-time link, and clicks it. No password, no account with anyone else. This is the default and it's what makes the plan usable by people who don't have (or don't want to use) GitHub, Google, and friends.
  2. OmniAuth social sign-in — optional convenience for those who do.

Either can run alone. config.omniauth_providers = [] gives you an email-only site; config.email_sign_in = false gives you social-only. Viewing the plan and reading others' reflections stays public either way — only checking off and commenting require signing in.

Email sign-in

Bible270.configure do |config|
  config. = true
  config.mailer_from   = "no-reply@gknt.org"
  config. = true       # reader picks their display name
  # config.email_sign_in_ttl = 20 * 60       # link lifetime (seconds)
  # config.email_sign_in_max_per_window = 5  # per address, per window
  # config.email_sign_in_window = 15 * 60
  # config.email_sign_in_deliver_later = true  # needs an Active Job backend
end

The host app needs working Action Mailer delivery — nothing else. How it behaves:

  • Tokens are 256-bit, URL-safe, single-use, and expire (20 minutes by default).
  • Only a SHA-256 digest of the token is stored, so the table is useless to anyone who reads the database. There is no password column anywhere in this gem.
  • Consuming a link is a conditional update, so a double-clicked link can't sign in twice.
  • The "check your inbox" response is identical whether the address was known, unknown, or rate-limited — no account enumeration and no hint that a limit was hit.
  • Readers may set the display name shown beside their reflections; otherwise it's derived from the address (mary.anne.smith@… → "Mary Anne Smith").
  • Spent and stale tokens can be cleaned up with Bible270::SignInToken.sweep! from a cron/rake task.

An email reader gets provider: "email" in the same identity columns OmniAuth uses, so downstream (progress, comments, leaderboard) treats every reader identically.

OmniAuth social sign-in

Quick setup

bin/rails generate bible270:install --mount-at=/reading-plan --providers=github

That writes config/initializers/bible270.rb (email sign-in enabled) and config/initializers/omniauth.rb (with path_prefix already matching your mount point), and adds the mount line to your routes. Pass --providers= with an empty value for an email-only site. Then add the gems you need and migrate:

# Gemfile — omniauth and omniauth-rails_csrf_protection come in with bible270;
# the provider strategy is your choice:
gem "omniauth-github"
bundle install
bin/rails bible270:install:migrations && bin/rails db:migrate

Finally register the callback URL with the provider:

https://gknt.org/reading-plan/auth/github/callback

Manual setup

If you'd rather not use the generator, the only subtlety is that OmniAuth's path_prefix must line up with where the engine is mounted, so its callback lands on the engine's route:

# config/initializers/omniauth.rb
Rails.application.config.middleware.use OmniAuth::Builder do
  path_prefix "/reading-plan/auth"          # == <mount point>/auth
  provider :github, ENV["GITHUB_CLIENT_ID"], ENV["GITHUB_CLIENT_SECRET"]
end

OmniAuth.config.on_failure = proc { |env| Bible270::SessionsController.action(:failure).call(env) }
# config/initializers/bible270.rb
Bible270.configure do |config|
  config.omniauth_providers = [:github]              # or [:github, [:google_oauth2, "Google"]]
  config.parent_controller  = "::ApplicationController"
end

Notes worth knowing

  • Sign-in is a POST, not a link. OmniAuth 2.0+ refuses GET on its request phase (CVE-2015-9284), so every sign-in control the engine renders is a button_to carrying a CSRF token, and omniauth-rails_csrf_protection provides the Rails-aware verifier. If you build your own sign-in link, it must POST to <mount>/auth/:provider.
  • One provider vs several. With a single configured provider the header shows a direct "Sign in with X" button; with several it links to a sign-in page (GET <mount>/sign_in) listing them all.
  • Return-to-origin. Sign-in controls pass the current path as origin, so a reader who clicks a check-off while signed out lands back on that same day afterwards. Origins are validated to be local paths only.
  • Session hygiene. reset_session runs on both sign-in and sign-out to avoid session fixation. Sign-out is DELETE <mount>/sign_out.
  • What's stored. Provider, uid, display name, email, and avatar URL — no tokens, no passwords.

Alternative: bridge your host's own users

If the host app already has authentication and you'd rather not use OmniAuth at all, set a resolver instead and the engine will never touch sessions:

config.current_reader_resolver = lambda do |controller|
  user = controller.send(:current_user)
  next nil unless user
  Bible270::Reader.for_owner(user, display_name: user.try(:name) || user.email,
                             email: user.try(:email), avatar_url: user.try(:avatar_url))
end

Reader.for_owner links a reader to any host model polymorphically via owner. Note that ComfortableMediaSurfer's admin authentication is separate from public site visitors, so for a public plan on a CMS front-end the OmniAuth path above is usually the right fit.

Start dates

The plan works either undated (day numbers only) or dated (day numbers mapped onto a calendar). There are two levers:

Bible270.configure do |config|
  # A community-wide start date — everyone reads together as a cohort.
  # Accepts a Date, Time, or "YYYY-MM-DD" string. Default: nil (undated).
  config.start_date = Date.new(2026, 9, 6)

  # May an individual reader set/change their own start date? Default: true.
  # Set false to pin everyone to config.start_date.
  config.allow_reader_start_date = true
end

How the two combine:

start_date allow_reader_start_date Behaviour
nil true (default) Each reader is stamped with their own start date the first time they check something off, and can change it.
a date true The community date is the default; any reader may override it with their own.
a date false Everyone is pinned to the community date. The per-reader form is hidden.
nil false Fully undated — day numbers only, no calendar anywhere.

When a plan is dated, each day page shows its calendar date with a Today badge, the overview gains a "Go to today" link, and readers see whether they're ahead of or behind the pace.

Readers manage their own date from the overview page (PATCH /start-date, DELETE /start-date). Changing a start date only re-maps days onto the calendar — check-offs and reflections are keyed to day numbers and are never touched.

The date helpers are pure functions on Bible270::Plan, so you can use them anywhere:

Bible270::Plan.date_for(1, "2026-09-06")        # => Sun, 06 Sep 2026
Bible270::Plan.end_date_for("2026-09-06")       # => Wed, 02 Jun 2027  (day 270)
Bible270::Plan.day_for(Date.current, start)     # => 42   (clamped to 1..270)
Bible270::Plan.day_for(date, start, clamp: false) # => -3 or 271, to detect out-of-range
Bible270::Plan.before_start?(date, start)       # => true/false

Configuration reference

Bible270.configure do |config|
  config.app_name = "Daily Bread"
  config.tagline  = "A 270-day journey through Scripture"

  config.start_date = nil                  # community start date, or nil for undated
  config.allow_reader_start_date = true    # may readers set their own?

  config.parent_controller = "ActionController::Base"      # or "::ApplicationController"
  config.layout            = "bible270/application"

  config. = true                              # passwordless email link
  config.mailer_from = "no-reply@example.com"
  config. = 20 * 60
  config. = true
  config.omniauth_providers = [:github]                    # optional social sign-in
  config.omniauth_path_prefix = nil                        # nil = derive "<mount>/auth"
  config.current_reader_resolver = nil                     # set to bridge host users instead
  config. = true

  config.  = nil                         # defaults to plan root
  config.after_sign_out_path = nil

  config.bible_version = "ESV"
  config.passage_url_builder = ->(reference, version) {
    "https://www.biblegateway.com/passage/?search=#{URI.encode_www_form_component(reference)}&version=#{version}"
  }
end

Every reading reference links out to the configured Bible reader (Bible Gateway by default). Point passage_url_builder at your own reader if you host one.


Using it inside ComfortableMediaSurfer

ComfortableMediaSurfer serves marketing/content pages; this engine is a separate mounted app. Two integration styles:

  1. Link to it — mount at /reading-plan and add a CMS navigation link. The engine renders its own themed pages. Simplest and fully featured.

  2. Match the site chrome — set config.parent_controller = "::ApplicationController" and config.layout = "layouts/application" (your site layout) so the plan pages sit inside your normal header/footer. The engine's scoped CSS (all classes are prefixed b270-) won't collide with CMS styles.

Because CMS admin auth (ComfortableMediaSurfer) is separate from public readers, Option B (OmniAuth) is usually the right fit for public visitors on gknt.org.


Data model

Table Purpose
bible270_readers identity: display name, avatar, provider/uid or polymorphic owner, started_on
bible270_checkoffs one row per reader per day per track (ot/nt/pp); unique index prevents dups
bible270_sign_in_tokens short-lived magic-link tokens: email, digest only, expiry, consumed-at
bible270_comments a reflection on a day (optionally scoped to a track), public to all

A day counts as "complete" for a reader when they've checked every track that has content that day (2 on a light-NT day, otherwise 3). All progress and comments are public to other readers by design (mirroring Bible.com's shared plans).

Routes (within the mount point)

GET    /                         days#index      overview + community + calendar
GET    /day/:day                 days#show       three readings, completers, reflections
POST   /day/:day/toggle/:track   checkoffs#toggle
POST   /day/:day/comments        comments#create
DELETE /comments/:id             comments#destroy
PATCH  /start-date               readers#update_start_date
DELETE /start-date               readers#clear_start_date
GET    /community                readers#index   leaderboard
GET    /readers/:id              readers#show    a reader's progress + reflections
GET    /sign_in                  sessions#new     email form + provider list
POST   /sign_in/email            sessions#email_link      send a magic link
GET    /sign_in/email/:token     sessions#email_callback  consume a magic link
GET    /auth/:provider/callback  sessions#create  (OmniAuth callback)
POST   /auth/:provider/callback  sessions#create
GET    /auth/failure             sessions#failure
DELETE /sign_out                 sessions#destroy

POST   <mount>/auth/:provider    handled by the OmniAuth middleware, not the engine

Tuning the plan shape

The whole schedule is derived from a handful of constants in Bible270::Plan, so the shape can be changed without touching anything else:

Constant Default Effect
DAYS 270 plan length
NT_PASSES 2 how many times the New Testament is read (each pass gets DAYS / NT_PASSES days)
PP_LONG_CHAPTER 100 a chapter longer than this is divided. At 100 only Psalm 119 (176v) qualifies, since the next longest is Psalm 78 at 72v. Raise it above 176 to never split anything
PP_SPLIT_PARTS 2 how many readings such a chapter becomes
PP_MIN_DAY / PP_DAY_TARGET 12 / 22 how aggressively short chapters merge. These two values are what make the companion come out to exactly 135 portions, i.e. two clean passes across 270 days

Everything is computed at load time and memoized; there is no generated schedule to migrate if you change these.

Scaling note

The community leaderboard computes completed-days in Ruby from grouped check-off counts. That's ideal for a homelab/parish-sized community (hundreds of readers). If you grow to many thousands, add a cached days_completed counter on Reader (updated in a Checkoff after_commit) and sort in SQL.

Troubleshooting

ActiveRecord::DuplicateMigrationNameError: Multiple migrations have the name CreateBible270Readers

You're on 0.6.2 or earlier, where the engine wrongly added its own db/migrate to the host app's migration paths as well as providing install:migrations — so copying them defined every migration class twice. Upgrade to >= 0.6.3 and keep your copied migrations:

bundle update bible270
bin/rails db:migrate

Sign-in returns 404 on the provider callback

OmniAuth's path_prefix doesn't match where the engine is mounted. They have to line up: mounting at /reading-plan means path_prefix "/reading-plan/auth", and the URL registered with the provider must be https://YOUR-HOST/reading-plan/auth/github/callback. The install generator writes both consistently.

Clicking "Sign in with …" does nothing, or raises OmniAuth::AuthenticityError

The request has to be a POST with a CSRF token — OmniAuth 2.0+ refuses GET (CVE-2015-9284). The engine's own controls are already POST forms; if you've built your own link, convert it to button_to. Also confirm omniauth-rails_csrf_protection is in the bundle.

No sign-in email arrives

Email sign-in needs working Action Mailer delivery in the host app — the engine only calls deliver_now (or deliver_later). Check config.mailer_from is a real address your relay will accept, and watch the logs: delivery errors surface there. Nothing about a failure is shown to the reader, deliberately, since the "check your inbox" message is identical either way to avoid disclosing which addresses exist.

"That link has expired or was already used"

Links are single-use and live for email_sign_in_ttl (20 minutes by default). Some mail scanners and link-preview services fetch URLs before the recipient clicks, which consumes the token. If you see this a lot, lengthen the TTL or check whether something upstream is prefetching links.

Everything renders unstyled inside my layout

The engine ships scoped CSS in a partial rendered by its own layout. If you point config.layout at your application layout, add <%= render "bible270/shared/styles" %> to that layout's <head>, or style the b270-* classes yourself.

Development / tests

The deterministic plan logic is fully unit-tested with no Rails dependency:

bundle install
rake test

License

MIT. See MIT-LICENSE.