Skip to content

Submission: Fullstack Developer Challenge - Daniel da Silva - #35

Open
DanielDz21 wants to merge 69 commits into
umanni:masterfrom
DanielDz21:master
Open

Submission: Fullstack Developer Challenge - Daniel da Silva#35
DanielDz21 wants to merge 69 commits into
umanni:masterfrom
DanielDz21:master

Conversation

@DanielDz21

Copy link
Copy Markdown

Summary

A user management admin panel built for the Modern Fullstack Developer Test:
role-based authentication, a real-time admin dashboard, full user CRUD with
avatar support (upload or remote URL), and asynchronous CSV/XLSX spreadsheet
import with live progress tracking.

Stack: Ruby 4.0 / Rails 8.1, Hotwire (Turbo + Stimulus), Tailwind CSS v4,
SQLite (multi-database, WAL), Solid Cache/Queue/Cable (no Redis), Pundit,
Rails 8's built-in authentication generator (no Devise).

See README.md for full setup, seeding, environment variables, architecture
decisions, and security notes.

Highlights

  • Real-time dashboard: live user counts via Turbo Streams over Solid
    Cable, backed by Solid Cache to avoid recomputation.
  • Async spreadsheet import: background CSV/XLSX processing (Solid Queue)
    with a live, throttled progress bar and per-row error tracking — a bad row
    never aborts the whole import. Imported users get a "set your password"
    e-mail reusing the same password-reset token mechanism as a normal reset.
  • SSRF-hardened avatar fetching: remote avatar URLs are validated against
    private/loopback/link-local ranges on every redirect hop, with bounded
    redirects, a content-type allowlist, and a streamed size cutoff.
  • Security: parameterized queries throughout, ERB auto-escaping (no
    html_safe/raw/sanitize anywhere), CSRF protection, rate-limited auth
    endpoints, strict params on every controller. Covered by
    spec/requests/security_spec.rb (SQLi/XSS/CSRF) plus manual verification.
  • Testing: 162 examples, 97.78% line coverage (SimpleCov,
    minimum_coverage enforced at 90%), parallelized via parallel_tests,
    system specs with real Turbo Stream/Action Cable delivery via Playwright.
  • CI/CD: GitHub Actions (security scan, lint, parallel test suite +
    coverage artifact) and CodeQL, plus a Kamal 2 deployment config and a
    multi-stage, non-root Docker image using Thruster and Ruby 4's ZJIT.

AI usage disclosure

This project was built with AI assistance throughout (Claude Code). See the
"AI Usage Disclosure" section at the top of README.md for the full,
honest account.

Test plan

  • bundle exec rspec — 162 examples, 0 failures
  • bundle exec rubocop — 0 offenses
  • bundle exec brakeman -q --no-pager — 0 warnings
  • bundle exec bundler-audit check — 0 vulnerabilities
  • bin/rails db:seed then sign in as both seeded users (see README)
  • Manual smoke test: create a user, toggle a role, watch the dashboard
    update live in a second browser session; import a spreadsheet and watch
    progress update live

Generate the base app with Ruby 4.0 / Rails 8.1 defaults: Propshaft,
importmap-rails, Hotwire (Turbo + Stimulus), Tailwind CSS, SQLite
(WAL journal mode by default), Solid Cache/Queue/Cable, RuboCop
(rubocop-rails-omakase), Brakeman, bundler-audit, Kamal 2 and a
multi-stage Dockerfile with Thruster.
Set up rspec-rails, FactoryBot, Faker, Shoulda Matchers, SimpleCov,
Capybara with the Playwright driver, and parallel_tests. Configure
database.yml so parallel workers each get their own SQLite test
database (TEST_ENV_NUMBER), avoiding lock contention. Add a request
spec for the health check endpoint and a boot spec verifying the
environment loads with WAL mode, the required gems and the ActiveJob
test adapter.
…audit

