alumni_lookup

27.2 — Sign-In Front Door and Rate Limiting

Status: Planned — interviewed August 6, 2026 Surface: Signup (alum-facing) + the shared config/initializers/rack_attack.rb Held surfaces touched: None. The portal throttles are renamed in place, which touches no Cp:: file — see §3 Ships first in Phase 27. See §7 for why this moved ahead of 27.1 Migration: None


1. Why This Goes First

The phase README ordered 27.1 before 27.2. That inverts a dependency the README itself states: Decision D commits the inline email check to shipping “throttled at ~10/hour/IP, fail2ban/throttle-abuse escalates repeat offenders” — but §5 assigns all of that throttle work to 27.2, the sub-phase after the one that introduces the oracle.

Shipping in README order means the account-enumeration endpoint goes live behind nothing but the honeypot gate and the global req/ip 300/5min. Decided August 6, 2026: 27.2 ships first. 27.1 then lands the oracle onto an already-metered surface, and open question 3 (§6) becomes answerable rather than deferred.

Ordering benefit beyond the oracle: §3.4’s /profile-link email-bomb vector is live in production today. It closes in stage 1 instead of stage 2.


2. Scope

# Item Notes
2.1 GET /sign-in — dedicated page wrapping the existing request_link form Shares one partial with the landing form
2.2 Header sign-in link in layouts/signup Grouped with the Belmont logo — see §4
2.3 Rename the three portal throttles; add live-endpoint throttles See §3
2.4 Per-email nudge email when someone ignores the 27.1 check Resolves open question 3 — see §6

The landing page’s inline “email me a link” form stays exactly as it is. It converts today, and people who never look at a header would lose the affordance. Both entry points render the same partial.


3. Rate Limiting

3.1 The portal throttles are renamed, not removed

The README’s §3.4 calls champion-logins/ip, champion-signups/ip, and champion-signups/email “stranded,” which reads as delete. That framing is wrong and the launch guide’s “removed or repointed” line is corrected with it.

/login and /signup are live, reachable Cp:: Devise routes (config/routes.rb:16-33). Those throttles guard a real, internet-facing login form. They are aimed at a surface this phase does not care about; they are not dead code. Repointing them at /sign-up and /profile-link would strip rate limiting off a production login.

Decided: keep all three, rename them to name their surface honestly, and add new throttles for the live endpoints.

Current name New name
champion-logins/ip portal-logins/ip
champion-signups/ip portal-signups/ip
champion-signups/email portal-signups/email

Each gets a comment naming the surface and its held status, so the next person reading rack_attack.rb does not have to re-derive which champion this is. This is CLAUDE.md rule 3 applied to a config file: since 23.4, “champion” in a name carries no routing signal, and these three names actively mislead — they sit in the same file as opportunity-responses/ip, which is the live signup ecosystem.

Rename side effects, verified:

3.2 POST /sign-up cannot take a flat per-IP throttle

This is the finding that most changes the work. POST /sign-up is not a create endpoint — it is the whole multi-step form. STEPS (signups_controller.rb:32) is landing, who_you_are, affinities, where_you_are, question1…question7, quiz_results, role plus the later interest/zip saves. A single alum completing the funnel legitimately POSTs to /sign-up well over ten times.

Mirroring the portal’s 5 per 30 minutes per IP onto this path would 429 the first real person to complete a signup, and would look like a broken form rather than a rate limit. It would also be worst for the most engaged alumni — the ones who answer every question.

So the per-IP throttle is scoped to the creating submit, not the path:

# Only the step that creates a row. Later steps POST to the same path a dozen
# times during one legitimate completion — see STEPS in signups_controller.
throttle("signup-creates/ip", limit: 5, period: 30.minutes) do |req|
  if req.post? && req.path == "/sign-up" &&
     req.params["step"].to_s == "who_you_are" &&
     req.params["signup_token"].blank?   # tokenless == create, not a hub edit
    req.ip
  end
end

