Two correctness gaps on the v1.4.41 onboarding-discovery endpoint,
caught in the v1.4.41 QA round:
1. The route called `identifier.toLowerCase()` before the OR-match on
`username` / `email`, but `registerSchema` applies no transform on
write — both columns are stored exactly as typed. A user registered
as `MixedCase@Example.com` would never resolve through this route,
and iOS onboarding would route them to the sign-up branch despite
an existing account.
2. The route header claimed an edge-level limit on `/api/auth/*`
covered brute-force enumeration; no such middleware exists. Sibling
`/api/auth/passkey/login-options` carries a per-route throttle. We
mirror that shape — `checkRateLimit("auth:check-user:<ip>", 30,
15 * 60 * 1000)` — and rewrite the header comment so the rationale
matches reality.
Tests pin both invariants: a `MixedCase@Example.com` probe hits
`findFirst` with the case preserved, and a 429 short-circuits the
`findFirst` call entirely.
Closes SB-7 follow-up. POST /api/auth/check-user accepts
{ identifier: string } (username or email, case-insensitive) and returns
{ branch, hasPasskey, hasPassword } where branch is one of:
- not_found no account
- passkey_only passkey present, no password
- email_fallback password present (passkey optional)
- exists neither credential (recovery path)
The booleans let the iOS client render a "Sign in with Passkey"
affordance alongside the password field without a second round-trip.
Five-case test pin covers every branch plus the 422 identifier-required
guard. The endpoint mirrors the envelope shape of /api/auth/passkey/
login-options and adds no new enumeration surface beyond that route.
Creates the new src/lib/rollups/ aggregator directory and relocates
the main measurement rollup module:
- src/lib/measurements/rollups.ts → src/lib/rollups/measurement-rollups.ts
- src/lib/measurements/__tests__/rollups.test.ts →
src/lib/rollups/__tests__/measurement-rollups.test.ts
Every importer of @/lib/measurements/rollups is rewritten to
@/lib/rollups/measurement-rollups. The orphan sibling reference inside
src/lib/measurements/rollup-read.ts (still in its old home until the
next batch) is promoted to the @/lib alias so the chain keeps
typechecking through the in-flight move.
Pure relocation — no behaviour change, no exported-surface change. The
umbrella exists so the rollup contract is discoverable from a single
directory before the iOS sprint locks the boundary.
The route is read-only and its envelope shape (`registrationEnabled`
boolean) gates iOS sign-in + web onboarding. Four branches walked:
1. singleton row exists with registrationEnabled: true → reports true
2. singleton row exists with registrationEnabled: false → reports
false (admin-disabled tenant, invite-only)
3. singleton row missing → reports schema default true (fresh self-
hosted instance, no-config baseline)
4. findUnique throws → reports false (fail-closed; critical defense
against a DB blip silently unlocking sign-up on a tenant that
disabled registration)
No code change; the route's current behaviour is correct. Pin it
before someone refactors fail-closed → fail-open by accident.
Five assertions on the route handler:
- 200 on an unauthenticated GET.
- Content-Type is exactly application/json with no charset parameter
(Apple's swcd/aasa-validator refuse a charset and the failure mode
is silent — Universal Links just stop firing).
- Cache-Control allows public CDN mirroring.
- JSON shape matches the SB-4 spec exactly.
- applinks App ID matches webcredentials App ID so a rotated
Team ID never half-lands.
Includes the v1.4.40 phase report.
QA Specialist-H2 (v1.4.39): the original 90-day cap on the per-type
live-fallback loop in `/api/analytics` silently nulled `summarize().avg30LastYear`
(year-ago tile) on every fall-through path because the window stopped at
day 90 and the year-ago aggregate reads days 365-395. Widened the cap to
425 days (365 + 30 for the year-ago bucket span + 30 buffer) so the
year-over-year comparison tile stays populated on cold mounts. The slim
slice and rollup-backed comprehensive aggregator already operate on
tighter windows so this only affects the legacy live-fallback path the
fast-path coverage gate misses. Adjusted the W-SINCE test that pinned
the 90-day cap and updated the route's `meta.analytics.bp_aggregate.live_since`
annotation rationale.
The per-type loop inside `buildAnalyticsResponse` calls
`fetchMeasurementSeriesChunked` without a `since` filter, so a single
default-slice request pulls every measurement ever for the user across
15 chunked rounds. The v1.4.38.8 per-type fast-path gate made this
unreachable in the common case, but a regression on those gates would
re-trigger Marc's 74.6 s cold full-slice mount.
- Add `ANALYTICS_LIVE_WINDOW_DAYS = 90` + `liveSince` constant.
- Thread `since: liveSince` into every per-type chunked read.
- Annotate `meta.analytics.bp_aggregate.live_since` (ISO) so the next
perf-verify can prove the cap is in effect on a live request.
Defense-in-depth only — the rollup fast-path is unchanged because it
never reaches the per-type loop. Worst-case bound drops from ~347 k
rows to ~5 k rows on Marc's tenant (matching the
comprehensive-aggregator + summaries-slice 90-day windows).
Trade-off: `summarize().avg30LastYear` returns null on the fall-through
path; the dashboard's year-over-year overlay shows "no prior data"
until the rollup tier warms. Acceptable on a degraded path that
v1.4.38.8 already made rare.
Tests: new `src/__tests__/api/analytics/since-cap.test.ts` pins the
90-day floor on every per-type findMany, pins the `live_since`
annotate, and pins that the slim slice is untouched. Existing chunked
paging integration test reseeded with timestamps inside the 90-day
window so it still exercises the two-chunk boundary.