Add a test job to the GitHub Actions pipeline that provisions one
SQLite database per parallel worker and runs the suite via
parallel_rspec, uploading the SimpleCov report as an artifact. Also
fix the push trigger branch to match this repo's default (master).
Generate the built-in authentication system (bin/rails generate
authentication): User with has_secure_password, Session, Current,
sign in/out, and password reset via email. Rename the generated
email_address column to email and extend User with full_name and a
role enum (no_admin/admin, defaulting to no_admin) to match the
domain model required by the spec.
Add a public registration form (full_name, email, password) that
signs the new user in immediately after creation. The role is always
hardcoded to no_admin server-side and is never accepted from request
params, so a visitor cannot self-promote to admin.
Admins land on a minimal Admin Dashboard (to be built out with
real-time counters in a later phase); everyone else lands on their
own Profile page. Both controllers are intentionally thin for now:
the dashboard's authorization check will be formalized with Pundit,
and the profile/dashboard views will gain real functionality in
their dedicated phases.
Add model specs for User (validations, email normalization, role
enum) and Session, plus request specs for sign in/out, self-registration
(including an attempt to inject role=admin, which must be ignored),
and access control on the admin dashboard and profile pages.
Wire Pundit into ApplicationController (pundit_user resolves to
Current.user, since this app uses Rails 8's Current attributes
instead of a current_user method) and enforce authorization on every
action via after_action :verify_authorized, opting out only in the
pre-authentication controllers (sessions, passwords, registration).

UserPolicy centralizes the authorization matrix: an admin can manage
any user; a no_admin user can only view/update/destroy their own
record. Admin::DashboardsController and ProfilesController now call
authorize instead of the naive role check from Fase 1. Unauthorized
access redirects to the user's own profile with a flash alert instead
of a bare 403.
Every auth-related view repeated the same alert/notice markup, and
the two views added in this phase (profile, admin dashboard) were
missing it entirely, so the new Pundit redirect alert had nowhere to
render. Render app/views/layouts/_flash once from the layout instead
of duplicating it per view.
…rect

Add pundit-matchers-based specs for the full admin/self/other-user
permission matrix on UserPolicy and its Scope, and update the admin
dashboard request spec to assert the new redirect-with-alert behavior
instead of a bare 403.
…idation

Install Active Storage and attach an avatar to User, validating that
uploaded files are a supported image type (PNG/JPEG/WEBP) and within
a 5MB size limit.
Add an avatar_url virtual attribute on User (validated as a plain
http(s) URL) that, once the record is committed, enqueues
AvatarDownloadJob to fetch and attach the image asynchronously.

The fetch itself goes through AvatarFetcher, hardened against SSRF:
only http(s) URLs are accepted, the resolved IP must be public (no
loopback/private/link-local ranges, which also blocks the common
cloud metadata endpoint), redirects are capped, and the response body
is streamed with an early size cutoff so a malicious server can't
exhaust memory before we notice the file is too large.
Admins can list, create, edit and delete any user (including setting
their role and avatar), backed by UserPolicy from Fase 2. A dedicated
toggle_role action flips a user's role with one click from the index,
and refuses to let an admin change their own role to avoid an
accidental lockout.
Extend ProfilesController with edit/update/destroy for the signed in
user (role is never in the permitted params, so self-service can't
promote to admin), show the avatar on the profile page, and add
sign-out buttons plus a link from the dashboard to user management,
since there was previously no UI path to log out.
Model specs for the content-type/size validation and the avatar_url
format check plus job enqueue; a full spec suite for AvatarFetcher's
SSRF hardening (disallowed schemes, unresolvable/private/loopback/
link-local addresses, redirect cap, content-type and size limits);
and specs for AvatarDownloadJob attaching on success and logging
instead of raising on failure. Adds a :with_avatar factory trait and
webmock for stubbing the outbound HTTP calls.
Request specs for Admin::UsersController (index/new/create/update/
destroy/toggle_role, including the no_admin forbidden paths and the
self-role-change guard) and for the profile edit/update/destroy
actions (blank password keeps the current one, an injected role param
is ignored, avatar_url enqueues the download job).
Admin::DashboardsController now computes total users and users grouped
by role, and the User model broadcasts a fresh render of those counts
to every connected admin whenever a user is created, destroyed, or has
its role changed (an unrelated attribute update does not broadcast).