The signup_token check mirrors editing_who_you_are? (signups_controller.rb:608): an alum editing their name from the hub carries a token and must not be counted as a create.

The per-email throttle needs no such scoping — later steps do not submit champion_signup[email], so the discriminator returns nil and the throttle skips itself naturally. Verify that in a test rather than trusting it.

3.3 Throttles added

Param keys verified against the controllers: /sign-up uses champion_signup[email] (who_you_are_params, signups_controller.rb:488); /profile-link uses a flat email (signups_controller.rb:192).

Name Limit Discriminator Rationale
signup-creates/ip 5 / 30 min req.ip, scoped per §3.2 Mirrors portal-signups/ip and opportunity-responses/ip
signup-creates/email 3 / 15 min champion_signup[email], downcased + stripped Mirrors portal-signups/email. Precondition for 2.4
profile-link/ip 5 / 15 min req.ip Volume backstop
profile-link/email 3 / 15 min flat email, downcased + stripped This is the §3.4 email-bomb fix. Per-email is the one that matters — per-IP alone still lets one attacker bomb many addresses
signup-email-check/ip 10 / 1 hour req.ip Decision D’s stated limit. Endpoint arrives in 27.1; the throttle ships here so it is never live unmetered

Every discriminator normalizes case and whitespace, matching the existing logins/email comment about case-sensitive bypass (rack_attack.rb:91).

signup-email-check/ip is written against 27.1’s not-yet-existing route. That is deliberate — an unmatched path simply never throttles, so the rule is inert until 27.1 adds the endpoint, and cannot be forgotten at the moment it starts to matter.


4. UI

4.1 The sign-in page

GET /sign-inSignup::SessionsController#new, or a signups#sign_in action. Prefer a new thin controllersignups_controller.rb is already ~630 lines and owns the whole multi-step funnel; a sign-in page is a different job.

The page renders one partial, shared with the landing form, containing the email field, the honeypot (SignupHoneypot::SHORT_FORM_MIN_SECONDS applies — single-field form), and the submit. It posts to the existing POST /profile-link. No controller logic changes in request_link.

The link_sent and link_request states currently redirect to new_signup_path(...) and render as banners on the landing page (steps/_landing.html.erb:9-33). Give them a better home: redirect to /sign-in instead, where the message sits directly above the form that produced it. Keep the landing-page banners working for anyone who submitted from there.

Copy must stay enumeration-neutral — identical response for known and unknown addresses. The existing wording (“If we found your information, we’ve sent a link…”) is already correct; reuse it verbatim rather than rewriting it.

4.2 Header

_header.html.erb is a sticky blue bar, justify-between, with the Champions mark left and the Belmont logo right. There is no nav slot.

Decided: wrap the right side in a flex group — Sign in as a small white text link, then the Belmont logo. Preserves the two-logo structure and the bar height.

Pinned alternative (revisit after seeing it rendered, per the August 6 interview): a slim right-aligned secondary strip below the blue bar. It has more room for 27.3’s recognized-tier states — “Sign out,” “Not you?” — so if the header group feels cramped once 27.3 adds those, that is the fallback. Note this in the 27.3 spec so the decision is re-examined at the point it gets harder, not forgotten.

Do not show the sign-in link when @session_signup is present — that person is already in, and the landing page’s resume_banner already speaks to them.


5. Activity Events

signin_link_sent is recorded where link_requested is today (signups_controller.rb:195). Do not replace link_requested — the README’s §5 says it is unchanged, and existing rows would lose their meaning.

Decision: link_requested stays as the event for the landing-page form; signin_link_sent records sends originating at /sign-in, with the entry point in metadata. If that distinction proves useless at wrap, collapse it then — collapsing later is cheap, splitting a merged history is not.

signin_completed belongs to 27.3, where a session is actually established. Not here.


6. Open Question 3, Resolved

Does ignoring the inline nudge and submitting anyway trigger an email?

Yes — decided August 6, 2026. The README deferred this to 27.2 pending the per-email throttle, and signup-creates/email (§3.3) is that precondition, landing in this sub-phase.

