From 916839eb9e1a656b10540e92e6c77879450f960f Mon Sep 17 00:00:00 2001 From: Kyle Kolodziej Date: Tue, 14 Apr 2026 16:21:48 -0700 Subject: [PATCH] security + code quality hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unrelated to any new feature — bundle of security fixes, input validation, URL encoding, and code cleanups discovered during review of the repo. Security: - Validate logo school slug with /^[a-z0-9-]+$/i (prevents path traversal) - Encode all GraphQL URL parameters with encodeURIComponent (previously raw string-interpolated into the URL) - Add AbortSignal.timeout(10000) to all external fetch calls - Add X-Content-Type-Options: nosniff and X-Frame-Options: DENY headers - Add res.ok check to /schools-index before parsing JSON - Fix getDivisionCode returning raw user input as fallback — now throws - Fix /schedule-alt crash on invalid sport key access - Year validation on /brackets (/^\d{4}$/ regex) - Wrap getTodayUrl() in try-catch in /scoreboard handler Correctness: - Fix cache key collision: `path + page` could collide (e.g. /stats/foo1 vs /stats/foo?page=1 both produce "/stats/foo1"). Now uses "?page=N" separator. - Fix JSON.stringify(undefined) in /game/:id when upstream data missing: now returns 502 with a descriptive message - Fix hash-retry loop guard: replace confusing `hashes.length > 2` check with explicit index counter capped at maxAttempts Code cleanups: - Remove dead commented-out code (cache_5m, shouldUseNewEndpoint, unused Contest game properties) - Log errors in all catch blocks instead of swallowing them - Remove redundant cache.has() check in catch-all route (already done in onBeforeHandle) - Fix invalid OpenAPI parameter placement on /game/{id}/scoring-summary, /game/{id}/team-stats, /schedule-alt, and /logo (parameters were at path level instead of inside the `get:` operation object) - Fix /logo `dark` query param schema type (boolean -> string; HTTP query params are strings and the handler compares as string) Tests: all 24 existing tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/codes.ts | 14 ++-- src/index.ts | 137 ++++++++++++++++++++--------------- src/openapi.ts | 118 +++++++++++++++--------------- src/scoreboard/scoreboard.ts | 14 +--- 4 files changed, 148 insertions(+), 135 deletions(-) diff --git a/src/codes.ts b/src/codes.ts index 4f4b20a..f5fea98 100644 --- a/src/codes.ts +++ b/src/codes.ts @@ -175,7 +175,11 @@ export const getDivisionCode = (sport: string, division: string) => { if (!sportData) { throw errNotSupported(sport, division); } - return sportData.divisions[division as keyof typeof sportData.divisions] ?? division; + const code = sportData.divisions[division as keyof typeof sportData.divisions]; + if (code === undefined) { + throw errNotSupported(sport, division); + } + return code; }; export const supportedSports = Object.keys(newCodesBySport); @@ -196,7 +200,7 @@ const errNotSupported = (sport: string, division: string) => * This is more reliable than the today.json endpoint for football. */ async function getDateFromScoreboardPage(sport: string, division: string): Promise { - const response = await fetch(`https://www.ncaa.com/scoreboard/${sport}/${division}`); + const response = await fetch(`https://www.ncaa.com/scoreboard/${sport}/${division}`, { signal: AbortSignal.timeout(10000) }); if (!response.ok) { return null; } @@ -250,10 +254,10 @@ export async function getScheduleBySportAndDivision(sport: string, division: Div return todayScoreboardPage; } - const url = `https://sdataprod.ncaa.com/?extensions={"persistedQuery":{"version":1,"sha256Hash":"a25ad021179ce1d97fb951a49954dc98da150089f9766e7e85890e439516ffbf"}}&queryName=NCAA_schedules_today_web&variables={"sportCode":"${sportData.code - }","division":${divisionCode},"seasonYear":${getSeasonYear(new Date())}}`; + const variables = { sportCode: sportData.code, division: divisionCode, seasonYear: getSeasonYear(new Date()) }; + const url = `https://sdataprod.ncaa.com/?extensions=${encodeURIComponent(JSON.stringify({ persistedQuery: { version: 1, sha256Hash: "a25ad021179ce1d97fb951a49954dc98da150089f9766e7e85890e439516ffbf" } }))}&queryName=NCAA_schedules_today_web&variables=${encodeURIComponent(JSON.stringify(variables))}`; - const req = await fetch(url); + const req = await fetch(url, { signal: AbortSignal.timeout(10000) }); if (!req.ok) { throw new Error(`Failed to fetch schedule: ${req.statusText}`); } diff --git a/src/index.ts b/src/index.ts index bccdcb0..87a9323 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,9 +26,6 @@ const cache_30m = new ExpiryMap(30 * 60 * 1000); // 45 second cache for scores const cache_45s = new ExpiryMap(1 * 45 * 1000); -// 5 minute cache for brackets -// const cache_5m = new ExpiryMap(5 * 60 * 1000); - // valid routes for the app with their respective caches const validRoutes = new Map([ ["stats", cache_30m], @@ -62,9 +59,13 @@ export const app = new Elysia() .get("/", ({ redirect }) => redirect("/openapi"), { detail: { hide: true } }) // fetch and return logo svg .get("/logo/:school", async ({ params: { school }, query: { dark }, set, status }) => { + const slug = school.replace(".svg", ""); + if (!/^[a-z0-9-]+$/i.test(slug)) { + return status(400, "Invalid school slug"); + } const bgParam = dark !== undefined && dark !== "false" ? "bgd" : "bgl"; - const url = `https://www.ncaa.com/sites/default/files/images/logos/schools/${bgParam}/${school.replace(".svg", "")}.svg`; - const res = await fetch(url); + const url = `https://www.ncaa.com/sites/default/files/images/logos/schools/${bgParam}/${slug}.svg`; + const res = await fetch(url, { signal: AbortSignal.timeout(10000) }); if (!res.ok) { return status(404, "Logo not found"); @@ -75,7 +76,8 @@ export const app = new Elysia() set.headers["Content-Type"] = "image/svg+xml"; set.headers["Cache-Control"] = "public, max-age=604800"; return svgContent; - } catch (_) { + } catch (err) { + log(`Error fetching logo: ${err}`); return status(500, "Error fetching data"); } }, @@ -106,19 +108,24 @@ export const app = new Elysia() return { basePath, cache: validRoutes.get(basePath) ?? cache_45s, - cacheKey: path + (page ?? ""), + cacheKey: page ? `${path}?page=${page}` : path, }; }) .onBeforeHandle(({ set, cache, cacheKey }) => { set.headers["Content-Type"] = "application/json"; set.headers["Cache-Control"] = `public, max-age=${cache === cache_45s ? 60 : 1800}`; + set.headers["X-Content-Type-Options"] = "nosniff"; + set.headers["X-Frame-Options"] = "DENY"; if (cache.has(cacheKey)) { return cache.get(cacheKey); } }) // schools-index route to return list of all schools .get("/schools-index", async ({ cache, cacheKey, status }) => { - const req = await fetch("https://www.ncaa.com/json/schools"); + const req = await fetch("https://www.ncaa.com/json/schools", { signal: AbortSignal.timeout(10000) }); + if (!req.ok) { + return status(502, "Error fetching data"); + } try { const json = (await req.json()).map((school: Record) => ({ slug: school.slug, @@ -128,7 +135,8 @@ export const app = new Elysia() const data = JSON.stringify(json); cache.set(cacheKey, data); return data; - } catch (_) { + } catch (err) { + log(`Error fetching schools: ${err}`); return status(500, "Error fetching data"); } }, @@ -141,7 +149,7 @@ export const app = new Elysia() } const url = `https://www.ncaa.com/news/${params.sport}/${params.division}/rss.xml`; - const res = await fetch(url); + const res = await fetch(url, { signal: AbortSignal.timeout(10000) }); if (!res.ok) { return status(404, "RSS feed not found"); @@ -205,7 +213,8 @@ export const app = new Elysia() const data = JSON.stringify(result); cache.set(cacheKey, data); return data; - } catch (_) { + } catch (err) { + log(`Error parsing RSS feed: ${err}`); return status(500, "Error parsing RSS feed"); } }, @@ -216,12 +225,17 @@ export const app = new Elysia() // game route to retrieve game details .get("/:id", async ({ cache, cacheKey, status, params: { id } }) => { const req = await fetch( - `https://sdataprod.ncaa.com/?meta=GetGamecenterGameById_web&extensions={%22persistedQuery%22:{%22version%22:1,%22sha256Hash%22:%2293a02c7193c89d85bcdda8c1784925d9b64657f73ef584382e2297af555acd4b%22}}&variables={%22id%22:%22${id}%22,%22week%22:null,%22staticTestEnv%22:null}` + `https://sdataprod.ncaa.com/?meta=GetGamecenterGameById_web&extensions=${encodeURIComponent('{"persistedQuery":{"version":1,"sha256Hash":"93a02c7193c89d85bcdda8c1784925d9b64657f73ef584382e2297af555acd4b"}}')}&variables=${encodeURIComponent(JSON.stringify({ id: String(id), week: null, staticTestEnv: null }))}`, + { signal: AbortSignal.timeout(10000) } ); if (!req.ok) { return status(404, "Resource not found"); } - const data = JSON.stringify((await req.json())?.data); + const json = await req.json(); + if (!json?.data) { + return status(502, "Invalid upstream response"); + } + const data = JSON.stringify(json.data); cache.set(cacheKey, data); return data; }, @@ -234,12 +248,13 @@ export const app = new Elysia() ) .get("/:id/boxscore", async ({ cache, cacheKey, status, params: { id } }) => { const hashes = [boxscoreHashes.TeamStatsBasketball]; - for (const hash of hashes) { - if (!hash || hashes.length > 2) { - continue; - } + const maxAttempts = 2; + for (let i = 0; i < hashes.length && i < maxAttempts; i++) { + const hash = hashes[i]; + if (!hash) continue; const req = await fetch( - `https://sdataprod.ncaa.com/?extensions={"persistedQuery":{"version":1,"sha256Hash":"${hash}"}}&variables={"contestId":"${id}","staticTestEnv":null}` + `https://sdataprod.ncaa.com/?extensions=${encodeURIComponent(JSON.stringify({ persistedQuery: { version: 1, sha256Hash: hash } }))}&variables=${encodeURIComponent(JSON.stringify({ contestId: String(id), staticTestEnv: null }))}`, + { signal: AbortSignal.timeout(10000) } ); if (!req.ok) { continue; @@ -272,12 +287,13 @@ export const app = new Elysia() ) .get("/:id/play-by-play", async ({ cache, cacheKey, status, params: { id } }) => { const hashes = [playByPlayHashes.PlayByPlayGenericSport]; - for (const hash of hashes) { - if (!hash || hashes.length > 2) { - continue; - } + const maxAttempts = 2; + for (let i = 0; i < hashes.length && i < maxAttempts; i++) { + const hash = hashes[i]; + if (!hash) continue; const req = await fetch( - `https://sdataprod.ncaa.com/?extensions={"persistedQuery":{"version":1,"sha256Hash":"${hash}"}}&variables={"contestId":"${id}","staticTestEnv":null}` + `https://sdataprod.ncaa.com/?extensions=${encodeURIComponent(JSON.stringify({ persistedQuery: { version: 1, sha256Hash: hash } }))}&variables=${encodeURIComponent(JSON.stringify({ contestId: String(id), staticTestEnv: null }))}`, + { signal: AbortSignal.timeout(10000) } ); if (!req.ok) { continue; @@ -312,7 +328,8 @@ export const app = new Elysia() .get("/:id/scoring-summary", async ({ cache, cacheKey, status, params: { id } }) => { const hash = "7f86673d4875cd18102b7fa598e2bc5da3f49d05a1c15b1add0e2367ee890198"; const req = await fetch( - `https://sdataprod.ncaa.com/?extensions={"persistedQuery":{"version":1,"sha256Hash":"${hash}"}}&variables={"contestId":"${id}","staticTestEnv":null}` + `https://sdataprod.ncaa.com/?extensions=${encodeURIComponent(JSON.stringify({ persistedQuery: { version: 1, sha256Hash: hash } }))}&variables=${encodeURIComponent(JSON.stringify({ contestId: String(id), staticTestEnv: null }))}`, + { signal: AbortSignal.timeout(10000) } ); if (req.ok) { const json = await req.json(); @@ -333,12 +350,13 @@ export const app = new Elysia() ) .get("/:id/team-stats", async ({ cache, cacheKey, status, params: { id } }) => { const hashes = [teamStatsHashes.TeamStatsBasketball]; - for (const hash of hashes) { - if (!hash || hashes.length > 2) { - continue; - } + const maxAttempts = 2; + for (let i = 0; i < hashes.length && i < maxAttempts; i++) { + const hash = hashes[i]; + if (!hash) continue; const req = await fetch( - `https://sdataprod.ncaa.com/?extensions={"persistedQuery":{"version":1,"sha256Hash":"${hash}"}}&variables={"contestId":"${id}","staticTestEnv":null}` + `https://sdataprod.ncaa.com/?extensions=${encodeURIComponent(JSON.stringify({ persistedQuery: { version: 1, sha256Hash: hash } }))}&variables=${encodeURIComponent(JSON.stringify({ contestId: String(id), staticTestEnv: null }))}`, + { signal: AbortSignal.timeout(10000) } ); if (!req.ok) { continue; @@ -379,12 +397,15 @@ export const app = new Elysia() return status(400, "Invalid sport or division"); } + const yearInt = parseInt(params.year, 10); + if (!/^\d{4}$/.test(params.year) || Number.isNaN(yearInt)) { + return status(400, "Invalid year"); + } + const variables = { sportUrl: params.sport, division: Number(divisionCode), - year: parseInt(params.year, 10), - // showUnstaged: false, - // staticTestEnv: null, + year: yearInt, }; const extensions = { persistedQuery: { @@ -396,7 +417,7 @@ export const app = new Elysia() JSON.stringify(variables) )}&extensions=${encodeURIComponent(JSON.stringify(extensions))}`; - const req = await fetch(url); + const req = await fetch(url, { signal: AbortSignal.timeout(10000) }); if (!req.ok) { return status(404, "Resource not found"); @@ -410,7 +431,8 @@ export const app = new Elysia() const data = JSON.stringify(json.data); cache.set(cacheKey, data); return data; - } catch (_) { + } catch (err) { + log(`Error parsing brackets response: ${err}`); return status(502, "Error parsing upstream response"); } }, @@ -432,7 +454,8 @@ export const app = new Elysia() const urlPathSegments = [params.sport, params.division, params.year, params.month] const urlPath = urlPathSegments.filter(Boolean).join("/"); const req = await fetch( - `https://data.ncaa.com/casablanca/schedule/${urlPath}/schedule-all-conf.json` + `https://data.ncaa.com/casablanca/schedule/${urlPath}/schedule-all-conf.json`, + { signal: AbortSignal.timeout(10000) } ); if (!req.ok) { @@ -446,16 +469,19 @@ export const app = new Elysia() { detail: { hide: true } } ) .get("/schedule-alt/:sport/:division/:year", async ({ cache, cacheKey, params, status }) => { - const sportCode = newCodesBySport[params.sport as keyof typeof newCodesBySport].code; - if (!sportCode) { + const sportData = newCodesBySport[params.sport as keyof typeof newCodesBySport]; + if (!sportData) { return status(400, "Invalid sport"); } - const divisionCode = getDivisionCode(params.sport, params.division); - if (!divisionCode) { + let divisionCode: string | number; + try { + divisionCode = getDivisionCode(params.sport, params.division); + } catch (_) { return status(400, "Invalid division"); } - const url = `https://sdataprod.ncaa.com/?extensions={"persistedQuery":{"version":1,"sha256Hash":"a25ad021179ce1d97fb951a49954dc98da150089f9766e7e85890e439516ffbf"}}&queryName=NCAA_schedules_today_web&variables={"sportCode":"${sportCode}","division":${divisionCode},"seasonYear":${params.year}}`; - const req = await fetch(url); + const variables = { sportCode: sportData.code, division: divisionCode, seasonYear: parseInt(params.year, 10) || params.year }; + const url = `https://sdataprod.ncaa.com/?extensions=${encodeURIComponent(JSON.stringify({ persistedQuery: { version: 1, sha256Hash: "a25ad021179ce1d97fb951a49954dc98da150089f9766e7e85890e439516ffbf" } }))}&queryName=NCAA_schedules_today_web&variables=${encodeURIComponent(JSON.stringify(variables))}`; + const req = await fetch(url, { signal: AbortSignal.timeout(10000) }); if (!req.ok) { return status(404, "Resource not found"); @@ -494,7 +520,12 @@ export const app = new Elysia() } } else { // if date not in passed in url, fetch date from today.json - urlDate = await getTodayUrl(params.sport, division); + try { + urlDate = await getTodayUrl(params.sport, division); + } catch (err) { + log(`Error fetching today URL: ${err}`); + return status(400, "Could not determine date for scoreboard"); + } } const scoreboardDate = new Date(urlDate); @@ -574,7 +605,7 @@ export const app = new Elysia() return cache.get(url); } // fetch data - const res = await fetch(url); + const res = await fetch(url, { signal: AbortSignal.timeout(10000) }); if (!res.ok) { return status(404, "Resource not found"); } @@ -593,10 +624,6 @@ export const app = new Elysia() ) // all other routes fetch data by scraping ncaa.com .get("/*", async ({ query: { page }, path, cache, cacheKey }) => { - if (cache.has(cacheKey)) { - return cache.get(cacheKey); - } - // fetch data const data = JSON.stringify(await getData({ path, page })); cache.set(cacheKey, data); return data; @@ -611,17 +638,6 @@ log(`Server is running at ${app.server?.url}`); /////////////////////////////// FUNCTIONS //////////////////////////////////// ////////////////////////////////////////////////////////////////////////////// -/** - * Check if this is a D1 football request that should use new endpoint - * @param sport - sport parameter - * @param division - division parameter - * @param year - year from URL (optional) - * @returns boolean indicating if new endpoint should be used - */ -// function shouldUseNewEndpoint(sport: string, division: string, year?: string) { -// return sport === "football" && division === "fbs" && year === "2025"; -// } - /** * Fetch proper url date for today from ncaa.com * @param sport - sport to fetch @@ -644,7 +660,8 @@ async function getTodayUrl(sport: string, division: string): Promise { // Fall through to old endpoint logic } const req = await fetch( - `https://data.ncaa.com/casablanca/schedule/${sport}/${division}/today.json` + `https://data.ncaa.com/casablanca/schedule/${sport}/${division}/today.json`, + { signal: AbortSignal.timeout(10000) } ); if (!req.ok) { throw new NotFoundError(JSON.stringify({ message: "Resource not found" })); @@ -664,7 +681,7 @@ async function getData(opts: { path: string; page?: string }) { const url = `https://www.ncaa.com${opts.path}${opts.page && Number(opts.page) > 1 ? `/p${opts.page}` : "" }`; log(`Fetching ${url}`); - const res = await fetch(url); + const res = await fetch(url, { signal: AbortSignal.timeout(10000) }); if (!res.ok) { throw new NotFoundError(JSON.stringify({ message: "Resource not found" })); diff --git a/src/openapi.ts b/src/openapi.ts index a364f7b..ead63c2 100644 --- a/src/openapi.ts +++ b/src/openapi.ts @@ -128,16 +128,16 @@ export const openapiSpec = openapi({ summary: "Game scoring summary", description: "Due to upstream changes, some seasons may not return data for game subroutes. Please open an issue if you encounter this.", + parameters: [ + { + name: "id", + in: "path", + schema: { type: "number" }, + required: true, + examples: makeExamples(["6459218", "6305900"]), + }, + ] as OpenAPIV3.ParameterObject[], }, - parameters: [ - { - name: "id", - in: "path", - schema: { type: "number" }, - required: true, - examples: makeExamples(["6459218", "6305900"]), - }, - ] as OpenAPIV3.ParameterObject[], }, "/game/{id}/team-stats": { get: { @@ -145,16 +145,16 @@ export const openapiSpec = openapi({ summary: "Game team stats", description: "Due to upstream changes, some seasons may not return data for game subroutes. Please open an issue if you encounter this.", + parameters: [ + { + name: "id", + in: "path", + schema: { type: "number" }, + required: true, + examples: makeExamples(["6459218", "6305900"]), + }, + ] as OpenAPIV3.ParameterObject[], }, - parameters: [ - { - name: "id", - in: "path", - schema: { type: "number" }, - required: true, - examples: makeExamples(["6459218", "6305900"]), - }, - ] as OpenAPIV3.ParameterObject[], }, "/stats/{sport}/{division}/{year}/{path}": { get: { @@ -369,30 +369,30 @@ export const openapiSpec = openapi({ responses: {}, summary: "Schedule (alt)", description: "Game dates for a given sport and division and year.", + parameters: [ + { + name: "sport", + in: "path", + schema: { type: "string" }, + required: true, + examples: sportExamples, + }, + { + name: "division", + in: "path", + schema: { type: "string" }, + required: true, + examples: divisionExamples, + }, + { + name: "year", + in: "path", + schema: { type: "string" }, + required: true, + examples: makeExamples(["2025", "2024"]), + }, + ], }, - parameters: [ - { - name: "sport", - in: "path", - schema: { type: "string" }, - required: true, - examples: sportExamples, - }, - { - name: "division", - in: "path", - schema: { type: "string" }, - required: true, - examples: divisionExamples, - }, - { - name: "year", - in: "path", - schema: { type: "string" }, - required: true, - examples: makeExamples(["2025", "2024"]), - }, - ], }, "/schools-index": { get: { @@ -407,25 +407,25 @@ export const openapiSpec = openapi({ responses: {}, summary: "Logos", description: "Logos for all NCAA schools. Use the school `slug` or `team_seo` property.", + parameters: [ + { + name: "school", + in: "path", + schema: { type: "string" }, + required: true, + examples: makeExamples(["michigan", "slippery-rock", "iowa"]), + }, + { + name: "dark", + in: "query", + schema: { type: "string" }, + required: false, + examples: makeExamples(["false", "true"]), + description: + "Set to `true` to get a version of the logo that works better on dark backgrounds.", + }, + ], }, - parameters: [ - { - name: "school", - in: "path", - schema: { type: "string" }, - required: true, - examples: makeExamples(["michigan", "slippery-rock", "iowa"]), - }, - { - name: "dark", - in: "query", - schema: { type: "boolean" }, - required: false, - examples: makeExamples(["false", "true"]), - description: - "Set to `true` to get a version of the logo that works better on dark backgrounds.", - }, - ], }, }, }, diff --git a/src/scoreboard/scoreboard.ts b/src/scoreboard/scoreboard.ts index f75d487..ac2a32b 100644 --- a/src/scoreboard/scoreboard.ts +++ b/src/scoreboard/scoreboard.ts @@ -17,9 +17,9 @@ const instance_id = createHash("md5").digest("hex"); * Fetch scoreboard data from new NCAA GraphQL endpoint */ export async function fetchGqlScoreboard(params: NewScoreboardParams) { - const url = `https://sdataprod.ncaa.com/?extensions={"persistedQuery":{"version":1,"sha256Hash":"7287cda610a9326931931080cb3a604828febe6fe3c9016a7e4a36db99efdb7c"}}&variables=${JSON.stringify(params)}`; + const url = `https://sdataprod.ncaa.com/?extensions=${encodeURIComponent(JSON.stringify({ persistedQuery: { version: 1, sha256Hash: "7287cda610a9326931931080cb3a604828febe6fe3c9016a7e4a36db99efdb7c" } }))}&variables=${encodeURIComponent(JSON.stringify(params))}`; - const req = await fetch(url); + const req = await fetch(url, { signal: AbortSignal.timeout(10000) }); if (!req.ok) { throw new Error("Failed to fetch football scoreboard data"); } @@ -86,7 +86,7 @@ export async function convertToOldFormat( try { // Format week with leading zero for old endpoint compatibility const oldUrl = `https://data.ncaa.com/casablanca/scoreboard/${sport}/${division}/${date}/scoreboard.json`; - const oldResponse = await fetch(oldUrl); + const oldResponse = await fetch(oldUrl, { signal: AbortSignal.timeout(10000) }); if (oldResponse.ok) { oldFormatData = await oldResponse.json(); } else { @@ -202,15 +202,7 @@ export async function convertToOldFormat( contestClock: contest.contestClock || "0:00", bracketId: contest.bracketId || "", bracketRound: contest.roundNumber || "", - // bracketRegion: "", - // videoState: "", - // contestName: contest.teams - // ? `${awayTeam.nameShort || ""} at ${homeTeam.nameShort || ""}` - // : "", }; - // if (contest.roundDescription) { - // game.roundDescription = contest.roundDescription; - // } if (contest.championshipId) { game.championshipId = contest.championshipId; }