A single after_commit with a combined condition is used on purpose:
registering two separate after_commit callbacks for the same method
name with different `on:` values silently drops the `on: :create`
one, since Active Record's callback chain de-duplicates by method
name regardless of options.

A small Stimulus controller briefly highlights the counts whenever
Turbo replaces them, so the live update is actually noticeable.
… two admin sessions

Model specs assert the broadcast fires on create/destroy/role change
and not on unrelated updates. A system spec (Playwright, two
independent Capybara sessions) creates a user as one signed-in admin
and asserts a second signed-in admin's dashboard updates the total
without a page reload, exercising the real Turbo Streams/Solid Cable
pipeline end to end. Adds package.json pinning the Playwright version
for local system spec runs, and a sign_in_via_ui helper for system
specs.
… per-row error tracking

Adds the roo gem (uniform API for both CSV and XLSX, avoiding separate
gems per format) plus the SpreadsheetImport (upload metadata, status,
row counters) and SpreadsheetImportRowError (row number + reason,
recorded individually rather than just counted, as requested) models
that Fase 5's background import will build on.
…reuse

Renamed dashboard_counts_controller.js to highlight_on_update_controller.js
now that the same "flash on Turbo Stream update" behavior is about to be
reused by the spreadsheet import progress bar too — only worth extracting
now that the duplication actually shows up.
SpreadsheetImportJob (Solid Queue) parses the attached CSV/XLSX via Roo
and creates a User per row with a random password (no login form was
submitted, so there is nothing to confirm) and the fixed no_admin role.
A bad row never aborts the whole import: validation failures are
recorded as a SpreadsheetImportRowError and processing continues.
Progress is persisted with update! (not increment!, which bypasses
after_commit callbacks via update_counters) so the row-by-row broadcast
added on SpreadsheetImport fires as each row completes. A spreadsheet
that fails to parse at all (corrupt file, invalid encoding) is
distinguished from a per-row failure and marks the import failed.

Spreadsheet cells are untrusted external input and are only ever read
as plain data, never interpreted as instructions.
…ogress view

index/new/create/show, all authorized through Pundit (policy_scope +
authorize on index, like Admin::UsersController). The show page
subscribes to the import's own Turbo Stream channel and renders a
progress bar plus a per-row error table that update live as
SpreadsheetImportJob works through the file, reusing the highlight
Stimulus controller from the dashboard.
…e progress

Model specs (file validations, status enum, progress_percent, job
enqueueing, progress broadcasts), job specs against real CSV and XLSX
fixtures (valid/mixed/malformed-encoding — missing email, invalid
format and duplicate email each produce their own row error without
aborting the rest), policy specs, request specs for
Admin::SpreadsheetImportsController, and a real Playwright system spec
that uploads a file through the UI, runs the job, and asserts the
progress bar and status update without a reload.
No shared navigation existed until now — every page was an island, and the
layout's mt-28 top margin hinted at a header that was never built. Admins
now see Dashboard/Manage users/Spreadsheet imports, regular users see My
Profile, and Sign out lives in one place instead of being duplicated across
the dashboard and profile pages. Below the sm breakpoint the links collapse
behind a "Menu" button (nav_toggle_controller.js, plain Stimulus, no new
dependency).
… partial

The same input/button/link class string was repeated verbatim in roughly
twenty places across every form and table view, and the error-list markup
was copy-pasted in four forms — real duplication, not a hypothetical one.
Extracted into @layer components in application.tailwind.css (form-input,
form-label, form-file, btn-primary, btn-link, link-action/-danger/-muted)
and shared/_form_errors.html.erb. form-input also bakes in a :user-invalid
red border for interactive validation feedback, degrading gracefully on
browsers without support.