Behavior: the duplicate row is still created and the session still adopts it — Decision C is untouched, and no create-time merge happens. Additionally, the matched address receives a “you already have an account — here’s your link” email.

Why this is safe here and would not have been in README order:

This does not make 27.1’s merge tooling optional. It teaches the person something staff-side merge cannot, at no schema cost — but the row is still a duplicate, and 27.1 still resolves it.

The send is skipped when the honeypot trips, matching request_link’s existing guard.


7. Interaction With 27.1

27.1’s scope was also settled in the August 6 interview and is recorded here because the two sub-phases traded items:

Measured August 6, 2026 against the development database — 203 active signups — so production may differ:

Measure Count
Active signups with no BUID 15 of 203
Of those, rows whose email matches an Alumni record 1
Email-duplicate sets where no row has a BUID (unmergeable today) 1
BUID-duplicate sets outstanding 0
Soft-deleted rows whose email no active row carries 16 of 38
Soft-deleted rows holding an access code 2

The 0 outstanding BUID duplicates is not an argument that the tooling is unnecessary — it is the signature of staff assigning BUIDs proactively, which is what makes duplicates visible at all today. Email keying removes that dependency on a human keeping up.


8. Acceptance Tests

Throttles (test/integration/ — assert the throttle exists and that it does not fire on legitimate traffic; the second half is what §3.2 shows we can get wrong):

Sign-in page:

Header:

Nudge email (§6):


9. Verification

# Confirm the renamed throttles still match the portal's live routes
bin/rails routes | grep -E '(POST)\s+/(signup|login)\b'

# Confirm the live endpoints are what the new throttles target
bin/rails routes | grep -E '(POST)\s+/(sign-up|profile-link)\b'

# Count POSTs a real completion makes — the §3.2 hazard, measured not assumed
grep -c '^\s*[a-z_]*$' /dev/null; ruby -e 'puts %w[who_you_are affinities where_you_are question1 question2 question3 question4 question5 question6 question7 quiz_results role].size'

10. What Was Implemented

Built August 7, 2026. bin/test: 5583 runs, 0 failures, 0 errors, 3 pre-existing skips.

Model handoff: planning and implementation both ran on Opus. The user approved skipping the model switch explicitly (“start building”), per CLAUDE.md’s Model Handoff Workflow allowance.

Sign-in front door

Scope added August 7, 2026 — session adoption and the signed-in header

Added after QA of the first build. The spec shipped a “Sign in” affordance over a mechanism that did not sign anyone in, and the reordering in §1 is what exposed it: 27.2 promotes the front door into the header while champion_signup_sessions — the thing that makes sign-in persist — was assigned to 27.3.

Observed: click the header link, request a link, click it, land on the hub — and the header still read “Sign in”. Clicking the logo made you anonymous again.

Root cause was narrower than “27.3 isn’t built yet”. show was the only entry point that never established a session:

