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. +

+
+ ); + } + + if (self.status === "unmatched") { + return ( +
+

+ 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. +

+ ) : ( +

+ {history.length}{" "} + {history.length === 1 ? "event" : "events"} + {bestPosition != null && ( + <> + {" · best finish "} + {bestPosition} + + )} +

+ )} + + View my full stats → + +
+ ); +} + +function Section({ children }: { children: React.ReactNode }) { + return ( +
+

+ My results +

+ {children} +
+ ); +} diff --git a/apps/web/src/lib/driver-self.ts b/apps/web/src/lib/driver-self.ts new file mode 100644 index 0000000..21602dd --- /dev/null +++ b/apps/web/src/lib/driver-self.ts @@ -0,0 +1,122 @@ +/** + * Resolve the signed-in MSR user to their own `Driver` row. + * + * `Driver.msrUid` has been a unique column since the first migration but had + * no write path until now — this module is it. Two keys, in priority order: + * + * 1. `Driver.msrUid` — an explicit link, written once at login by + * `claimSelfDriver`. Authoritative, and survives a later MSR name change. + * 2. `Driver.nameOnlyHash` — sha256 of the full name (`computeNameOnlyHash` + * in lib/pii), which ingest already stamps on every Driver row and the + * OAuth callback computes from the MSR profile. Matched only when + * EXACTLY ONE Driver carries the hash, mirroring ingest's own + * merge/adopt rule: 0 or >=2 candidates means we don't guess. + * + * Reads and writes are deliberately split. `resolveSelfDriver` is pure read, + * so rendering /me carries no side effect; the single write lives in + * `claimSelfDriver`, called from the OAuth callback. + */ + +import type { PrismaClient } from "@/generated/prisma/client"; +import { prisma } from "@/lib/prisma"; +import type { SessionData } from "@/lib/session"; + +export type SelfDriver = + /** This viewer's Driver row. */ + | { status: "linked"; driverId: number; firstName: string; lastInitial: string } + /** No hash match, or an ambiguous one — nothing to link, and we won't guess. */ + | { status: "unmatched" } + /** + * Session predates `nameOnlyHash` (30-day cookie) so there is no key to + * match on. Distinct from "unmatched" because the fix is a re-login, not an + * admin. + */ + | { status: "unlinkable" }; + +/** The session fields resolution needs — keeps callers testable. */ +export type SelfSession = Pick; + +/** + * Find the Driver row belonging to `session`. Read-only: a match found via + * `nameOnlyHash` is NOT persisted here (the next login does that), so this is + * safe to call from a server component. + */ +export async function resolveSelfDriver( + session: SelfSession, + client: PrismaClient = prisma, +): Promise { + if (!session.msrUid) return { status: "unmatched" }; + + const linked = await client.driver.findUnique({ + where: { msrUid: session.msrUid }, + select: { id: true, firstName: true, lastInitial: true }, + }); + if (linked) { + return { + status: "linked", + driverId: linked.id, + firstName: linked.firstName, + lastInitial: linked.lastInitial, + }; + } + + if (!session.nameOnlyHash) return { status: "unlinkable" }; + + // take: 2 is all we need — one row means a confident match, two means + // ambiguity, and we treat both ">=2" cases identically. + const candidates = await client.driver.findMany({ + where: { nameOnlyHash: session.nameOnlyHash }, + select: { id: true, firstName: true, lastInitial: true }, + take: 2, + }); + if (candidates.length !== 1) return { status: "unmatched" }; + + const only = candidates[0]!; + return { + status: "linked", + driverId: only.id, + firstName: only.firstName, + lastInitial: only.lastInitial, + }; +} + +/** + * Best-effort write of `Driver.msrUid` for a user who just logged in. Called + * only from the OAuth callback. + * + * Never throws: a failure here must not break a login, and the read path + * resolves by `nameOnlyHash` regardless, so the link is an optimization plus + * durability against a later name change. + */ +export async function claimSelfDriver( + msrUid: string, + nameOnlyHash: string, + client: PrismaClient = prisma, +): Promise { + try { + // Already linked — nothing to do, and re-claiming would fight the unique + // constraint if this user's name now hashes to a different row. + const existing = await client.driver.findUnique({ + where: { msrUid }, + select: { id: true }, + }); + if (existing) return; + + const candidates = await client.driver.findMany({ + where: { nameOnlyHash, msrUid: null }, + select: { id: true }, + take: 2, + }); + if (candidates.length !== 1) return; + + // updateMany + `msrUid: null` in the WHERE makes a concurrent claim a + // no-op rather than a crash: the loser matches zero rows. + await client.driver.updateMany({ + where: { id: candidates[0]!.id, msrUid: null }, + data: { msrUid }, + }); + } catch { + // Swallowed on purpose — including a unique-constraint collision from a + // racing login. The user still resolves via nameOnlyHash. + } +} diff --git a/apps/web/src/lib/ingest.ts b/apps/web/src/lib/ingest.ts index 1d95149..7027355 100644 --- a/apps/web/src/lib/ingest.ts +++ b/apps/web/src/lib/ingest.ts @@ -4,8 +4,8 @@ import Database from "better-sqlite3"; import { PrismaClient, RunDisposition } from "@/generated/prisma/client"; import { prisma as defaultClient } from "@/lib/prisma"; import { resolveOrCreateSeason, resolveSeasonBySlug } from "@/lib/season-resolve"; -import { redactLastName } from "./pii"; -export { redactLastName }; +import { computeNameOnlyHash, redactLastName } from "./pii"; +export { computeNameOnlyHash, redactLastName }; export type IngestSummary = { status: "ingested" | "unchanged"; @@ -82,16 +82,6 @@ export function computeIdentityHash( return createHash("sha256").update(key).digest("hex"); } -// Full-name-only key, independent of member_num. Used to self-heal legacy .axdb -// exports (e.g. the 2024 AxWare transition) where every driver row has a blank -// member_num, which would otherwise split one human into a distinct Driver per -// event. Never used as the primary identity — only to find merge/adopt -// candidates when an identityHash lookup misses (see the driver-resolution -// block below). -export function computeNameOnlyHash(firstName: string, lastName: string): string { - const key = `${firstName.toLowerCase().trim()}|${lastName.toLowerCase().trim()}`; - return createHash("sha256").update(key).digest("hex"); -} // VisualAX's post-AxWare-transition exports sometimes append a "verified" token // to member_num (`"1234 verified"`, `"1234-verified"`) that isn't present on diff --git a/apps/web/src/lib/pii.ts b/apps/web/src/lib/pii.ts index e4ce0d4..ec42f11 100644 --- a/apps/web/src/lib/pii.ts +++ b/apps/web/src/lib/pii.ts @@ -1,5 +1,24 @@ +import { createHash } from "node:crypto"; + export function redactLastName(name: string): string { const trimmed = name.trim(); if (trimmed.length === 0) return "?."; return trimmed[0]!.toUpperCase() + "."; } + +// Full-name-only key, independent of member_num. Used to self-heal legacy .axdb +// exports (e.g. the 2024 AxWare transition) where every driver row has a blank +// member_num, which would otherwise split one human into a distinct Driver per +// event. Never used as the primary identity — only to find merge/adopt +// candidates when an identityHash lookup misses (see ingest.ts's +// driver-resolution block), and to match a signed-in MSR user to their own +// Driver row (src/lib/driver-self.ts). +// +// Lives here rather than in ingest.ts so callers that only need the digest — +// notably the OAuth callback — don't drag ingest.ts's better-sqlite3 import +// into their bundle. ingest.ts re-exports it, so its existing importers are +// unaffected. +export function computeNameOnlyHash(firstName: string, lastName: string): string { + const key = `${firstName.toLowerCase().trim()}|${lastName.toLowerCase().trim()}`; + return createHash("sha256").update(key).digest("hex"); +} diff --git a/apps/web/src/lib/session.ts b/apps/web/src/lib/session.ts index 41e89dc..48f1b83 100644 --- a/apps/web/src/lib/session.ts +++ b/apps/web/src/lib/session.ts @@ -2,13 +2,14 @@ * iron-session typed wrappers for Launch Control. * * Two cookies: - * lc_session — main session (30 days). Stores the seven SessionData fields. + * lc_session — main session (30 days). Stores the SessionData fields. * lc_msr_req — transient request-token cookie (10 min, path-scoped to * the callback route). Stashes oauth_token_secret between * /api/auth/msr/login and /api/auth/msr/callback. * * PII rule: full lastName is NEVER stored here. The callback route applies - * redactLastName() and stores only lastInitial. + * redactLastName() and stores only lastInitial, plus a one-way nameOnlyHash + * digest (see the field's doc comment). */ import { getIronSession } from "iron-session"; @@ -57,6 +58,18 @@ export interface SessionData { isRmrMember?: boolean; /** MSR org IDs from the login profile — enables per-league org gating (PR 3). */ msrOrgIds?: string[]; + /** + * sha256("|", lowercased/trimmed) — the join key to + * `Driver.nameOnlyHash`, computed at login from the MSR profile before the + * full last name is discarded. This is what lets `/me` find the viewer's own + * Driver row (src/lib/driver-self.ts). + * + * PII: a one-way digest, NOT the surname — the identical value is already a + * column on every Driver row. The full last name is still never stored here. + * Absent on sessions minted before this field shipped (30-day cookie); those + * viewers get the "sign in again" empty state until they re-login. + */ + nameOnlyHash?: string; } // --------------------------------------------------------------------------- diff --git a/apps/web/tests/driver-self.test.ts b/apps/web/tests/driver-self.test.ts new file mode 100644 index 0000000..32f8a77 --- /dev/null +++ b/apps/web/tests/driver-self.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; +import { rmSync } from "node:fs"; +import { PrismaClient } from "@/generated/prisma/client"; +import { PrismaLibSql } from "@prisma/adapter-libsql"; +import { dbTarget, migrateDeploy } from "./helpers/db"; +import { computeNameOnlyHash } from "@/lib/pii"; +import { claimSelfDriver, resolveSelfDriver } from "@/lib/driver-self"; + +const { path: DB_PATH, url: DB_URL } = dbTarget("driver-self"); +let client: PrismaClient; + +const HASH = computeNameOnlyHash("Alex", "Ada"); + +/** Minimal Driver row — identityHash is the only other required unique field. */ +async function makeDriver(opts: { + firstName?: string; + lastInitial?: string; + identityHash: string; + nameOnlyHash?: string | null; + msrUid?: string | null; + memberNum?: string | null; +}) { + return client.driver.create({ + data: { + firstName: opts.firstName ?? "Alex", + lastInitial: opts.lastInitial ?? "A.", + identityHash: opts.identityHash, + nameOnlyHash: opts.nameOnlyHash ?? null, + msrUid: opts.msrUid ?? null, + memberNum: opts.memberNum ?? null, + }, + }); +} + +beforeAll(async () => { + rmSync(DB_PATH, { force: true }); + migrateDeploy(DB_URL); + client = new PrismaClient({ adapter: new PrismaLibSql({ url: DB_URL }) }); +}); +afterAll(async () => { + await client.$disconnect(); + rmSync(DB_PATH, { force: true }); +}); +beforeEach(async () => { + await client.driver.deleteMany({}); +}); + +describe("resolveSelfDriver", () => { + it("returns unmatched when the session has no msrUid", async () => { + expect(await resolveSelfDriver({ nameOnlyHash: HASH }, client)).toEqual({ + status: "unmatched", + }); + }); + + it("prefers an explicit Driver.msrUid link over the name hash", async () => { + // The linked row carries a DIFFERENT name hash, so a hash-only resolver + // would pick the other row — this asserts msrUid wins. + const linked = await makeDriver({ + firstName: "Alexandra", + identityHash: "id-linked", + nameOnlyHash: computeNameOnlyHash("Alexandra", "Ada"), + msrUid: "U1", + }); + await makeDriver({ identityHash: "id-hash", nameOnlyHash: HASH }); + + expect(await resolveSelfDriver({ msrUid: "U1", nameOnlyHash: HASH }, client)).toEqual({ + status: "linked", + driverId: linked.id, + firstName: "Alexandra", + lastInitial: "A.", + }); + }); + + it("matches on nameOnlyHash when exactly one driver carries it", async () => { + const d = await makeDriver({ identityHash: "id-1", nameOnlyHash: HASH }); + expect(await resolveSelfDriver({ msrUid: "U1", nameOnlyHash: HASH }, client)).toEqual({ + status: "linked", + driverId: d.id, + firstName: "Alex", + lastInitial: "A.", + }); + }); + + it("returns unmatched when two drivers share the name hash", async () => { + await makeDriver({ identityHash: "id-1", nameOnlyHash: HASH }); + await makeDriver({ identityHash: "id-2", nameOnlyHash: HASH }); + expect(await resolveSelfDriver({ msrUid: "U1", nameOnlyHash: HASH }, client)).toEqual({ + status: "unmatched", + }); + }); + + it("returns unmatched when no driver carries the name hash", async () => { + await makeDriver({ identityHash: "id-1", nameOnlyHash: computeNameOnlyHash("Bo", "Bea") }); + expect(await resolveSelfDriver({ msrUid: "U1", nameOnlyHash: HASH }, client)).toEqual({ + status: "unmatched", + }); + }); + + it("returns unlinkable for a session minted before nameOnlyHash shipped", async () => { + await makeDriver({ identityHash: "id-1", nameOnlyHash: HASH }); + expect(await resolveSelfDriver({ msrUid: "U1" }, client)).toEqual({ + status: "unlinkable", + }); + }); + + it("does not match a legacy driver whose nameOnlyHash is null", async () => { + await makeDriver({ identityHash: "id-1", nameOnlyHash: null }); + expect(await resolveSelfDriver({ msrUid: "U1", nameOnlyHash: HASH }, client)).toEqual({ + status: "unmatched", + }); + }); +}); + +describe("claimSelfDriver", () => { + it("writes msrUid onto the single hash match", async () => { + const d = await makeDriver({ identityHash: "id-1", nameOnlyHash: HASH }); + await claimSelfDriver("U1", HASH, client); + expect((await client.driver.findUniqueOrThrow({ where: { id: d.id } })).msrUid).toBe("U1"); + }); + + it("leaves both rows alone when the hash is ambiguous", async () => { + await makeDriver({ identityHash: "id-1", nameOnlyHash: HASH }); + await makeDriver({ identityHash: "id-2", nameOnlyHash: HASH }); + await claimSelfDriver("U1", HASH, client); + expect(await client.driver.count({ where: { msrUid: { not: null } } })).toBe(0); + }); + + it("does not steal a row already claimed by a different user", async () => { + const d = await makeDriver({ identityHash: "id-1", nameOnlyHash: HASH, msrUid: "OTHER" }); + await claimSelfDriver("U1", HASH, client); + expect((await client.driver.findUniqueOrThrow({ where: { id: d.id } })).msrUid).toBe("OTHER"); + }); + + it("is a no-op when this user is already linked to another row", async () => { + const linked = await makeDriver({ + identityHash: "id-linked", + nameOnlyHash: computeNameOnlyHash("Alexandra", "Ada"), + msrUid: "U1", + }); + const other = await makeDriver({ identityHash: "id-hash", nameOnlyHash: HASH }); + + await claimSelfDriver("U1", HASH, client); + + expect((await client.driver.findUniqueOrThrow({ where: { id: linked.id } })).msrUid).toBe("U1"); + expect((await client.driver.findUniqueOrThrow({ where: { id: other.id } })).msrUid).toBeNull(); + }); + + it("is a no-op, and does not throw, when nothing carries the hash", async () => { + await expect(claimSelfDriver("U1", HASH, client)).resolves.not.toThrow(); + expect(await client.driver.count({ where: { msrUid: { not: null } } })).toBe(0); + }); +}); diff --git a/docs/BUILD.md b/docs/BUILD.md index 7267678..c0c71b6 100644 --- a/docs/BUILD.md +++ b/docs/BUILD.md @@ -131,12 +131,12 @@ model Event { model Driver { id Int @id @default(autoincrement()) - msrUid String? @unique + msrUid String? @unique // set at MSR login by claimSelfDriver() — links a signed-in user to their own Driver row. Null until that user logs in (or forever, for a driver who never does). firstName String lastInitial String // single uppercase letter + period, e.g. "K." — never the full last name; enforced at ingest identityHash String @unique // SHA-256 of `${memberNum ?? ''}|${firstName.toLowerCase().trim()}|${lastName.toLowerCase().trim()}` — cross-event person identity. Full last name is used transiently for the hash and never persisted. memberNum String? // family/account-level in VisualAX; NOT person-unique. Stored for display/lookup only — the upsert key is identityHash. - nameOnlyHash String? // sha256(lower(first)|lower(last)) — full-name key, the FALLBACK when an identityHash lookup misses (blank-member .axdb rows, and every RMsolo entry). Nullable: pre-existing rows can't be backfilled, since the full surname is never stored. + nameOnlyHash String? // sha256(lower(first)|lower(last)) — full-name key, the FALLBACK when an identityHash lookup misses (blank-member .axdb rows, and every RMsolo entry), and the key that matches a signed-in MSR user to this row. Nullable: pre-existing rows can't be backfilled, since the full surname is never stored. entries Entry[] videos Video[] @@ -524,7 +524,7 @@ M2 ships full MSR OAuth 1.0a sign-in end-to-end: a three-legged OAuth handshake - `oauth-1.0a` (npm) + Node `crypto` for HMAC-SHA1 request signing per RFC 5849. - `iron-session` for encrypted session cookies (App Router-native, stateless, AES-256-GCM). No NextAuth/Auth.js — v5 explicitly deprioritizes OAuth 1.0a, and a single-provider MVP doesn't earn back the dep weight or upgrade tax. -**Session shape (`apps/web/src/lib/session.ts`):** `SessionData` has six fields: `msrUid`, `firstName`, `lastInitial`, `accessToken`, `accessTokenSecret`, `isRmrMember`. Note: `profileId` from the original M2 plan was dropped — `msrUid` (the `id` field from `/rest/me.json`) is the authoritative user identifier; the speculative `tokenData["memberid"]` parsing was removed. +**Session shape (`apps/web/src/lib/session.ts`):** `SessionData` shipped with six fields: `msrUid`, `firstName`, `lastInitial`, `accessToken`, `accessTokenSecret`, `isRmrMember`. Two were added later: `msrOrgIds` (PR 3, per-league org gating) and `nameOnlyHash` (see "My results" below). Note: `profileId` from the original M2 plan was dropped — `msrUid` (the `id` field from `/rest/me.json`) is the authoritative user identifier; the speculative `tokenData["memberid"]` parsing was removed. **Two cookies:** - `lc_session` — 30-day sliding window, HttpOnly + Secure(prod) + SameSite=Lax. Carries the full session (MSR UID, name initials, tokens, membership flag). @@ -549,8 +549,8 @@ M2 ships full MSR OAuth 1.0a sign-in end-to-end: a three-legged OAuth handshake **`/rest/me.json` shape pinned as `MsrMeResponse` in `apps/web/src/lib/msr.ts`:** double-wrapped `{ response: { profile: { id, firstName, lastName, email, avatar, organizations: [{ id, memberId, name }] } } }`. `id` and `organizations[].id` are uppercase-hex UUIDs with dashes. **Pages:** -- `/login` — public; renders an error message from `?error=`; "Sign in with MotorsportReg" is a `` to `/api/auth/msr/login`. -- `/me` — server component; redirects to `/login` if `msrUid` is missing; renders `firstName lastInitial` + monospace MSR UID + RMR-membership badge + logout form. +- `/login` — public; renders an error message from `?error=`; "Sign in with MotorsportReg" is a plain `` to `/api/auth/msr/login`. It must **not** be a ``: the target is a Route Handler that 302s cross-origin to MSR, so the App Router client fetches an RSC payload for it, fails on the redirect ("Failed to fetch RSC payload … Falling back to browser navigation"), and re-navigates — running OAuth step 1 twice per click, minting two request tokens and writing `lc_msr_req` twice. `components/landing.tsx`'s sign-in button is a plain `` for the same reason. +- `/me` — server component; redirects to `/login` if `msrUid` is missing; renders `firstName lastInitial` + monospace MSR UID + RMR-membership badge + logout form, and (added later) a "My results" card linking to the viewer's own driver stats page. **Header nav (`apps/web/src/components/header-nav.tsx`):** server component reading `getSession()`; signed-in users see their display name as a `` to `/me`; signed-out users see a "Sign in" link. Integrated into `apps/web/src/app/layout.tsx`. @@ -910,6 +910,29 @@ Verified against the published table: the 2026 grouping is line-for-line identic `DriverHistoryRow` gained `seasonYear`/`seasonSlug`, and `eventDetailInclude`/`combinedSessionInclude` gained `season.year`/`season.name`/`season.slug`: classing is per (league, season), the driver page's rows can span both, and a season may straddle a calendar year, so it is not derivable from `Event.date`. The **year** selects the vehicle lines (the upstream rulebook is written per calendar year, so two Season rows sharing a year — a main season and a winter series — correctly resolve to the same table); the **slug** addresses the guide, so a card's "Full classing guide →" opens `?season=` rather than the league's active one, which on a historical event could class the same car differently. `classingKey` is therefore `(leagueSlug, seasonSlug)`. +### My results — self-service driver stats from `/me` ✓ (done 2026-09-15) + +Driver feedback: reaching your own stats meant opening an event or the championship and finding yourself in a results table. Clicking your own name in the header should just take you there. + +**The header already linked to `/me`** — what `/me` lacked was any idea which `Driver` row the viewer was. `Driver.msrUid` had been a unique column since the `init` migration with **no read or write path anywhere in the codebase**; this is what finally uses it. No migration was needed. + +**Two keys, in priority order** (`src/lib/driver-self.ts`): + +1. `Driver.msrUid` — an explicit link, written once at login. Authoritative, and survives a later MSR name change. +2. `Driver.nameOnlyHash` — matched only when **exactly one** Driver carries the hash, mirroring ingest's own merge/adopt rule: 0 or ≥2 candidates means we don't guess. Two humans sharing a full name therefore resolve to nothing rather than to each other's results, and the `@unique` on `msrUid` means one MSR user can only ever own one row. + +**The full-name hash is what makes this possible without storing a surname.** The OAuth callback holds `profile.lastName` transiently before discarding it, so it computes the same `computeNameOnlyHash` digest that ingest already stamps on every Driver row, and keeps it in the session as `nameOnlyHash`. A one-way digest is not the surname, the identical value is already a DB column, and the cookie is AES-256-GCM encrypted — the PII rule (never persist a full last name) is intact. + +**`computeNameOnlyHash` moved from `ingest.ts` to `pii.ts`** (re-exported, so existing importers are untouched). `ingest.ts` top-level-imports `better-sqlite3`; importing the digest from there would have pulled a native SQLite driver into the OAuth callback's bundle. + +**Reads and writes are split deliberately.** `resolveSelfDriver` is pure read, so rendering `/me` carries no side effect; the single write is `claimSelfDriver`, called from the callback and wrapped so it can never fail a login. A guarded `updateMany` (`where: { id, msrUid: null }`) makes a racing second login a no-op rather than a unique-constraint crash. + +**Three states on `/me`**, because "we couldn't find you" has three different fixes: `linked` (event count + best finish + a link to the stats page), `unmatched` (0 or ≥2 candidates — usually a nickname mismatch, "Bob" vs "Robert"; needs an admin), and `unlinkable` (session predates `nameOnlyHash` — needs a re-login, since the 30-day cookie carries no key to match on). + +**Where the link points** depends on the leagues the driver actually has entries in, because `/drivers/[id]` gates on the *deployment default* league: no footprint in the default league → `/l//drivers/` (gated on the league that actually holds their results); more than one league → `/drivers/?league=all`; otherwise the plain route. Cross-league aggregation needed no new code — `buildDriverHistory` already accepts `leagueIds: "all"` and the driver page's filter already understands the param. + +Covered by `apps/web/tests/driver-self.test.ts` (12 cases across both functions, including ambiguity, a row already claimed by another user, and the legacy-null-`nameOnlyHash` row). + ### M3 — Public calendar (target: 0.5 session, after M2) - `/calendar` server-fetches `/rest/calendars/organization/{RMR_ORG_ID}`. diff --git a/docs/PRD.md b/docs/PRD.md index 2194e55..9fc18cf 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -27,7 +27,7 @@ A streamlined, high-performance web platform for the Porsche Club of America Roc ### 1.2 Personas -- **Driver / Competitor** — wants a clean mobile-responsive results dashboard (raw / PAX / class), and a way to view or share event media. May appear in more than one league; driver stats can be filtered per-league or aggregated across all of them. +- **Driver / Competitor** — wants a clean mobile-responsive results dashboard (raw / PAX / class), and a way to view or share event media. May appear in more than one league; driver stats can be filtered per-league or aggregated across all of them, and are reachable directly from their own name in the header. - **League Admin / Timing Chief** — wants frictionless MSR login, a dead-simple way to publish a post-event `.axdb` so leaderboards appear immediately, and a way to fix upload mistakes (bad metadata, duplicates) without touching the database. Administers **one** league: its events, members, seasons, and rulesets. - **Superuser / Operator** — deployment-wide. Stands up new leagues, grants league admins, and administers every league. A superuser granted in-app can be revoked in-app; one bootstrapped from the env allowlist can only be removed by editing that env var. @@ -41,6 +41,7 @@ A streamlined, high-performance web platform for the Porsche Club of America Roc - **Signed session cookie** — HttpOnly, SameSite=Lax, signed, keyed on the MSR user UID returned by `/rest/me`. - **Per-league access gate** — each League sets its own `accessGate` (`required` / `optional` / `none`), so one deployment can host a members-only league and a fully public one side by side. For a `required` gate, access resolves in this exact order: superuser → allow; `BLOCKED` membership → deny; `ADMIN`/`MEMBER` membership → allow; gate is not `required` → allow; the session's MSR org ids include the league's org → allow; otherwise redirect to sign-in. Unauthenticated and non-member visitors see a landing page describing what they'll unlock. Deep links round-trip through OAuth via `?returnTo=`, sanitized against open-redirect. Membership is captured at login (`msrOrgIds`), so gating needs no live MSR call. - **Two-tier roles** — a **superuser** is global (bootstrapped from the `ADMIN_MSR_UIDS` env allowlist, or granted a `SuperUser` row) and administers every league; the allowlist bootstrap cannot be revoked from inside the app. Per-league roles live on `LeagueMembership` (`ADMIN` / `MEMBER` / `BLOCKED`) and are managed from the admin UI — no direct DB editing. +- **"My results" — your own stats under your own name** — a signed-in driver reaches their own stats page from the header, rather than opening an event or the championship and finding themselves in a results table. `/me` resolves the viewer to their `Driver` row and links to it, spanning every league they've run in. The match is made on a one-way hash of the full name computed at login (never the surname itself, which is still never persisted) and only when it is unambiguous — a driver whose results carry a different name than their MSR profile, or who shares a full name with another competitor, is told so plainly rather than shown someone else's results. - **Dynamic public calendar (M3 — not yet shipped)** — `/calendar` will fetch the league's MSR event calendar server-side and cache it for 5 minutes. #### 1.3.2 VisualAX `.axdb` ingestion @@ -91,7 +92,7 @@ Answers the question a prospective entrant asks before anything else: *what clas - **Type safety:** `"strict": true` everywhere; `any` is forbidden. CI runs `tsc --noEmit`. - **Ingestion correctness:** integration test ingests the synthetic `apps/web/tests/fixtures/synthetic.axdb` (committed) and asserts driver counts, run counts, class PAX multipliers, and that every persisted `Driver.lastInitial` matches `/^[A-Z?]\./`. A regex sweep on the dumped DB confirms no full last name beyond the first character appears in any Driver, Entry, or Run row. -- **PII rule:** the full last name of any driver is used only transiently to compute the identity hash and must never be persisted to the app DB or appear in any leaderboard rendering. +- **PII rule:** the full last name of any driver is used only transiently to compute the identity hash (at ingest) and the full-name hash (at ingest, and at MSR login to match a signed-in user to their own `Driver` row), and must never be persisted to the app DB, the session cookie, or any leaderboard rendering. A one-way digest of a name is not the name; storing the digest is in scope, storing the surname is not. - **Data invariants (RMR / AxWare convention):** - **One class per driver per event.** A human enters each event in at most one car class. Co-drives are modeled by VisualAX as separate `drivers` rows with a number-suffix convention (`337` + `337X`, `62` + `162`) and resolve to separate app `Driver` records via the identity-hash dedupe at ingest (see `apps/web/src/lib/ingest.ts`). The schema is permissive (`Entry` has no `(eventId, driverId)` uniqueness, to handle a club whose convention differs) but RMR real data has never violated this invariant. Season scoring (M1.14) depends on it: the "one championship class per driver" arithmetic guarantee (`2 × qualifyingThreshold > N`) only holds when this invariant holds. - **Combined (same-date, multi-session) events (M1.15).** Events sharing a calendar date are auto-grouped into one scoring event — no schema change, no ingest flag, no admin linking step. A combined event counts **once** toward the season's event totals. A driver earns points only when they have a countable (CLEAN, per `bestCorrectedMsForEntry`) time in **every** session of the group, **in the same class**; class mismatch across sessions or a missing session forfeits that scoring group entirely (fail-safe — shouldn't occur under RMR convention). The redaction/PII rule applies identically to the combined-standings page. `Season.plannedEvents` and actual scoring groups determine the displayed season size; `Season.minimumEvents` independently controls Official vs. Provisional eligibility.