Also wraps the users/spreadsheet-imports/row-errors tables in overflow-x-
auto and lets action-row headers wrap (flex-wrap) so they don't break on
narrow viewports.
…word length

HTML5 required/type/minlength already gave interactive feedback, but
cross-field validation (does the confirmation match the password?) has no
native equivalent. password_confirmation_controller.js compares the two
fields on input and reports the mismatch via setCustomValidity, reusing the
same :user-invalid styling. Backend gains a matching User#password minimum
length of 8 (previously only presence was enforced by has_secure_password),
keeping frontend and backend validation in sync per the test's requirement.
System spec resizing the real Playwright window to a desktop and a mobile
viewport, checking the nav links render inline above the sm breakpoint and
collapse behind the "Menu" toggle below it, then exercising the toggle end
to end (open menu, follow a link).
Phase 7 security hardening review found no vulnerabilities in the existing
code (Brakeman stayed clean, strong params/escaping/CSRF already sound), so
this adds request specs that pin down the traditional vectors the README
explicitly calls out for assessment: a crafted email cannot bypass
authentication or leak records through ActiveRecord's parameterized finder,
a malicious full_name renders escaped on the profile and admin users pages,
and a state-changing request without a valid authenticity token is rejected
with the forgery-protection guard re-enabled just for that example (test env
disables it globally so request specs can post freely).
…onfig

