From fd41f01d1584c704e633b1fe06acccdd1d13a410 Mon Sep 17 00:00:00 2001 From: Kyle Fritz Date: Mon, 13 Jul 2026 23:45:41 -0400 Subject: [PATCH 1/6] Upgrade rails gem 7.2.3.1 -> 8.0.5 bundle update rails --conservative resolved cleanly; no transitive gem bumps were needed. Framework defaults stay at 7.2 for now. Co-Authored-By: Claude Fable 5 --- Gemfile | 2 +- Gemfile.lock | 117 +++++++++++++++++++++++++-------------------------- 2 files changed, 58 insertions(+), 61 deletions(-) diff --git a/Gemfile b/Gemfile index 48dfc9b8..aecb4db9 100644 --- a/Gemfile +++ b/Gemfile @@ -37,7 +37,7 @@ gem "pg", ">= 0.18", "< 2.0" gem "progress_bar" gem "puma", "~> 7.0" # web/app server gem "rack-attack" # block scanner traffic / rate limit -gem "rails", "~> 7.2.0" +gem "rails", "~> 8.0.3" gem "rails-settings-cached" # site-wide settings gem "redcarpet" # markdown the baker's note in admin gem "sass-rails", "~> 6" # css diff --git a/Gemfile.lock b/Gemfile.lock index 4d4ebe93..623b3f44 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,49 +1,46 @@ GEM remote: https://rubygems.org/ specs: - actioncable (7.2.3.1) - actionpack (= 7.2.3.1) - activesupport (= 7.2.3.1) + actioncable (8.0.5) + actionpack (= 8.0.5) + activesupport (= 8.0.5) nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) - actionmailbox (7.2.3.1) - actionpack (= 7.2.3.1) - activejob (= 7.2.3.1) - activerecord (= 7.2.3.1) - activestorage (= 7.2.3.1) - activesupport (= 7.2.3.1) + actionmailbox (8.0.5) + actionpack (= 8.0.5) + activejob (= 8.0.5) + activerecord (= 8.0.5) + activestorage (= 8.0.5) + activesupport (= 8.0.5) mail (>= 2.8.0) - actionmailer (7.2.3.1) - actionpack (= 7.2.3.1) - actionview (= 7.2.3.1) - activejob (= 7.2.3.1) - activesupport (= 7.2.3.1) + actionmailer (8.0.5) + actionpack (= 8.0.5) + actionview (= 8.0.5) + activejob (= 8.0.5) + activesupport (= 8.0.5) mail (>= 2.8.0) rails-dom-testing (~> 2.2) - actionpack (7.2.3.1) - actionview (= 7.2.3.1) - activesupport (= 7.2.3.1) - cgi + actionpack (8.0.5) + actionview (= 8.0.5) + activesupport (= 8.0.5) nokogiri (>= 1.8.5) - racc - rack (>= 2.2.4, < 3.3) + rack (>= 2.2.4) rack-session (>= 1.0.1) rack-test (>= 0.6.3) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) useragent (~> 0.16) - actiontext (7.2.3.1) - actionpack (= 7.2.3.1) - activerecord (= 7.2.3.1) - activestorage (= 7.2.3.1) - activesupport (= 7.2.3.1) + actiontext (8.0.5) + actionpack (= 8.0.5) + activerecord (= 8.0.5) + activestorage (= 8.0.5) + activesupport (= 8.0.5) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (7.2.3.1) - activesupport (= 7.2.3.1) + actionview (8.0.5) + activesupport (= 8.0.5) builder (~> 3.1) - cgi erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) @@ -57,22 +54,22 @@ GEM kaminari (>= 1.2.1) railties (>= 6.1) ransack (>= 4.0) - activejob (7.2.3.1) - activesupport (= 7.2.3.1) + activejob (8.0.5) + activesupport (= 8.0.5) globalid (>= 0.3.6) - activemodel (7.2.3.1) - activesupport (= 7.2.3.1) - activerecord (7.2.3.1) - activemodel (= 7.2.3.1) - activesupport (= 7.2.3.1) + activemodel (8.0.5) + activesupport (= 8.0.5) + activerecord (8.0.5) + activemodel (= 8.0.5) + activesupport (= 8.0.5) timeout (>= 0.4.0) - activestorage (7.2.3.1) - actionpack (= 7.2.3.1) - activejob (= 7.2.3.1) - activerecord (= 7.2.3.1) - activesupport (= 7.2.3.1) + activestorage (8.0.5) + actionpack (= 8.0.5) + activejob (= 8.0.5) + activerecord (= 8.0.5) + activesupport (= 8.0.5) marcel (~> 1.0) - activesupport (7.2.3.1) + activesupport (8.0.5) base64 benchmark (>= 0.3) bigdecimal @@ -81,9 +78,10 @@ GEM drb i18n (>= 1.6, < 2) logger (>= 1.4.2) - minitest (>= 5.1, < 6) + minitest (>= 5.1) securerandom (>= 0.3) tzinfo (~> 2.0, >= 2.0.5) + uri (>= 0.13.1) addressable (2.9.0) public_suffix (>= 2.0.2, < 8.0) ahoy_email (1.1.1) @@ -437,20 +435,20 @@ GEM rack (>= 1.3) rackup (2.3.1) rack (>= 3) - rails (7.2.3.1) - actioncable (= 7.2.3.1) - actionmailbox (= 7.2.3.1) - actionmailer (= 7.2.3.1) - actionpack (= 7.2.3.1) - actiontext (= 7.2.3.1) - actionview (= 7.2.3.1) - activejob (= 7.2.3.1) - activemodel (= 7.2.3.1) - activerecord (= 7.2.3.1) - activestorage (= 7.2.3.1) - activesupport (= 7.2.3.1) + rails (8.0.5) + actioncable (= 8.0.5) + actionmailbox (= 8.0.5) + actionmailer (= 8.0.5) + actionpack (= 8.0.5) + actiontext (= 8.0.5) + actionview (= 8.0.5) + activejob (= 8.0.5) + activemodel (= 8.0.5) + activerecord (= 8.0.5) + activestorage (= 8.0.5) + activesupport (= 8.0.5) bundler (>= 1.15.0) - railties (= 7.2.3.1) + railties (= 8.0.5) rails-dom-testing (2.3.0) activesupport (>= 5.0.0) minitest @@ -461,10 +459,9 @@ GEM rails-settings-cached (2.9.6) activerecord (>= 5.0.0) railties (>= 5.0.0) - railties (7.2.3.1) - actionpack (= 7.2.3.1) - activesupport (= 7.2.3.1) - cgi + railties (8.0.5) + actionpack (= 8.0.5) + activesupport (= 8.0.5) irb (~> 1.13) rackup (>= 1.0.0) rake (>= 12.2) @@ -706,7 +703,7 @@ DEPENDENCIES puma (~> 7.0) rack-attack rack-mini-profiler (~> 4.0) - rails (~> 7.2.0) + rails (~> 8.0.3) rails-settings-cached rcodetools redcarpet From ff67cc2aa520710ffdc8733d568df4b61f3de856 Mon Sep 17 00:00:00 2001 From: Kyle Fritz Date: Mon, 13 Jul 2026 23:55:27 -0400 Subject: [PATCH 2/6] Run bin/rails app:update for Rails 8.0 (keep 7.2 defaults) Accepted the new defaults where we had no customizations (puma.rb, CSP and inflections initializers), merged the expanded parameter-filter list with our :uid entry, and kept the rest of our customized files (bin scripts, application.rb, environment configs, error pages). Includes the Rails-generated Active Storage migration dropping the NOT NULL on blobs.checksum, the regenerated schema.rb (autocorrected to omakase array style), the new bin/rubocop binstub, and the commented-out new_framework_defaults_8_0.rb for the defaults flip. config.load_defaults stays at 7.2 for this commit. Co-Authored-By: Claude Fable 5 --- bin/rubocop | 8 +++ .../initializers/content_security_policy.rb | 47 ++++++------- .../initializers/filter_parameter_logging.rb | 6 +- config/initializers/inflections.rb | 8 +-- .../new_framework_defaults_8_0.rb | 30 ++++++++ config/puma.rb | 68 +++++++++---------- ...e_storage_blobs_checksum.active_storage.rb | 8 +++ db/schema.rb | 6 +- 8 files changed, 111 insertions(+), 70 deletions(-) create mode 100755 bin/rubocop create mode 100644 config/initializers/new_framework_defaults_8_0.rb create mode 100644 db/migrate/20260714034616_remove_not_null_on_active_storage_blobs_checksum.active_storage.rb diff --git a/bin/rubocop b/bin/rubocop new file mode 100755 index 00000000..40330c0f --- /dev/null +++ b/bin/rubocop @@ -0,0 +1,8 @@ +#!/usr/bin/env ruby +require "rubygems" +require "bundler/setup" + +# explicit rubocop config increases performance slightly while avoiding config confusion. +ARGV.unshift("--config", File.expand_path("../.rubocop.yml", __dir__)) + +load Gem.bin_path("rubocop", "rubocop") diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb index 35d0f26f..b3076b38 100644 --- a/config/initializers/content_security_policy.rb +++ b/config/initializers/content_security_policy.rb @@ -1,30 +1,25 @@ # Be sure to restart your server when you modify this file. -# Define an application-wide content security policy -# For further information see the following documentation -# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy +# Define an application-wide content security policy. +# See the Securing Rails Applications Guide for more information: +# https://guides.rubyonrails.org/security.html#content-security-policy-header -# Rails.application.config.content_security_policy do |policy| -# policy.default_src :self, :https -# policy.font_src :self, :https, :data -# policy.img_src :self, :https, :data -# policy.object_src :none -# policy.script_src :self, :https -# policy.style_src :self, :https -# # If you are using webpack-dev-server then specify webpack-dev-server host -# policy.connect_src :self, :https, "http://localhost:3035", "ws://localhost:3035" if Rails.env.development? - -# # Specify URI for violation reports -# # policy.report_uri "/csp-violation-report-endpoint" +# Rails.application.configure do +# config.content_security_policy do |policy| +# policy.default_src :self, :https +# policy.font_src :self, :https, :data +# policy.img_src :self, :https, :data +# policy.object_src :none +# policy.script_src :self, :https +# policy.style_src :self, :https +# # Specify URI for violation reports +# # policy.report_uri "/csp-violation-report-endpoint" +# end +# +# # Generate session nonces for permitted importmap, inline scripts, and inline styles. +# config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s } +# config.content_security_policy_nonce_directives = %w(script-src style-src) +# +# # Report violations without enforcing the policy. +# # config.content_security_policy_report_only = true # end - -# If you are using UJS then enable automatic nonce generation -# Rails.application.config.content_security_policy_nonce_generator = -> request { SecureRandom.base64(16) } - -# Set the nonce only to specific directives -# Rails.application.config.content_security_policy_nonce_directives = %w(script-src) - -# Report CSP violations to a specified URI -# For further information see the following documentation: -# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy-Report-Only -# Rails.application.config.content_security_policy_report_only = true diff --git a/config/initializers/filter_parameter_logging.rb b/config/initializers/filter_parameter_logging.rb index e0202457..d54b2b67 100644 --- a/config/initializers/filter_parameter_logging.rb +++ b/config/initializers/filter_parameter_logging.rb @@ -1,6 +1,8 @@ # Be sure to restart your server when you modify this file. -# Configure sensitive parameters which will be filtered from the log file. +# Configure parameters to be partially matched (e.g. passw matches password) and filtered from the log file. +# Use this to limit dissemination of sensitive information. +# See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors. Rails.application.config.filter_parameters += [ - :passw, :uid + :passw, :uid, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :cvv, :cvc ] diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb index ac033bf9..3860f659 100644 --- a/config/initializers/inflections.rb +++ b/config/initializers/inflections.rb @@ -4,13 +4,13 @@ # are locale specific, and you may define rules for as many different # locales as you wish. All of these examples are active by default: # ActiveSupport::Inflector.inflections(:en) do |inflect| -# inflect.plural /^(ox)$/i, '\1en' -# inflect.singular /^(ox)en/i, '\1' -# inflect.irregular 'person', 'people' +# inflect.plural /^(ox)$/i, "\\1en" +# inflect.singular /^(ox)en/i, "\\1" +# inflect.irregular "person", "people" # inflect.uncountable %w( fish sheep ) # end # These inflection rules are supported but not enabled by default: # ActiveSupport::Inflector.inflections(:en) do |inflect| -# inflect.acronym 'RESTful' +# inflect.acronym "RESTful" # end diff --git a/config/initializers/new_framework_defaults_8_0.rb b/config/initializers/new_framework_defaults_8_0.rb new file mode 100644 index 00000000..92efa951 --- /dev/null +++ b/config/initializers/new_framework_defaults_8_0.rb @@ -0,0 +1,30 @@ +# Be sure to restart your server when you modify this file. +# +# This file eases your Rails 8.0 framework defaults upgrade. +# +# Uncomment each configuration one by one to switch to the new default. +# Once your application is ready to run with all new defaults, you can remove +# this file and set the `config.load_defaults` to `8.0`. +# +# Read the Guide for Upgrading Ruby on Rails for more info on each option. +# https://guides.rubyonrails.org/upgrading_ruby_on_rails.html + +### +# Specifies whether `to_time` methods preserve the UTC offset of their receivers or preserves the timezone. +# If set to `:zone`, `to_time` methods will use the timezone of their receivers. +# If set to `:offset`, `to_time` methods will use the UTC offset. +# If `false`, `to_time` methods will convert to the local system UTC offset instead. +#++ +# Rails.application.config.active_support.to_time_preserves_timezone = :zone + +### +# When both `If-Modified-Since` and `If-None-Match` are provided by the client +# only consider `If-None-Match` as specified by RFC 7232 Section 6. +# If set to `false` both conditions need to be satisfied. +#++ +# Rails.application.config.action_dispatch.strict_freshness = true + +### +# Set `Regexp.timeout` to `1`s by default to improve security over Regexp Denial-of-Service attacks. +#++ +# Regexp.timeout = 1 diff --git a/config/puma.rb b/config/puma.rb index ccadc778..a248513b 100644 --- a/config/puma.rb +++ b/config/puma.rb @@ -1,43 +1,41 @@ -# Puma can serve each request in a thread from an internal thread pool. -# The `threads` method setting takes two numbers: a minimum and maximum. -# Any libraries that use thread pools should be configured to match -# the maximum value specified for Puma. Default is set to 5 threads for minimum -# and maximum; this matches the default thread size of Active Record. +# This configuration file will be evaluated by Puma. The top-level methods that +# are invoked here are part of Puma's configuration DSL. For more information +# about methods provided by the DSL, see https://puma.io/puma/Puma/DSL.html. # -max_threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 } -min_threads_count = ENV.fetch("RAILS_MIN_THREADS") { max_threads_count } -threads min_threads_count, max_threads_count - -# Specifies the `worker_timeout` threshold that Puma will use to wait before -# terminating a worker in development environments. +# Puma starts a configurable number of processes (workers) and each process +# serves each request in a thread from an internal thread pool. # -worker_timeout 3600 if ENV.fetch("RAILS_ENV", "development") == "development" - -# Specifies the `port` that Puma will listen on to receive requests; default is 3000. +# You can control the number of workers using ENV["WEB_CONCURRENCY"]. You +# should only set this value when you want to run 2 or more workers. The +# default is already 1. # -port ENV.fetch("PORT") { 3000 } - -# Specifies the `environment` that Puma will run in. +# The ideal number of threads per worker depends both on how much time the +# application spends waiting for IO operations and on how much you wish to +# prioritize throughput over latency. # -environment ENV.fetch("RAILS_ENV") { "development" } - -# Specifies the `pidfile` that Puma will use. -pidfile ENV.fetch("PIDFILE") { "tmp/pids/server.pid" } - -# Specifies the number of `workers` to boot in clustered mode. -# Workers are forked web server processes. If using threads and workers together -# the concurrency of the application would be max `threads` * `workers`. -# Workers do not work on JRuby or Windows (both of which do not support -# processes). +# As a rule of thumb, increasing the number of threads will increase how much +# traffic a given process can handle (throughput), but due to CRuby's +# Global VM Lock (GVL) it has diminishing returns and will degrade the +# response time (latency) of the application. # -# workers ENV.fetch("WEB_CONCURRENCY") { 2 } - -# Use the `preload_app!` method when specifying a `workers` number. -# This directive tells Puma to first boot the application and load code -# before forking the application. This takes advantage of Copy On Write -# process behavior so workers use less memory. +# The default is set to 3 threads as it's deemed a decent compromise between +# throughput and latency for the average Rails application. # -# preload_app! +# Any libraries that use a connection pool or another resource pool should +# be configured to provide at least as many connections as the number of +# threads. This includes Active Record's `pool` parameter in `database.yml`. +threads_count = ENV.fetch("RAILS_MAX_THREADS", 3) +threads threads_count, threads_count + +# Specifies the `port` that Puma will listen on to receive requests; default is 3000. +port ENV.fetch("PORT", 3000) -# Allow puma to be restarted by `rails restart` command. +# Allow puma to be restarted by `bin/rails restart` command. plugin :tmp_restart + +# Run the Solid Queue supervisor inside of Puma for single-server deployments +plugin :solid_queue if ENV["SOLID_QUEUE_IN_PUMA"] + +# Specify the PID file. Defaults to tmp/pids/server.pid in development. +# In other environments, only set the PID file if requested. +pidfile ENV["PIDFILE"] if ENV["PIDFILE"] diff --git a/db/migrate/20260714034616_remove_not_null_on_active_storage_blobs_checksum.active_storage.rb b/db/migrate/20260714034616_remove_not_null_on_active_storage_blobs_checksum.active_storage.rb new file mode 100644 index 00000000..93c8b85a --- /dev/null +++ b/db/migrate/20260714034616_remove_not_null_on_active_storage_blobs_checksum.active_storage.rb @@ -0,0 +1,8 @@ +# This migration comes from active_storage (originally 20211119233751) +class RemoveNotNullOnActiveStorageBlobsChecksum < ActiveRecord::Migration[6.0] + def change + return unless table_exists?(:active_storage_blobs) + + change_column_null(:active_storage_blobs, :checksum, true) + end +end diff --git a/db/schema.rb b/db/schema.rb index 08198846..ab057c13 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,12 +10,12 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2026_06_11_180000) do +ActiveRecord::Schema[8.0].define(version: 2026_07_14_034616) do create_schema "heroku_ext" # These are extensions that must be enabled in order to support this database + enable_extension "pg_catalog.plpgsql" enable_extension "pg_stat_statements" - enable_extension "plpgsql" create_table "active_admin_comments", force: :cascade do |t| t.string "namespace" @@ -47,7 +47,7 @@ t.string "content_type" t.text "metadata" t.bigint "byte_size", null: false - t.string "checksum", null: false + t.string "checksum" t.datetime "created_at", precision: nil, null: false t.string "service_name", null: false t.index [ "key" ], name: "index_active_storage_blobs_on_key", unique: true From 4d010ceb24cba0175d175398b03b22a50f3af478 Mon Sep 17 00:00:00 2001 From: Kyle Fritz Date: Mon, 13 Jul 2026 23:58:44 -0400 Subject: [PATCH 3/6] Adopt Rails 8.0 framework defaults; drop EOLRails brakeman ignore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only three defaults change from 7.2 to 8.0 and none affect this app: - to_time_preserves_timezone = :zone — no app code calls to_time; the boot-time deprecation warning (from a gem) is gone with the flip - action_dispatch.strict_freshness — we use no conditional-GET helpers - Regexp.timeout = 1s — no long-running regexes over unbounded input All defaults adopted, so new_framework_defaults_8_0.rb is deleted rather than kept with opt-outs. The EOLRails ignore entry is removed from brakeman.ignore (brakeman flagged it as obsolete once on 8.0); bin/brakeman exits 0. Co-Authored-By: Claude Fable 5 --- config/application.rb | 2 +- config/brakeman.ignore | 6 +--- .../new_framework_defaults_8_0.rb | 30 ------------------- 3 files changed, 2 insertions(+), 36 deletions(-) delete mode 100644 config/initializers/new_framework_defaults_8_0.rb diff --git a/config/application.rb b/config/application.rb index fb639449..fca1623d 100644 --- a/config/application.rb +++ b/config/application.rb @@ -9,7 +9,7 @@ module Motzi class Application < Rails::Application # Initialize configuration defaults for originally generated Rails version. - config.load_defaults 7.2 + config.load_defaults 8.0 # Settings in config/environments/* take precedence over those specified here. # Application configuration can go into files in config/initializers diff --git a/config/brakeman.ignore b/config/brakeman.ignore index fa4ed15b..fb205782 100644 --- a/config/brakeman.ignore +++ b/config/brakeman.ignore @@ -7,12 +7,8 @@ { "fingerprint": "19ec190c996eca43da675de0907235ecb61b31a18d1b162444c5e6033a901ea4", "note": "Open3.capture2 with array form — no shell injection possible" - }, - { - "fingerprint": "98b26f60d776fd41ee6f088c833725145be9aac2d7c5b33780241c273622db42", - "note": "Rails 7.2 EOL 2026-08-09 — upgrade tracked separately; don't block CI on the countdown. Fingerprint changes with the patch version; re-run bin/brakeman -f json for the new one when it goes stale." } ], - "updated": "2026-07-13", + "updated": "2026-07-14", "brakeman_version": "8.0.4" } diff --git a/config/initializers/new_framework_defaults_8_0.rb b/config/initializers/new_framework_defaults_8_0.rb deleted file mode 100644 index 92efa951..00000000 --- a/config/initializers/new_framework_defaults_8_0.rb +++ /dev/null @@ -1,30 +0,0 @@ -# Be sure to restart your server when you modify this file. -# -# This file eases your Rails 8.0 framework defaults upgrade. -# -# Uncomment each configuration one by one to switch to the new default. -# Once your application is ready to run with all new defaults, you can remove -# this file and set the `config.load_defaults` to `8.0`. -# -# Read the Guide for Upgrading Ruby on Rails for more info on each option. -# https://guides.rubyonrails.org/upgrading_ruby_on_rails.html - -### -# Specifies whether `to_time` methods preserve the UTC offset of their receivers or preserves the timezone. -# If set to `:zone`, `to_time` methods will use the timezone of their receivers. -# If set to `:offset`, `to_time` methods will use the UTC offset. -# If `false`, `to_time` methods will convert to the local system UTC offset instead. -#++ -# Rails.application.config.active_support.to_time_preserves_timezone = :zone - -### -# When both `If-Modified-Since` and `If-None-Match` are provided by the client -# only consider `If-None-Match` as specified by RFC 7232 Section 6. -# If set to `false` both conditions need to be satisfied. -#++ -# Rails.application.config.action_dispatch.strict_freshness = true - -### -# Set `Regexp.timeout` to `1`s by default to improve security over Regexp Denial-of-Service attacks. -#++ -# Regexp.timeout = 1 From c084ede12a70db94ce3c5d1cf5ce8d4edc1a2732 Mon Sep 17 00:00:00 2001 From: Kyle Fritz Date: Tue, 14 Jul 2026 00:55:12 -0400 Subject: [PATCH 4/6] Capitalize default option in bin/dev port prompt; add project verify skill [U]se marks the default answer in the port-conflict prompt. The verify skill documents how to drive the running dev server end-to-end (login shortcut, order flows, admin/engines, jobs, letter_opener). Co-Authored-By: Claude Fable 5 --- .claude/skills/verify/SKILL.md | 27 +++++++++++++++++++++++++++ bin/dev | 2 +- 2 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 .claude/skills/verify/SKILL.md diff --git a/.claude/skills/verify/SKILL.md b/.claude/skills/verify/SKILL.md new file mode 100644 index 00000000..32ec40f3 --- /dev/null +++ b/.claude/skills/verify/SKILL.md @@ -0,0 +1,27 @@ +--- +name: verify +description: Drive Motzi's running dev server end-to-end to verify changes — login shortcuts, order flows, admin, jobs, emails +--- + +# Verifying Motzi changes at runtime + +## Handle + +- `bin/dev` runs foreman: Rails web + `bun run build:watch` + Solid Queue worker. If port 3000 is busy it offers an alternate port — check `lsof -iTCP:3001 -sTCP:LISTEN` before starting another server. +- For a throwaway server: `bun run build` (populates `app/assets/builds`), then `DISABLE_SPRING=1 bin/rails server -p `. Worktrees need `bun install` first (no shared node_modules). +- Auth: `GET /dev/login_as_admin` signs in as admin — works for both curl cookie jars (`curl -c jar -L`) and Playwright. +- Admin pages need `-H "Accept: text/html"` with curl or they 302. + +## Flows worth driving + +- **Member order**: `/menu` (React) → pick a day on an item → Add to cart → Submit/Update Order. Credits counter must move; "We've got your order!" confirms. Edit Order → × an item → Update refunds the credit. +- **Guest buy**: `/menu` logged out shows cash prices; cart total fills the "You pay $" box; tip % updates the charge button. Card charge stays disabled in dev — no `STRIPE_PUBLISHABLE_KEY` in `.env` (expect 2 Stripe IntegrationErrors in console; pre-existing, ignore, along with 1 React hooks-order warning in Layout). +- **Admin**: `/admin/orders` (find the order), `/admin/orders//edit` → Update round-trip. `/admin/activity_feed`, `/admin/error_events` (+ `.txt` format), `/admin/uptime_checks`, engines `/blazer`, `/jobs`, `/letter_opener`. +- **Email pipeline**: order updates enqueue `ActionMailer::MailDeliveryJob`; confirm delivery in `/letter_opener` (rich view shows the MJML-rendered email). +- **Jobs**: `psql -d motzi -c "select * from solid_queue_processes"` — supervisor/worker/dispatcher/scheduler heartbeats prove the worker is up. `solid_queue_jobs` timestamps are UTC — don't compare against local `now()`. +- **Probes**: `/nonexistent` → custom 404 via ErrorsController; `/health/admin` without token → 404; `POST /reply_ingress` bad secret → 401; `POST /error_events` JSON → 204 + row in `error_events`. + +## Gotchas + +- Dev DB `motzi` is shared across worktrees and the main checkout — other sessions' migrations show up in your server (PendingMigrationError) and in `db:migrate` schema dumps. +- `AnalyzeAnomaliesJob` fails nightly in dev (Anthropic 400) — pre-existing, not your change. diff --git a/bin/dev b/bin/dev index bbba1f78..80267f24 100755 --- a/bin/dev +++ b/bin/dev @@ -22,7 +22,7 @@ if lsof -iTCP:"$web_port" -sTCP:LISTEN -t > /dev/null 2>&1; then alt_port=$((alt_port + 1)) done echo "==> Port ${web_port} in use (pid $pid)" - read -rp "==> [u]se port ${alt_port} instead (default) / [k]ill $pid / [q]uit? " answer + read -rp "==> [U]se port ${alt_port} instead (default) / [k]ill $pid / [q]uit? " answer case "${answer:-u}" in [Kk]*) kill "$pid" 2>/dev/null && echo "==> Killed $pid" || echo "==> Could not kill $pid" From 6ac450f1ca84cb4a7eb69359c50ca678b1de6c01 Mon Sep 17 00:00:00 2001 From: Kyle Fritz Date: Tue, 14 Jul 2026 01:08:03 -0400 Subject: [PATCH 5/6] Fix hook-order warning in menu Layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getDeadlineContext() calls useContext, but Layout invoked it after the loading/error early returns — so the hook only ran once the menu had loaded, changing the hook order between renders and triggering React's rules-of-hooks warning on every menu load. Hoist the call above the early returns; allClosed(menu) still runs only after the null check. Co-Authored-By: Claude Fable 5 --- app/javascript/packs/menu/Layout.tsx | 5 ++- test/javascript/menu/Layout.test.tsx | 55 ++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 test/javascript/menu/Layout.test.tsx diff --git a/app/javascript/packs/menu/Layout.tsx b/app/javascript/packs/menu/Layout.tsx index d056baa3..5a1a0800 100644 --- a/app/javascript/packs/menu/Layout.tsx +++ b/app/javascript/packs/menu/Layout.tsx @@ -42,6 +42,9 @@ export default function Layout({ const [showEmailSettings, setShowEmailSettings] = useState( initialTab === "email" ); + // useContext under the hood — must run before the early returns below so + // the hook order doesn't change once the menu finishes loading. + const deadlineContext = getDeadlineContext(); if (error) { return ( @@ -62,7 +65,7 @@ export default function Layout({ ); } - const orderingClosed = getDeadlineContext().allClosed(menu); + const orderingClosed = deadlineContext.allClosed(menu); if (order && !isEditingOrder) { const handleEditOrder = diff --git a/test/javascript/menu/Layout.test.tsx b/test/javascript/menu/Layout.test.tsx new file mode 100644 index 00000000..0e3fac61 --- /dev/null +++ b/test/javascript/menu/Layout.test.tsx @@ -0,0 +1,55 @@ +import React from "react"; +import { afterEach, expect, mock, test } from "bun:test"; +import { cleanup, render } from "@testing-library/react"; + +import Layout from "menu/Layout"; +import { SettingsContext } from "menu/Contexts"; +import mockMenuJson from "./mockMenuJson"; +import stripeMock from "./stripeMock"; + +afterEach(cleanup); + +// Layout renders a loading state before the menu arrives, then the real +// content. Hooks called after the loading-state early return change the +// hook order between those renders, which React reports via console.error. +test("keeps hook order stable when menu loads after the loading state", () => { + window.gon = { stripeApiKey: "no-such-key" }; + window.Stripe = mock(() => stripeMock); + + const originalConsoleError = console.error; + const errors: string[] = []; + console.error = (...args: unknown[]) => { + errors.push(args.map(String).join(" ")); + }; + + try { + const { menu, bundles } = mockMenuJson({ user: false, order: false }); + const props = { + bundles, + handleCreateOrder: mock(() => Promise.resolve()), + isEditingOrder: false, + order: null, + setIsEditingOrder: mock(() => {}), + user: null, + }; + + const { rerender } = render( + + + + ); + + rerender( + + + + ); + + const hookOrderErrors = errors.filter((e) => + e.includes("change in the order of Hooks") + ); + expect(hookOrderErrors).toEqual([]); + } finally { + console.error = originalConsoleError; + } +}); From d479747c38502e91a6131a1c46ce6c752677f549 Mon Sep 17 00:00:00 2001 From: Kyle Fritz Date: Tue, 14 Jul 2026 01:08:18 -0400 Subject: [PATCH 6/6] Accept error_event-wrapped payloads in error ingest The controller only permitted top-level keys, so a client posting the {error_event: {...}} shape that ParamsWrapper advertises had every field silently dropped to the fallbacks (error_class "Error", empty message/stack/context). Fall back to the nested params when the top-level permit comes up empty; top level still wins because the auto-wrapped copy excludes non-column keys (stack, context). Co-Authored-By: Claude Fable 5 --- app/controllers/error_events_controller.rb | 6 ++++++ test/integration/error_events_test.rb | 23 ++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/app/controllers/error_events_controller.rb b/app/controllers/error_events_controller.rb index b4d27ddb..bd906c83 100644 --- a/app/controllers/error_events_controller.rb +++ b/app/controllers/error_events_controller.rb @@ -11,6 +11,12 @@ def create end payload = params.permit(:error_class, :message, :stack, :url, context: {}).to_h + # Clients may nest the payload under error_event (the shape ParamsWrapper + # advertises). Top level wins when present: the auto-wrapped copy drops + # keys that aren't ErrorEvent columns (stack, context). + if payload.empty? && params[:error_event].is_a?(ActionController::Parameters) + payload = params[:error_event].permit(:error_class, :message, :stack, :url, context: {}).to_h + end ErrorEvent.record_browser_exception( error_class: payload["error_class"].presence || "Error", diff --git a/test/integration/error_events_test.rb b/test/integration/error_events_test.rb index f274a085..529e7354 100644 --- a/test/integration/error_events_test.rb +++ b/test/integration/error_events_test.rb @@ -41,6 +41,29 @@ class ErrorEventsTest < ActionDispatch::IntegrationTest assert_nil ErrorEvent.last.user_id end + test "browser ingest accepts a payload nested under error_event" do + # Rails' ParamsWrapper invites this shape (wrap_parameters is on for + # JSON), so a client posting {error_event: {...}} must not silently + # lose every field to the fallbacks. + assert_difference "ErrorEvent.count", 1 do + post "/error_events", params: { + error_event: { + error_class: "TypeError", + message: "wrapped payload", + stack: "TypeError: wrapped\n at App (/menu.js:1:1)", + url: "/menu", + context: { kind: "wrapped" } + } + }, as: :json + end + + assert_response :no_content + ev = ErrorEvent.last + assert_equal "TypeError", ev.error_class + assert_equal "wrapped payload", ev.message + assert_equal "wrapped", ev.context["kind"] + end + test "browser ingest rate-limits high-volume posters" do # The test env's :null_store cache doesn't actually count, so swap in a # real in-memory store for this test only.