Entry point Adopted the session?
Just signed up (handle_who_you_are) Yes
Access code (#access) Yes
Profile token (#show) — the magic link No

The hub threads its token through its own links via profile_path_for, so the hub worked and nothing else did. That was an inconsistency rather than a policy: the never-expiring access code granted a 2-week session while the 72-hour profile token — a stronger, time-limited proof of the same claim — granted none.

Fixed in three parts:

  1. #show adopts the signup into the session on a valid token. Ordering is load-bearing: arrived_with_session is captured before the adoption, because record_return_visit reads the same key. Token still beats session, so opening someone else’s valid link switches you to their record — matching load_signup’s documented resolution order.
  2. Header renders first name + “Sign out” when signed in. button_to, not link_to method: :delete@rails/ujs is gone and Turbo would silently GET.
  3. #reset gained an intent param. “Start a new signup” on a shared device still lands on the blank form; intent=sign_out lands on the landing page with a confirmation banner. Dropping someone onto a signup form they didn’t ask for is the same “we’ve forgotten you” mistake 19.7 fixed for expired tokens.

No decisions changed. Decision B already defines an active signup session as verified, which grants the full hub, so a first name in the header discloses nothing new. A 2-week session from a verified click is strictly more conservative than the 6-month cookie 27.3 plans. The header partial carries a comment forbidding any further PII, because 27.3’s recognized tier will share it.

Considered and rejected: switching from magic links to an emailed code. The credential mechanism was not the cause — an OTP that doesn’t establish a session produces the identical experience, and a reusable emailed code is access_code, which never expires and already has the §3.3 defect. Cross-device (email on phone, browser on laptop) is the one place OTP genuinely wins; recorded as a possible later addition, not a replacement.

What this leaves for 27.3: duration (6 months), the recognized/verified split for shared devices, the consent checkbox, and device revocation. It is now an upgrade rather than the thing that makes sign-in work.

Deviations from the plan

  1. session_signup was promoted to Signup::BaseController as a helper_method, not duplicated into the new controller as §4.1’s first draft suggested. The header renders on every page of the surface, so an ivar assigned by one controller could not serve it. Signup::SignupsController’s private copy was removed.
  2. link_request now redirects to /sign-in from all three of its origins — the expired-token step guard, the unauthorized hub view, and the unknown access code — rather than only being renderable there. Six existing assertions in signups_controller_test.rb were updated; each test’s intent (“offers a fresh link prompt”) is unchanged, only the URL.
  3. signup-creates/email gained the signup_token.blank? condition that §3.2 said it would not need. The reasoning in the spec was wrong: later steps do skip the throttle on their own, but a hub edit of step 1 submits champion_signup[email], so an alum fixing a typo then their phone then their ZIP would have hit a 3-per-15-minutes cap on their own profile. Caught by the tokenful-edit test.

Two pre-existing bugs found and fixed

Both surfaced from the first test ever written against a throttle in this repo. Neither was introduced by this sub-phase; both are documented in /debug and .github/copilot-instructions.md.

  1. Every throttle returned 500 instead of 429, and every blocklist 500 instead of 403. throttled_responder / blocklisted_responder were written as ->(env), but rack-attack 6.0 passes a Rack::Attack::Request, which has no #[]. So no Retry-After was ever sent and the responders’ own log lines never ran. Regression coverage: test/integration/rack_attack_responder_test.rb.
  2. fail2ban/throttle-abuse never banned anyone. It incremented its counter from inside the blocklist, reading rack.attack.matched — a key no throttle has written yet, because Rack::Attack evaluates blocklists before throttles. The blocklist now only reads Allow2Ban.banned?; the counter is incremented from the throttle.rack_attack subscriber. Decision D’s escalation claim was false until this fix and is now true and tested.

Throttles

All five new rules landed as specified in §3.3, plus the §3.2 scoping and deviation 3 above. The three portal rules were renamed portal-* with a comment naming the surface; test "throttles are named for the surface they actually protect" fails if a champion-* name returns.

Nudge email (§6)

ChampionSignupMailer#existing_signup_notice + text/HTML templates following the canonical eyebrow-and-headline header. Wired in handle_who_you_are via notify_existing_signup; the match is resolved before save, since find_active_by_email takes the newest row and would otherwise return the row just created.

Activity events

signin_link_sent added to ChampionSignupEvent::EVENT_TYPES. link_requested kept for the landing form as planned. The nudge records signin_link_sent against the pre-existing record with entry_point: "duplicate_nudge" and triggered_by_signup_id.

Tests added

File Runs Covers
test/integration/signup_throttling_test.rb 15 §8’s throttle matrix, funnel-not-throttled first
test/integration/rack_attack_responder_test.rb 6 The 429/403 response contract
test/controllers/signup/sessions_controller_test.rb 36 Page, enumeration neutrality, entry-point routing, header, session adoption, sign out
test/controllers/signup/duplicate_nudge_test.rb 14 §6, incl. “the matched record is not modified”

Not done — carried forward