servers.web and registry.server still had rails new's literal scaffold values
(192.168.0.1, localhost:5555), which don't point anywhere real and would need
a throwaway local registry container just to inspect. Read the deploy host and
registry credentials from ENV instead (deploy.yml is ERB before YAML), falling
back to an RFC 5737 TEST-NET-3 address that can never resolve, so a deploy run
without KAMAL_WEB_HOST set fails fast rather than silently targeting the wrong
host. Registry moved to ghcr.io, which needs no extra infrastructure to try.
Sets RUBYOPT="--zjit" in the production image (safe no-op with just a startup
warning on a Ruby build without ZJIT support). Rails 8.1's load_defaults
already auto-enables YJIT in production (config.yjit = !Rails.env.local?), and
only one JIT can be active per process - leaving both on printed "Only one JIT
can be enabled at the same time." on every boot and silently dropped the
Rails-side enable. Disabling config.yjit in production.rb makes ZJIT the one
actually running, confirmed via RubyVM::YJIT.enabled?/ZJIT.enabled? inside a
built container with no conflict warning.
DanielDz21 and others added 30 commits September 3, 2026 20:15
Reorders every CSV/XLSX fixture to name-first/email-second (the new positional
contract) and adds coverage for both has_header states: the header row is never
used to map columns (even when its labels don't say nome/email), and a file
without a header is read from row 1 onward.
… pt-BR

Leftover English text from the earlier translation pass: the password-reset
mailer templates and the native "Passwords don't match" validation tooltip.
Admin::UsersController#index rendered each row's avatar attachment with a
separate query; Admin::SpreadsheetImportsController#index did the same for
each row's user, file attachment/blob and row-error count. Preload them with
with_attached_avatar/includes, and switch the row-error tally from #count
(always hits the DB) to #size (uses the preloaded association).
User.count/group(:role).count ran on every dashboard render even though the
one place that actually knows when they change (the after_commit broadcast
hook) already recomputes them on every create/destroy/role change. Cache them
under a shared key, written through by that same hook, so Solid Cache (already
configured, previously unused) actually does something.

Also swaps User#normalizes's block for the Ruby 3.4+ implicit `it` parameter,
and User#avatar_url from a bare attr_accessor to a typed `attribute` (cast/
dirty-tracking for free), while touching this file.
The custom file-attachment validation re-ran on every update! call made while
processing a spreadsheet (once per row, to bump processed_rows), redoing an
Active Storage attachment check that can only ever matter at upload time. The
file never changes after creation, so scope the validation to on: :create.
Opening or editing a user was a full-page navigation away from the table, even
though Turbo 8 was already in use elsewhere for live updates. Wrap the form in
a turbo_frame_tag targeted from the index's links, with the form itself set to
break out to a full visit (turbo_frame: "_top") on submit so create/update/
cancel keep navigating and rendering exactly as before.
- AvatarFetcher::Result: Struct -> Data.define (it's an immutable value object).
- AvatarDownloadJob: ad hoc rescue -> discard_on, so Solid Queue records the
  discard instead of it being silently swallowed.
- PasswordsController#update: params.permit -> params.expect, matching every
  other controller (needed nesting the form fields under `user`, updated).
- RegistrationsController#create: added the same rate_limit already used on
  sessions/passwords, so public sign-up isn't the one unthrottled endpoint.
RegistrationsController#new and the invalid-params re-render branches of
Admin::UsersController#create/#update had no request spec coverage, pulling
overall line coverage under the 90% bar once the optimization phase touched
nearby lines.
The submission's own "Documentation" rule requires build/seed/run instructions,
environment variables and architecture decisions in README.md itself, in English.
It had been reverted to just the original test brief plus an AI disclosure block,
which doesn't satisfy that rule even though the content existed elsewhere
(CLAUDE.md, in Portuguese, meant for AI operational context). Also corrects the
disclosure block itself, which cited an inaccurate model/phase breakdown.
…he dashboard

Every processed row triggered two full Turbo Stream broadcasts: one for the
import's own progress bar (auto-fired via after_commit on processed_rows) and
one for the admin dashboard counts (since creating a User always broadcasts
them). For a 10,000-row import that's ~20,000 broadcasts, each a full partial
render plus a Solid Cable write.

- SpreadsheetImportJob now bumps processed_rows via update_columns (skips
  validation/callbacks) and calls SpreadsheetImport#broadcast_progress
  explicitly, throttled to once every 10 rows (always including the last row).
- User gets a skip_dashboard_broadcast flag, set by imported rows, so bulk
  import no longer fires one dashboard broadcast per created user; the job
  calls the new User.broadcast_dashboard_counts! once at the end instead.
SpreadsheetImportRowError broadcasts now append just the new row
(broadcast_append_to) instead of the progress partial re-rendering every
accumulated error on every broadcast — O(1) per error instead of O(errors so
far), which mattered once row errors could reach into the hundreds/thousands.

Also moves Solid Cable's message trim off the synchronous per-broadcast path
(autotrim does a DELETE attempt on every single write) onto the same scheduled-
job pattern already used for Solid Queue's cleanup, via config/recurring.yml.
SpreadsheetImportJob mixed three concerns: ActiveJob lifecycle, spreadsheet
parsing (Roo setup, header handling, positional mapping), and per-row business
logic (building a User, recording row errors). Split the latter two into
SpreadsheetParser and SpreadsheetImportRowImporter (app/services/, following
the existing AvatarFetcher convention), leaving the job as pure orchestration.

Also drops the redundant "always broadcast on the last row" throttle exception:
the status: :completed transition right after the loop already broadcasts the
final row count on its own (a regular update!), so forcing an extra broadcast
immediately before it just fired two broadcasts back-to-back for no benefit —
which turned out to be exactly the race behind the system spec's documented
intermittent flake (confirmed by removing it: 8/8 clean runs afterward, versus
frequent failures before, even with a 20s wait).
Spreadsheet-imported users were created with an unguessable random password
they were never told, so they could never actually log in. Reuse the existing
password-reset token mechanism (already auto-generated by has_secure_password)
via a new PasswordsMailer#welcome, sent right after a row successfully creates
a user — no PasswordsController changes needed, since the token/edit/update
path is already generic. Also fixes #reset's subject, hardcoded in English
despite the body already being pt-BR.

Adds letter_opener for development, since there was previously no way to see
outgoing mail locally at all (development had no delivery method configured,
silently falling back to an unconfigured :smtp adapter).
rails_helper.rb rescued ActiveRecord::PendenteMigrationError, which doesn't
exist — a real pending migration would have raised a NameError instead of the
intended friendly abort message. Also adds SimpleCov.minimum_coverage 90 (only
on a plain sequential run — parallel_rspec workers each only exercise a slice
of the suite, so enforcing it there would fail spuriously), so a coverage
regression below the README's own bar actually fails the suite instead of
just being a number nobody re-checks.
Removes the SEED_ADMIN_EMAIL/SEED_ADMIN_PASSWORD environment-variable override
for the seeded admin's credentials in favor of fixed values, and seeds a second,
non-admin user alongside the admin so the app has more than one account to sign
in as right after setup. Updates the README's seeding and environment-variable
docs to match.
One-time formatting pass, tools run via npx (not added as project dependencies):
Rustywind (npx rustywind --write app/views) sorts Tailwind classes into their
canonical order, then Herb (npx @herb-tools/formatter app/views) reformats the
ERB/HTML structure (indentation, attribute wrapping for long tags). No content,
class, or logic changes — verified via full diff review, the complete test
suite, RuboCop, Brakeman, bundler-audit, and a manual visual check of the
sidebar, users index and spreadsheet imports pages in a real browser.
letter_opener opens a new browser tab for every single delivery, unconditionally
(confirmed in its source — no config flag disables this). A bulk spreadsheet
import sends one "set your password" e-mail per successfully-created row, so a
large CSV (e.g. 10k rows) would attempt to open thousands of tabs and could
crash the machine running it locally.

Replaces it with two built-in Action Mailer/Rails features instead of a new
gem: delivery_method :file (writes to tmp/mails, no I/O beyond the filesystem)
and an ApplicationMailer after_deliver callback that logs the recipient and
body to the Rails console in development. Note message.body.to_s is empty for
a multipart message — the readable content lives in text_part/html_part.
The fixed mobile hamburger header (layouts/_sidebar.html.erb) sits on top of
the page at all times below the md breakpoint, but the main content area had
no top clearance for it (only a flat py-8 at every breakpoint) — so the top
of every page's content, including the "Novo Usuário" button next to the
Usuários heading, rendered underneath the opaque header and was clipped.
Mirrors the mt-14 md:mt-0 offset the sidebar's own profile block already uses
for the same header. Verified via a real Playwright browser at a 375px
viewport: header bottom edge at 65px, button top now at 88px.
Unmodified GitHub template scaffolding since it was added: deprecated v1
actions (checkout@v2, codeql-action/{init,autobuild,analyze}@v1) and an
Autobuild step that only applies to compiled languages (C/C++, C#, Java) —
a no-op at best for this Ruby/JS repo, and a plausible source of the reported
failures on its own. Bumps to checkout@v4 and codeql-action@v3, drops
Autobuild and the manual PR-head checkout (handled natively by v3), and adds
the security-events permission v3 requires.
The test job never compiled app/assets/tailwind/application.css into
app/assets/builds/tailwind.css before running system specs — that output is
gitignored, so a clean CI checkout boots the app with zero compiled Tailwind
CSS, meaning every responsive (md:*) utility class simply doesn't exist in
the served stylesheet. This is what actually broke
responsive_navigation_spec.rb in CI ("Menu" button visible on a desktop
viewport) — reproduced locally byte-for-byte by deleting the compiled CSS,
and confirmed fixed by running tailwindcss:build first. Not a code
regression: the md:hidden class itself has been correct all along.
Brakeman, bundler-audit and importmap audit are all lightweight, dependency-free
Ruby commands (importmap audit needs no Node/npm setup) — no reason to pay for
two separate runners and checkouts when one job covers all three.
PasswordsController had zero test coverage despite being a live, linked,
security-sensitive feature (account recovery, session invalidation on reset,
invalid/expired-token handling) — the single largest coverage gap in the app
by a wide margin. Covers: requesting a reset without revealing whether the
e-mail exists, an invalid/expired token redirecting with an alert, and the
happy/mismatch paths of actually setting a new password (including that it
signs the user out of every session). Verified manually end-to-end against a
real server too, since this flow had apparently never been exercised even
by hand.
…_fetcher error branches

profiles_spec.rb: every existing spec sent valid params, so the
:unprocessable_entity re-render branch on invalid input was never exercised.

avatar_fetcher_spec.rb: the "unexpected response" branch (a non-2xx,
non-redirect status like 404/500 — one of the more likely real-world failures
for a dead avatar URL) and the URI::InvalidURIError rescue (a genuinely
malformed URL string, distinct from the already-covered "wrong scheme" case)
had no coverage.
…troller

Three avatar image_tag calls (sidebar, profile, admin users index) had no
explicit alt, so Rails fell back to deriving one from the Active Storage
variant's signed/tokenized URL — meaningless text for a screen reader. Also
removes hello_controller.js, the default rails new Stimulus scaffold, never
referenced by any view (confirmed via grep) across 21 phases of work.
Chromium was downloaded fresh on every run (~4 minutes of the test job).
Caches ~/.cache/ms-playwright keyed by OS + exact Playwright package version —
only the browser binary is cacheable (OS-level deps are installed system-wide
via apt and never persist on the ephemeral runner anyway), so a cache hit
skips straight to the fast install-deps-only path instead of --with-deps.

Also adds a workflow-level permissions: {contents: read} block, per CodeQL's
own flag that none of the three jobs limited GITHUB_TOKEN's default
permissions — none of them need more than read access (no pushes, PR
comments, releases, or package publishing).
The browser-binary cache alone left npm install playwright@x.y.z as the new
bottleneck (~2 minutes fetching/resolving a single package fresh every run,
dwarfing the ~15s test suite it's there to support). Caches node_modules
under the same version-keyed cache key as the browser binary cache, so both
invalidate together on a Playwright version bump and both hit together
otherwise — skips npm install entirely on a cache hit.
A fresh clone had no way to know that system specs need a browser binary
downloaded separately, and the obvious `npx playwright install` on its own
makes things worse: with no local node_modules, npx fetches the *latest*
Playwright, which expects a different browser build than the 1.62.1 the
`playwright` gem drives. The result is a confusing
"Executable doesn't exist at .../chromium_headless_shell-<build>" failure
immediately after apparently installing the browser.

Document `npm install` before `npx playwright install chromium`, explain why
the order matters, and pin playwright to an exact version in package.json so
neither npm install nor npm update can drift off the gem's
COMPATIBLE_PLAYWRIGHT_VERSION.
config/credentials.yml.enc was committed but config/master.key never was --
correctly, it is gitignored. That left the encrypted file undecryptable by
anyone cloning the repo, so the container could not boot at all: the entrypoint
runs db:prepare, which boots Rails in production, which resolves secret_key_base
via ENV["SECRET_KEY_BASE"] || credentials.secret_key_base. With the content file
present but no key, EncryptedConfiguration#read rescues only MissingContentError
and the boot died on MissingKeyError. Kamal failed even earlier, on
`$(cat config/master.key)` in .kamal/secrets. The image still built fine, since
assets:precompile uses SECRET_KEY_BASE_DUMMY, so nothing caught this.

Nothing in the app actually reads credentials -- every reference is a commented
out SMTP or storage block -- so secret_key_base is all that is needed, and
ENV["SECRET_KEY_BASE"] short-circuits before credentials are touched. Supply it
directly and drop the dead encrypted file; without it the read raises
MissingContentError instead, which is rescued, so a forgotten env var now yields
Rails' own actionable message rather than a decryption crash.

Kamal declares SECRET_KEY_BASE under env.secret to match what .kamal/secrets now
provides, with the RAILS_MASTER_KEY route kept as a documented alternative for
anyone generating their own credentials. Also corrects the commented builder arg
RUBY_VERSION, which carried the .ruby-version "ruby-" prefix and would have
resolved to the nonexistent tag ruby:ruby-4.0.0-slim if uncommented.

Verified by building the image and booting it with only SECRET_KEY_BASE set:
db:prepare succeeds, /up returns 200, and Solid Queue starts in-process.
Submission: Fullstack Developer Challenge - Daniel da Silva
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant