Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,19 +64,30 @@ where `memberNum` is the trimmed VisualAX `member_num` with any trailing `verifi

Why all three fields rather than just `member_num`: VisualAX's `member_num` is family/account-level, so distinct people can share one, and a co-driver may carry the primary's `member_num` or an empty one. Folding in first+last name disambiguates those. Two `.axdb` rows that hash to the same value are treated as one `Driver` (last write wins within a single source); a matching hash already in the DB updates that existing row instead of inserting.

**Self-healing for blank-`member_num` sources:** some 2024 AxWare-transition exports have `member_num` blank for every driver, and RMsolo results PDFs never print one at all, which would otherwise split one human into separate `Driver` rows. `Driver.nameOnlyHash` (`sha256("<firstName>|<lastName>")`, lowercased/trimmed, nullable — pre-existing rows can't be backfilled since the full surname is never stored) gives ingest a full-name-only key to fall back on whenever an `identityHash` lookup misses:
**Self-healing for blank-`member_num` sources:** some 2024 AxWare-transition exports have `member_num` blank for every driver, and RMsolo results PDFs never print one at all, which would otherwise split one human into separate `Driver` rows. `Driver.nameOnlyHash` (`sha256("<firstName>|<lastName>")`, lowercased/trimmed, nullable — pre-existing rows can't be backfilled since the full surname is never stored) gives ingest a full-name-only key to fall back on whenever an `identityHash` lookup misses. It is also what matches a signed-in MSR user to their own row — see "Self driver link" below.
- A **blank** row (normalized `memberNum` is null) merges into the existing **populated** `Driver` sharing its `nameOnlyHash`, instead of creating a new row.
- A **populated** row "**adopts**" a pre-existing **blank** `Driver` sharing its `nameOnlyHash`, updating that row's `memberNum`/`identityHash`/`firstName`/`lastInitial` in place — so a later, better-identified export of the same legacy-era human still lands on the same row.
- Both only merge/adopt when there is **exactly one** candidate; 0 or ≥2 (e.g. two different populated drivers who happen to share a full name) leaves the status quo — a new, separate `Driver` row — rather than guessing. Because resolution only sees data ingested so far, ingesting chronologically maximizes merge confidence.
- **Both ingest paths do this.** `src/lib/ingest.ts` (`.axdb`) runs the full merge *and* adopt. `src/lib/rmsolo-ingest.ts` is blank-member by construction, so it only needs the merge half, and it leaves the populated row it merges into untouched. Since `Driver` rows are not league-scoped, a PCA ingest is what gives the RMsolo path a populated row to find: every PCA event that attaches a `member_num` to a shared human makes the next RMsolo ingest resolve rather than duplicate.
- **DB:** Prisma 7 with `provider = "sqlite"`. Client in `src/lib/prisma.ts` picks the `PrismaLibSql` (Turso) adapter when `TURSO_DATABASE_URL` is set, else local `DATABASE_URL` (`file:./dev.db`). Schema + SQL migrations under `apps/web/prisma/`. Models: `Event`, `Driver`, `CarClass`, `Entry` (including its `paxIndexApplied` PAX snapshot column, see below), `Run`, `Video`, `AdminAuditLog`, `League`, `ScoringSystem`, `Season`, `LeagueMembership`, `SuperUser`. `Video` is schema-only (no write path yet — reserved for the future media hub); don't build features that assume video rows exist.
- **App:** Next.js App Router, server components by default; gated routes use `export const dynamic = "force-dynamic"`. Legacy (default-league) routes: `/events/[slug]`, `/leaderboard[/year]`, `/drivers/[id]`, `/admin` + `/admin/ingest`, `/me`. Multi-league public browsing: `/leagues` (directory of every `League` row), `/l/[league]` (league home = events list), `/l/[league]/leaderboard[/s/[seasonSlug]]`, `/l/[league]/events/[slug]` — all league-scoped, gated on *that* league's own `accessGate`. The one public league-scoped route is `/l/[league]/classing` (legacy alias `/classing`), which takes no gate at all — see the classing bullet below. Leaderboard logic lives in `src/lib/leaderboard.ts`, `season-leaderboard.ts`, `entry-best.ts`, `driver-history.ts` — pages call these, not inline Prisma aggregation.
- **App:** Next.js App Router, server components by default; gated routes use `export const dynamic = "force-dynamic"`. Legacy (default-league) routes: `/events/[slug]`, `/leaderboard[/year]`, `/drivers/[id]`, `/admin` + `/admin/ingest`, `/me` (profile + a "My results" card linking to the viewer's own driver stats — see "Self driver link" below). Multi-league public browsing: `/leagues` (directory of every `League` row), `/l/[league]` (league home = events list), `/l/[league]/leaderboard[/s/[seasonSlug]]`, `/l/[league]/events/[slug]` — all league-scoped, gated on *that* league's own `accessGate`. The one public league-scoped route is `/l/[league]/classing` (legacy alias `/classing`), which takes no gate at all — see the classing bullet below. Leaderboard logic lives in `src/lib/leaderboard.ts`, `season-leaderboard.ts`, `entry-best.ts`, `driver-history.ts` — pages call these, not inline Prisma aggregation.
- **Tenant/scoring model (`src/lib/league-config.ts`, `src/lib/league-resolve.ts`, `src/lib/scoring-policy.ts`):** tenant identity is DB data, not config — `League` (one row per club/tenant: branding, access gate, MSR org, SmugMug lookup), `Season` (one per league-year, addressed by a `slug` unique within its league — `resolveSeasonBySlug`/`activeSeason` in `src/lib/season-resolve.ts` — with `plannedEvents`, `minimumEvents`, and a required live `rulesetId` reference), and `ScoringSystem` (UI: Ruleset; named per league, e.g. "PCA Classic"; owns drop count/timing, cone penalty, PAX-section behavior, the **per-event points system**, and the complete PAX table). Editing a ruleset's policy immediately affects every assigned season; PAX-table edits affect existing entries only after the explicit per-season re-apply action. Qualification is intentionally independent of score drops: `Season.minimumEvents` decides Official vs. Provisional, while `ScoringPolicy.dropCount` decides how many scores are discarded. `DEFAULT_LEAGUE_SLUG` (env, default `pca-rmr`) selects which `League` row the **legacy, unprefixed routes** serve — legacy env vars (`MSR_ORG_ID`/`MSR_RMR_ORG_ID`, `SMUGMUG_USER`/`SMUGMUG_DISCIPLINE_PATH`) are honored only as a fallback when the League row leaves a field `null`. A deployment can host multiple `League` rows at once (see `/leagues`/`/l/[league]` above), each independently ingestable via `--league <slug>` on both ingest CLIs. Create leagues with `pnpm --filter web league:create` (`src/lib/create-league.ts`), seasons with `pnpm --filter web season:create` (`src/lib/create-season.ts`); ingest auto-creates a bare Season the first time it sees an event year with none.

**Two-tier role model (`src/lib/admin.ts`, `src/lib/super-user.ts`, `src/lib/membership.ts`):** a **superuser** is global — bootstrapped irrevocably from the `ADMIN_MSR_UIDS` env allowlist (checked first, no DB read) or granted via a `SuperUser` row — and administers every league. Per-league roles live on `LeagueMembership` (`(leagueId, msrUid)` unique, `role` = `ADMIN` / `MEMBER` / `BLOCKED`), written via the admin UI (`/admin/leagues/[slug]/members`) and its REST route — no more manual `prisma studio` edits needed. `isLeagueAdmin(msrUid, leagueId)` (superuser OR that league's ADMIN row) gates one league's admin actions; `isAnyLeagueAdmin(msrUid)` (superuser OR ADMIN of any league) gates the coarse `/admin` entry point; `administeredLeagues(msrUid)` feeds the `/admin` league index.

**Access decision chain (`decideLeagueAccess`, `src/lib/league-access.ts`, exact order):** superuser → allow; `BLOCKED` membership → deny; `ADMIN`/`MEMBER` membership → allow; `accessGate !== "required"` → allow; MSR org match (`session.msrOrgIds` includes the league's `msrOrgId`) → allow; else → redirect. `checkLeagueAccess`/`requireMember` (`src/lib/session.ts`) resolve this per-league, replacing the old default-league-only `isRmrMember` flag. **Both temporary guards that once refused a `"required"` gate on any non-default league are deleted** now that real per-league membership gating exists: `league-config.ts`'s `toLeagueConfig` no longer throws for that combination, and `league:create --gate required` is no longer refused (still defaults to `"optional"` when `--gate` is omitted). See `docs/BUILD.md`'s "League Admin (PR 3)" section for the full chain and PAX-snapshot semantics.
- **Auth:** MSR (MotorsportReg) OAuth 1.0a (`src/lib/msr.ts`, `msr-endpoints.ts`) + `iron-session` cookies (`src/lib/session.ts`). The session now carries `msrOrgIds` (every MSR org the user belongs to, captured at login) alongside `msrUid`/`isRmrMember`, so per-league org-match gating (above) doesn't need a fresh MSR API call. Admin gating is the two-tier `isLeagueAdmin`/`isAnyLeagueAdmin`/`isSuperUser` model above, not a flat `ADMIN_MSR_UIDS`-only check. Full surnames are never stored in the session.
- **Auth:** MSR (MotorsportReg) OAuth 1.0a (`src/lib/msr.ts`, `msr-endpoints.ts`) + `iron-session` cookies (`src/lib/session.ts`). The session now carries `msrOrgIds` (every MSR org the user belongs to, captured at login) alongside `msrUid`/`isRmrMember`, so per-league org-match gating (above) doesn't need a fresh MSR API call, plus `nameOnlyHash` (see "Self driver link" below). Admin gating is the two-tier `isLeagueAdmin`/`isAnyLeagueAdmin`/`isSuperUser` model above, not a flat `ADMIN_MSR_UIDS`-only check. Full surnames are never stored in the session.

### Self driver link (`Driver.msrUid`)

`src/lib/driver-self.ts` resolves a signed-in user to their own `Driver` row, which is what the "My results" card on `/me` renders. Two keys, in priority order:

1. **`Driver.msrUid`** — an explicit link. Written **only** by `claimSelfDriver()`, called from the MSR OAuth callback; authoritative, and survives a later MSR name change. Null until that user logs in.
2. **`Driver.nameOnlyHash`** — matched only when **exactly one** `Driver` carries the hash, the same "exactly one candidate or don't guess" rule ingest uses for merge/adopt. 0 or ≥2 candidates resolves to nothing rather than guessing.

The join key exists because the OAuth callback computes `computeNameOnlyHash(profile.firstName, profile.lastName)` while it still has the full surname, and stores that digest in the session. **`computeNameOnlyHash` lives in `src/lib/pii.ts`, not `ingest.ts`** (which re-exports it) — `ingest.ts` top-level-imports `better-sqlite3`, so importing the digest from there drags a native module into any bundle that needs only the hash.

**Reads and writes are split on purpose:** `resolveSelfDriver()` is pure read so a page render carries no side effect; `claimSelfDriver()` is the lone write, is wrapped so it can never fail a login, and guards its `updateMany` on `msrUid: null` so a racing login is a no-op instead of a unique-constraint crash. Don't add a write to the read path.
- **PAX scoring reads an entry snapshot, not a live class join:** `Entry.paxIndexApplied` is stamped once at ingest (both pipelines) with the PAX factor in effect at that moment; `appliedPaxIndex()` (`src/lib/pax-applied.ts`) is what every scoring path (`leaderboard.ts`, `season-leaderboard.ts`) reads, falling back to the live `entry.paxClass.paxIndex` join only for the rare pre-snapshot row. `CarClass.paxIndex` itself remains the live/current factor — written fresh on every ingest and used as the ingest-time source for the snapshot — but never read directly by scoring once an entry has its own snapshot. The assigned ruleset owns the complete PAX table; `reapplySeasonPaxFactors()` re-stamps matching entries for one selected season after a table edit.
- **Vehicle classing is checked-in repo data, not DB rows** (`src/lib/classing.ts`, `classing-registry.ts`, `src/data/classing/<league-slug>.json`): which car runs in which class, per league, per season. Unlike `League`/`Season`/`ScoringSystem` — per-deployment tenant config that belongs in the DB — a classing table is a published rulebook that changes about once a season, wants PR review, and is read on every page that draws a class badge, where a DB lookup would cost a Turso round trip for data that never varies between deployments. `classing.ts` is pure (shape, validation, per-season grouping, lookup); `classing-registry.ts` owns the JSON imports and the league-slug registry, and is a separate module only so `scripts/classing-import.ts` can reuse the validator to *write* the files it reads. Upstream for `pca-rmr` is the YAML in `enginerdify/rmr-pca-classing` (also the source of the static table at rmr.pca.org) — re-run `classing:import` after pulling a new revision and commit the JSON diff, checking the regenerated table still matches the published one. A vehicle with no `years:` block imports as `years: null` meaning *every* year (upstream's `non-Porsche` → `TO` row) — not a parse error, and not the same as an open-ended `to`. Adding a league is one JSON file plus one line in the registry; a league without one gets no `/classing` page (404), no subnav tab, and no class hover cards, all silently and correctly. Surfaces: the public `/l/[league]/classing` page (plus the legacy `/classing` alias for the default league) and the hover cards on `ClassBadge` (`src/components/class-badge.tsx`, the single class-badge component every results table now uses). **The classing page is deliberately ungated** — no PII, no results, and it is what a prospective entrant reads before deciding to show up; every results route keeps its existing gate.
- **The per-event points formula is ruleset policy (`ScoringPolicy` v4 `points`), not a constant:** `{ type: "ratio1000", basis: "class" }` scores each class section against its own fastest, so every class winner earns 1000 (PCA). `{ type: "ratio1000", basis: "event" }` scores every driver against the event's fastest indexed time, so a driver earns exactly **one** score per event, reused in their class section and in the synthetic PAX section — RMsolo's published rule, under which only the overall PAX winner ever scores 1000. `{ type: "position", table, beyondTable, basis }` maps finishing position onto an ordered table, with tied times sharing the higher position's points. The arithmetic lives in `awardPoints` (`src/lib/event-points.ts`), a pure function over a "lower is better" metric map; `basis` never reaches it, because the caller picks the population by choosing which map to pass. Section **membership** is independent of all of this — only the points value changes.
Expand Down
21 changes: 18 additions & 3 deletions apps/web/src/app/api/auth/msr/callback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
* 6. Apply PII rule: compute lastInitial via redactLastName; discard full lastName.
* 7. Compute isRmrMember from org list (display only — when the default
* league has org config at all).
* 8. Persist the SessionData fields in the main session cookie.
* 8. Persist the SessionData fields in the main session cookie, then
* best-effort claim this user's Driver row (Driver.msrUid).
* 9. 302 to the re-validated returnTo, else "/" (the league card grid).
*
* This callback is deliberately league-AGNOSTIC (PR #99 review): it neither
Expand All @@ -33,7 +34,8 @@ import { parseFormEncoded, signRequest, signedMsrFetch } from "@/lib/msr";
import type { MsrMeResponse } from "@/lib/msr";
import { getRequestTokenSession, getSession, sanitizeReturnTo } from "@/lib/session";
import { getLeagueConfig } from "@/lib/league-config";
import { redactLastName } from "@/lib/pii";
import { computeNameOnlyHash, redactLastName } from "@/lib/pii";
import { claimSelfDriver } from "@/lib/driver-self";

export const runtime = "nodejs";

Expand Down Expand Up @@ -111,11 +113,17 @@ export async function GET(request: NextRequest) {
// redactLastName is imported from lib/pii (not redefined here).
const lastInitial = redactLastName(profile.lastName);

// Same PII rule, one extra derivation: sha256 the full name into the
// key that Driver rows already carry (Driver.nameOnlyHash), so /me can
// find this viewer's own Driver row without anyone storing a surname.
// The digest is one-way; profile.lastName still dies with this scope.
const nameOnlyHash = computeNameOnlyHash(profile.firstName, profile.lastName);

// 7. Default-league org membership — a display flag (/me badge), not an
// authorization input; per-league gates use session.msrOrgIds instead.
const isRmrMember = orgId != null && profile.organizations.some((o) => o.id === orgId);

// 8. Persist session — only the seven approved fields; full lastName is never stored.
// 8. Persist session — only the approved fields; full lastName is never stored.
const session = await getSession();
session.msrUid = profile.id;
session.firstName = profile.firstName;
Expand All @@ -124,8 +132,15 @@ export async function GET(request: NextRequest) {
session.accessTokenSecret = accessSecret;
session.isRmrMember = isRmrMember;
session.msrOrgIds = profile.organizations.map((o) => o.id);
session.nameOnlyHash = nameOnlyHash;
await session.save();

// 8b. Best-effort: link this MSR user to their Driver row (Driver.msrUid).
// Login is the only write path for that column — /me resolves read-only,
// so no page render carries a side effect. Never allowed to fail a
// login: claimSelfDriver swallows its own errors.
await claimSelfDriver(profile.id, nameOnlyHash);

// 9. Redirect to the re-validated (same-origin, path-only) returnTo, else
// "/". Not gated on org membership: the destination page enforces its
// own league's access rules server-side, and gating here stranded
Expand Down
11 changes: 8 additions & 3 deletions apps/web/src/app/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,15 @@
* Reads ?error= from the query string and shows contextual copy.
* Renders a "Sign in with MotorsportReg" button that links to the
* OAuth login route handler.
*
* That button is a plain <a>, NOT next/link: the target is a Route Handler
* that 302s to MSR (cross-origin), so the App Router client would try to
* fetch an RSC payload for it, fail on the cross-origin redirect, and fall
* back to a browser navigation — running OAuth step 1 twice per click and
* minting two request tokens. See also components/landing.tsx.
*/

import type { Metadata } from "next";
import Link from "next/link";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";

export const metadata: Metadata = {
Expand Down Expand Up @@ -45,12 +50,12 @@ export default async function LoginPage({ searchParams }: PageProps) {
Use your MotorsportReg account to sign in. You do not need a
separate password.
</p>
<Link
<a
href="/api/auth/msr/login"
className="inline-flex w-full items-center justify-center rounded-lg border border-transparent bg-primary px-2.5 text-sm font-medium text-primary-foreground transition-all hover:bg-primary/80 h-8"
>
Sign in with MotorsportReg
</Link>
</a>
</CardContent>
</Card>
</main>
Expand Down
Loading