diff --git a/AGENTS.md b/AGENTS.md
index 606faf9..5d1e2a3 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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("|")`, 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("|")`, 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 ` 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/.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.
diff --git a/apps/web/src/app/api/auth/msr/callback/route.ts b/apps/web/src/app/api/auth/msr/callback/route.ts
index 4957069..e7b1a84 100644
--- a/apps/web/src/app/api/auth/msr/callback/route.ts
+++ b/apps/web/src/app/api/auth/msr/callback/route.ts
@@ -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
@@ -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";
@@ -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;
@@ -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
diff --git a/apps/web/src/app/login/page.tsx b/apps/web/src/app/login/page.tsx
index b1d34cb..fedcca5 100644
--- a/apps/web/src/app/login/page.tsx
+++ b/apps/web/src/app/login/page.tsx
@@ -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 , 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 = {
@@ -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.
-
Sign in with MotorsportReg
-
+
diff --git a/apps/web/src/app/me/page.tsx b/apps/web/src/app/me/page.tsx
index 0b9c117..618f437 100644
--- a/apps/web/src/app/me/page.tsx
+++ b/apps/web/src/app/me/page.tsx
@@ -2,22 +2,55 @@
* /me — authenticated user profile page.
*
* Reads session via getSession(). Redirects to /login if not signed in.
- * Renders first name + last initial, MSR UID, RMR membership badge, and a
- * logout form. The cookie is the source of truth — no live MSR re-fetch in MVP.
+ * Renders first name + last initial, MSR UID, RMR membership badge, a
+ * "My results" card linking to this viewer's own driver stats page, and a
+ * logout form. The cookie is the source of truth for identity — no live MSR
+ * re-fetch — but the results card does hit the DB to resolve the Driver row.
*/
import type { Metadata } from "next";
+import Link from "next/link";
import { redirect } from "next/navigation";
import { getSession } from "@/lib/session";
+import { resolveSelfDriver } from "@/lib/driver-self";
+import { buildDriverHistory, listSeasonsForDriver } from "@/lib/driver-history";
+import { getLeagueConfig } from "@/lib/league-config";
import { Card, CardAction, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { CloseButton } from "@/components/close-button";
import { Badge } from "@/components/ui/badge";
-import { Button } from "@/components/ui/button";
+import { Button, buttonVariants } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
export const metadata: Metadata = {
title: "My Profile",
};
+export const dynamic = "force-dynamic";
+
+/**
+ * Where "View my full stats" points.
+ *
+ * `/drivers/[id]` gates on the DEPLOYMENT's default league (requireRmrMember),
+ * so a viewer whose results live only in another league would bounce off it.
+ * When none of their leagues is the default one, send them to that league's
+ * own scoped route instead, which gates on the league that actually holds
+ * their results. Otherwise use the legacy route, widened to every league via
+ * `?league=all` when they've run in more than one (the driver page's own
+ * filter already understands that param).
+ */
+function statsHref(
+ driverId: number,
+ leagueSlugs: string[],
+ defaultLeagueSlug: string,
+): string {
+ if (leagueSlugs.length > 0 && !leagueSlugs.includes(defaultLeagueSlug)) {
+ return `/l/${leagueSlugs[0]}/drivers/${driverId}`;
+ }
+ return leagueSlugs.length > 1
+ ? `/drivers/${driverId}?league=all`
+ : `/drivers/${driverId}`;
+}
+
export default async function MePage() {
const session = await getSession();
@@ -26,6 +59,7 @@ export default async function MePage() {
}
const displayName = `${session.firstName ?? ""} ${session.lastInitial ?? ""}`.trim();
+ const self = await resolveSelfDriver(session);
return (
@@ -36,7 +70,7 @@ export default async function MePage() {
-
+
{session.isRmrMember ? (
RMR member
@@ -44,6 +78,9 @@ export default async function MePage() {
Non-member
)}
+
+
+
MSR UID: {session.msrUid}
@@ -59,3 +96,92 @@ export default async function MePage() {
);
}
+
+/**
+ * "My results" section. Split out so the three states read as one branch each
+ * rather than as nested ternaries inside the card.
+ */
+async function MyResults({
+ self,
+}: {
+ self: Awaited>;
+}) {
+ if (self.status === "unlinkable") {
+ return (
+
+
+ Sign out and back in to link your event results to your profile.
+
+ We haven't matched you to any event results yet. This usually means the
+ name on the results differs from your MSR profile — ask an event admin to
+ link them up.
+
+
+ );
+ }
+
+ // These three reads don't depend on each other; against Turso each is a
+ // network round trip, so issue them together.
+ const [driverSeasons, history, defaultLeague] = await Promise.all([
+ listSeasonsForDriver(self.driverId),
+ buildDriverHistory(self.driverId, { leagueIds: "all" }),
+ getLeagueConfig(),
+ ]);
+
+ // Same "best finish" definition the driver page uses: only events where the
+ // driver actually posted a scoring position count.
+ const cleanRows = history.filter((r) => r.position != null);
+ const bestPosition =
+ cleanRows.length === 0
+ ? null
+ : Math.min(...cleanRows.map((r) => r.position as number));
+
+ const leagueSlugs = Array.from(new Set(driverSeasons.map((s) => s.leagueSlug)));
+ const href = statsHref(self.driverId, leagueSlugs, defaultLeague.slug);
+
+ return (
+
+ {history.length === 0 ? (
+
+ No event results yet — your stats page will fill in after your first event.
+