Submission: Fullstack Developer Challenge - Daniel da Silva - #35
Open
DanielDz21 wants to merge 69 commits into
Open
Submission: Fullstack Developer Challenge - Daniel da Silva#35DanielDz21 wants to merge 69 commits into
DanielDz21 wants to merge 69 commits into
Conversation
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.
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.mdfor full setup, seeding, environment variables, architecturedecisions, and security notes.
Highlights
Cable, backed by Solid Cache to avoid recomputation.
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.
private/loopback/link-local ranges on every redirect hop, with bounded
redirects, a content-type allowlist, and a streamed size cutoff.
html_safe/raw/sanitizeanywhere), CSRF protection, rate-limited authendpoints, strict params on every controller. Covered by
spec/requests/security_spec.rb(SQLi/XSS/CSRF) plus manual verification.minimum_coverageenforced at 90%), parallelized viaparallel_tests,system specs with real Turbo Stream/Action Cable delivery via Playwright.
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.mdfor the full,honest account.
Test plan
bundle exec rspec— 162 examples, 0 failuresbundle exec rubocop— 0 offensesbundle exec brakeman -q --no-pager— 0 warningsbundle exec bundler-audit check— 0 vulnerabilitiesbin/rails db:seedthen sign in as both seeded users (see README)update live in a second browser session; import a spreadsheet and watch
progress update live