<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Get Your Dream Job]]></title><description><![CDATA[Get Your Dream Job]]></description><link>https://cogniprep.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6aaaa0dcf3939c0b8bd37427/56c01a30-2582-4b0a-b308-6fdcb26f8dfc.png</url><title>Get Your Dream Job</title><link>https://cogniprep.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Sun, 20 Sep 2026 19:18:19 GMT</lastBuildDate><atom:link href="https://cogniprep.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Our articles are JSX, so I exported them from the DOM]]></title><description><![CDATA[We have 48 long-form articles. Each one is its own route folder with its own hand-laid-out JSX, because they genuinely are different shapes and a single template would have flattened them:
app/blogs/
]]></description><link>https://cogniprep.hashnode.dev/our-articles-are-jsx-so-i-exported-them-from-the-dom</link><guid isPermaLink="true">https://cogniprep.hashnode.dev/our-articles-are-jsx-so-i-exported-them-from-the-dom</guid><category><![CDATA[JavaScript]]></category><category><![CDATA[React]]></category><category><![CDATA[webdev]]></category><category><![CDATA[automation]]></category><category><![CDATA[Next.js]]></category><dc:creator><![CDATA[Daniel Pertu]]></dc:creator><pubDate>Wed, 16 Sep 2026 17:23:28 GMT</pubDate><content:encoded><![CDATA[<p>We have 48 long-form articles. Each one is its own route folder with its own hand-laid-out JSX, because they genuinely are different shapes and a single template would have flattened them:</p>
<pre><code>app/blogs/
  understanding-psychometric-tests/page.tsx
  what-is-a-good-psychometric-test-score/page.tsx
  ...46 more
</code></pre>
<p>I needed all 48 as markdown. My first instinct was to parse the JSX, and that instinct was wrong in a way I think is worth writing down.</p>
<h2>The content is not in the children</h2>
<p>Plain prose parses fine. <code>&lt;p&gt;Some text&lt;/p&gt;</code> has a text node in it. But a lot of our article content is passed to shared components as props:</p>
<pre><code class="language-tsx">&lt;LookupRows
  rows={[
    { terms: 'Verify, Verify G+, OPQ32, TalentCentral', label: 'SHL', href: '/games/shl' },
    { terms: 'scales, smartPredict, gridChallenge, cut-e', label: 'Aon', href: '/games/aon' },
  ]}
/&gt;
</code></pre>
<p>There is no text node anywhere in that subtree. There is an array of object literals whose meaning is entirely defined by a component in another file. To turn it into markdown from an AST, I would have to know that <code>LookupRows</code> renders <code>terms</code> as the visible lead and <code>label</code> as a small uppercase tag underneath, and that the whole card is a link to <code>href</code>.</p>
<p>Then do the same for <code>CardGrid</code>, <code>Callout</code>, <code>SpecList</code>, <code>SectionIndex</code>, <code>NumberedSteps</code>, <code>SplitCompare</code>, <code>DoDont</code>, <code>PullQuote</code> and <code>TimeBudget</code>. That is reimplementing the render, by hand, in a second renderer that will drift from the first one the day someone changes a component.</p>
<h2>The browser has already done it</h2>
<p>There is a renderer that knows exactly how every one of those components turns into content, and it is the one in production. So: open the published page, walk the DOM, emit markdown.</p>
<pre><code class="language-js">const page = await task.newPage();
await page.goto('https://cogniprep.app/blogs/' + slug);
await page.waitForSelector('.blog-content');
const { md } = await page.evaluate(converter);
</code></pre>
<p>Semantic tags are the easy half. <code>H2</code> to <code>##</code>, <code>UL</code> to a list, <code>TABLE</code> to a pipe table, <code>A</code> to <code>[text](href)</code> with relative hrefs made absolute.</p>
<p>The hard half is that styled components produce div soup: a grid of divs, each holding two or three more divs of bare text, with no roles and no headings. There is real structure there, but it is expressed in class names.</p>
<p>So the fallback branch reads the classes:</p>
<pre><code class="language-js">const walk = (n) =&gt; {
  // ...
  lines.push({
    t: inline(n).trim(),
    bold: /font-semibold|font-bold/.test(String(n.className)),
    upper: /uppercase/.test(String(n.className)),
  });
};
</code></pre>
<p><code>font-semibold</code> means this line is the card's title. <code>uppercase</code> means it is the little eyebrow above it. Everything else is body. An <code>&lt;a&gt;</code> wrapping the whole card means the title should be a link. That reconstructs <code>- **Percentile** (Most common in the UK): The percentage of the norm group you scored above.</code> out of three anonymous divs.</p>
<p>This is a heuristic and it is coupled to our design system. I am fine with that, and I will come back to why at the end.</p>
<h2>The part that actually matters</h2>
<p>Every one of those branches is a chance to silently drop content. A div shape you did not anticipate falls through to a branch that returns <code>''</code>, and you get a clean-looking markdown file with a paragraph missing from the middle. You would never notice across 48 files.</p>
<p>So the converter returns a coverage diff along with the output:</p>
<pre><code class="language-js">const norm = (s) =&gt; s.toLowerCase().replace(/[^a-z0-9 ]+/g, ' ').split(/\s+/).filter(Boolean);
const mdWords = new Set(norm(markdown));
const missing = norm(container.innerText).filter((w) =&gt; !mdWords.has(w));
return { md: markdown, missing: [...new Set(missing)].slice(0, 25) };
</code></pre>
<p>Every word the browser renders should appear somewhere in the output. Print what did not. That is it, and it is the only reason I trust the result.</p>
<p>It earned its keep immediately. On the first run one file came back with a clean empty <code>missing</code> array and completely wrong output: I had the lookup rows inverted, emitting <code>**SHL** -&gt; [Verify, Verify G+, ...]</code> when the source means the opposite. Word-level coverage cannot catch a transposition, because both words are present.</p>
<p>What it did catch was the second run:</p>
<pre><code>what-is-a-good-psychometric-test-score | missing: ["01","02","03","04","05","06"]
does-practising-psychometric-tests-work | missing: ["1","2","3","4","5"]
</code></pre>
<p>Numbered eyebrows from a section index, dropped by a filter I wrote on purpose. Expected, and now visibly expected rather than invisibly gone.</p>
<h2>Two things that cost me a run each</h2>
<p><strong><code>innerText</code> applies <code>text-transform</code>, <code>textContent</code> does not.</strong> Our eyebrows are lowercase in the source and uppercased in CSS. Read them with <code>innerText</code> and your markdown SHOUTS. Read with <code>textContent</code> and you get the author's casing back. The coverage check has to use <code>innerText</code> on both sides, or every CSS-uppercased word shows up as missing.</p>
<p><strong><code>innerText</code> joins across inline boundaries without a space.</strong> A link at the end of a sentence produced <code>libraryis</code> in the diff, a word that exists in neither the page nor the output. Which is the argument against making the coverage check a hard assertion: it is a report you read, not a test you gate on. Two residual artifacts across 48 files is a fine result. A CI job failing on <code>libraryis</code> forever is not.</p>
<h2>This is a script, not a pipeline</h2>
<p>The tempting next step is to make this a proper export feature: a route, a cached artifact, maybe a CMS. It runs once. It lives in a scratch directory, it is about 120 lines, and when I need it again I would rather rewrite it against whatever the components look like then than maintain a DOM heuristic against a design system that is allowed to change.</p>
<p>The generalisable bit is not the script. It is the two rules:</p>
<ul>
<li>When content is defined by a renderer, read the renderer's output, not its input.</li>
<li>Any lossy transform should tell you what it lost, in the same breath as it hands you the result.</li>
</ul>
<p>The articles it was pointed at are at <a href="https://cogniprep.app/blogs">cogniprep.app/blogs</a>, and the div soup in question is visible in any of them if you open the inspector on a card grid, for example on <a href="https://cogniprep.app/blogs/what-is-a-good-psychometric-test-score">the one about test scores</a>.</p>
]]></content:encoded></item><item><title><![CDATA[A URL missing from your sitemap is invisible, not broken]]></title><description><![CDATA[The bug report was that nothing was wrong. Every page loaded. Every link worked. Nothing threw. A handful of pages had simply never been announced to a search engine, for weeks, and there was no way t]]></description><link>https://cogniprep.hashnode.dev/a-url-missing-from-your-sitemap-is-invisible-not-broken</link><guid isPermaLink="true">https://cogniprep.hashnode.dev/a-url-missing-from-your-sitemap-is-invisible-not-broken</guid><category><![CDATA[Next.js]]></category><category><![CDATA[SEO]]></category><category><![CDATA[TypeScript]]></category><category><![CDATA[webdev]]></category><category><![CDATA[Web Development]]></category><dc:creator><![CDATA[Daniel Pertu]]></dc:creator><pubDate>Wed, 16 Sep 2026 17:21:36 GMT</pubDate><content:encoded><![CDATA[<p>The bug report was that nothing was wrong. Every page loaded. Every link worked. Nothing threw. A handful of pages had simply never been announced to a search engine, for weeks, and there was no way to notice except to go and count.</p>
<p>That is the failure mode of duplicated derived data. It does not error. It goes quiet.</p>
<h2>Three copies of the same list</h2>
<p>We had three places that needed to know every canonical URL on the site:</p>
<ol>
<li><code>app/sitemap.ts</code>, which Next.js turns into <code>/sitemap.xml</code></li>
<li><code>app/api/indexnow/route.ts</code>, a cron-triggered POST that tells Bing, Yandex, Seznam and Naver that things changed</li>
<li><code>scripts/submit-indexnow.ts</code>, the manual version of the same thing</li>
</ol>
<p>The first was maintained, because you look at your sitemap. The other two each kept their own hardcoded array, and both had drifted. Between them they were missing <code>/tests</code>, every <code>/tests/&lt;format&gt;</code> page, and <code>/about</code>.</p>
<p>Nobody introduced that bug. It accumulated, one "add the page, ship it" at a time, because adding a page to the site and adding it to a hand-written list in a script you run once a month are not the same motion.</p>
<h2>Derive, do not copy</h2>
<p>A Next.js <code>sitemap.ts</code> default export is just a function. There is nothing stopping you calling it:</p>
<pre><code class="language-ts">import sitemap from '@/app/sitemap';

/**
 * Every canonical URL, read straight out of the sitemap so the two can never
 * disagree.
 */
export function getIndexNowUrls(): string[] {
  return sitemap().map((entry) =&gt; entry.url);
}
</code></pre>
<p>Both submitters now import that one function. Adding a page picks itself up with no extra wiring, and the class of bug is gone rather than fixed.</p>
<h2>The sitemap derives too</h2>
<p>Which only helps if the sitemap itself is not hand-maintained. Ours is a short list of genuinely singular pages, plus spreads over content registries:</p>
<pre><code class="language-ts">...ALL_PROVIDERS.map((provider) =&gt; ({
  url: `${baseUrl}/games/${provider}`,
  lastModified: new Date('2026-08-09'),
  changeFrequency: 'weekly' as const,
  priority: 0.9,
})),
...TEST_TYPES.map((entry) =&gt; ({ url: `${baseUrl}/tests/${entry.slug}`, /* ... */ })),
...CHEATING_GUIDES.map((guide) =&gt; ({ url: `${baseUrl}/cheating/${guide.provider}`, /* ... */ })),
...INTERVIEW_GUIDES.map((guide) =&gt; ({ url: `${baseUrl}/interview/${guide.slug}`, /* ... */ })),
</code></pre>
<p>Same comment sits above each one, because it is the same reason every time: a page missing from the sitemap works fine and is simply never discovered.</p>
<h2>Make the omission loud</h2>
<p>The registry pattern has one hole, and it is the interesting part. If the registry is the source of truth, what stops someone adding a route folder and forgetting the registry entry?</p>
<p>You cannot solve this by discipline. You solve it by making the forgotten case break something visible. Our blog article pages do not carry their own title, date or category. They read them from the registry, by slug:</p>
<pre><code class="language-tsx">export default function Page() {
  return (
    &lt;ArticleShell slug="understanding-psychometric-tests" width="wide"&gt;
      {/* body */}
    &lt;/ArticleShell&gt;
  );
}
</code></pre>
<p>Forget the registry entry and the page does not quietly publish without a sitemap URL. It renders without a header, which you see the first time you open it. The loud failure and the silent one now have the same cause, so fixing the one you notice fixes the one you do not.</p>
<p>This is a general move and I reach for it constantly now: when a thing must be registered somewhere, make the registration also supply something the unregistered version visibly needs.</p>
<h2>One more, for bundle reasons</h2>
<p>There is a second module that looks redundant and is not:</p>
<pre><code class="language-ts">// lib/blog/posts.ts
import { BLOG_POSTS } from './registry';

export const blogPostMeta = BLOG_POSTS.map(({ slug, date, lastModified }) =&gt; ({
  slug,
  date,
  lastModified,
}));
</code></pre>
<p>The sitemap and the IndexNow submitter need slugs and dates. They do not need every title, description, excerpt and tag array, and the registry is mostly prose. This narrow projection keeps the copy out of anything that only wants URLs. The RSS feed, which does want the copy, imports <code>BLOG_POSTS</code> directly.</p>
<h2>What the fix is worth</h2>
<p>Roughly nothing, on the day. The pages were already live and already in the sitemap, so Google had them; only the IndexNow engines were behind. The value is the next 50 pages, none of which anyone has to remember to add to three lists.</p>
<p>If you want to check your own, the test is cheap: get the URL list out of every place that has one and diff them. If you have more than one place, they have already drifted, and the diff will tell you by how much.</p>
<p>Ours are at <a href="https://cogniprep.app/sitemap.xml">cogniprep.app/sitemap.xml</a> and <a href="https://cogniprep.app/feed.xml">cogniprep.app/feed.xml</a>, both generated from the same registries as the <a href="https://cogniprep.app/blogs">blog index</a> itself. Open the sitemap and the blog listing side by side; if they ever disagree, one of us has stopped deriving.</p>
]]></content:encoded></item><item><title><![CDATA[Four CSS rules that fought our two-column refactor]]></title><description><![CDATA[Our practice tests were built mobile-first, and it showed. Every game put the stimulus (a matrix, a data table, a passage) in one narrow centred column, with the answer options underneath and the subm]]></description><link>https://cogniprep.hashnode.dev/four-css-rules-that-fought-our-two-column-refactor</link><guid isPermaLink="true">https://cogniprep.hashnode.dev/four-css-rules-that-fought-our-two-column-refactor</guid><category><![CDATA[CSS]]></category><category><![CDATA[Tailwind CSS]]></category><category><![CDATA[React]]></category><category><![CDATA[webdev]]></category><category><![CDATA[frontend]]></category><dc:creator><![CDATA[Daniel Pertu]]></dc:creator><pubDate>Wed, 16 Sep 2026 17:19:12 GMT</pubDate><content:encoded><![CDATA[<p>Our practice tests were built mobile-first, and it showed. Every game put the stimulus (a matrix, a data table, a passage) in one narrow centred column, with the answer options underneath and the submit button under those. On a phone that is correct. On a laptop it wasted the entire right half of the screen and pushed the options, sometimes the button, below the fold. A candidate on a per-item clock had to scroll before they could answer.</p>
<p>The fix is a two-column layout above <code>lg</code>. The interesting part was not the grid. It was the four things in our own stylesheet that quietly defeated it.</p>
<h2>The shape of the thing</h2>
<p>One component, used by 56 game files:</p>
<pre><code class="language-tsx">&lt;GameSplitLayout
  stimulus={&lt;MatrixGrid cells={item.cells} /&gt;}
  answers={&lt;OptionList options={item.options} onPick={pick} /&gt;}
  action={&lt;SubmitButton disabled={!picked} /&gt;}
  mobileWidthClassName="max-w-md sm:max-w-lg"
  desktopWidthClassName="lg:max-w-6xl"
  ratio="wide-stimulus"
/&gt;
</code></pre>
<p>The constraint I set before writing a line of it: <strong>mobile must render byte-identical markup</strong>. Not "looks about the same". Identical. 56 screens is far too many to re-verify by eye on a phone, so every class the split adds is <code>lg:</code> prefixed, and below 1024px the component collapses to <code>w-full &lt;your widths&gt; space-y-4</code> wrapping two plain divs. That is exactly what a flat sibling list produced before.</p>
<p>Which brings us to the first thing that fought back.</p>
<h2>1. <code>space-y-*</code> puts margins on grid items</h2>
<p>Tailwind v4 compiles <code>space-y-4</code> to <code>margin-block-end</code> on every child but the last. Fine in a stack. In a grid, those children are grid items sitting in their own tracks, and the margin is still there, pushing the answers column a rem down inside its cell.</p>
<p>So the rhythm class has to be cancelled at the same breakpoint the grid appears:</p>
<pre><code class="language-tsx">className={cn(
  'w-full',
  mobileWidthClassName,
  spacingClassName,          // "space-y-4 lg:space-y-6", from the game
  'lg:grid lg:items-start lg:space-y-0',
  ...
)}
</code></pre>
<p>Note the order. <code>lg:space-y-0</code> comes <em>after</em> <code>spacingClassName</code> because <code>cn</code> is <code>tailwind-merge</code>, and tailwind-merge resolves conflicts by last-wins, per variant. Put it first and the game's own <code>lg:space-y-6</code> wins, and you get a mystery one-rem offset on exactly one column.</p>
<h2>2. Grid items default to <code>min-width: auto</code></h2>
<p>This is the CSS spec behaviour that bites everyone once. A grid item will not shrink below its content's intrinsic minimum width. A wide data table or one long unbroken string will blow its track out past the container, and because our game shell is <code>overflow-hidden</code>, that overflow is clipped rather than scrollable. The content is just gone.</p>
<pre><code class="language-tsx">&lt;div className={cn('min-w-0', spacingClassName, stimulusClassName)}&gt;{stimulus}&lt;/div&gt;
</code></pre>
<p><code>min-w-0</code> on both tracks, always, not only the one you think is wide.</p>
<h2>3. <code>position: sticky</code> does nothing on a stretched grid item</h2>
<p>Some games have a long stimulus and a short answer list, and pinning the answers while the stimulus scrolls past is the right call. The obvious implementation does not work:</p>
<pre><code class="language-tsx">stickyAnswers &amp;&amp; 'lg:sticky lg:top-6'   // no effect
</code></pre>
<p>Grid items default to <code>align-self: stretch</code>, so the item is already as tall as its row. It has nowhere to stick. You need:</p>
<pre><code class="language-tsx">stickyAnswers &amp;&amp; 'lg:sticky lg:top-6 lg:self-start'
</code></pre>
<p>The prop is also opt-in rather than default, for a reason worth stating: a sticky element taller than its scrollport pins at the top, and its own bottom becomes permanently unreachable. If the answers plus the submit button are not clearly shorter than the viewport, sticky turns a scrolling annoyance into an unanswerable question.</p>
<h2>4. Tailwind's scanner reads source text, not runtime values</h2>
<p>Three column ratios, chosen per game:</p>
<pre><code class="language-tsx">const RATIO_CLASSES: Record&lt;GameSplitRatio, string&gt; = {
  balanced: 'lg:grid-cols-2',
  'wide-stimulus': 'lg:grid-cols-[minmax(0,1.35fr)_minmax(320px,1fr)]',
  'wide-answers': 'lg:grid-cols-[minmax(0,1fr)_minmax(0,1.35fr)]',
};
</code></pre>
<p>Written out as complete literal strings. Tailwind generates CSS by scanning your source files for things that look like class names; it does not execute your code. <code>lg:grid-cols-${cols}</code> produces a class at runtime that has no CSS behind it, and you find out in production, on the one layout you did not open locally.</p>
<h2>The stylesheet you forgot you wrote</h2>
<p>The four above are general CSS. These two were ours, and they are the reason I now read <code>globals.css</code> before writing any layout in this codebase.</p>
<p><strong><code>contain</code> changes what <code>fixed</code> means.</strong> Our scroll container carries <code>contain: layout style paint</code>. Containment establishes a containing block for fixed-position descendants, so <code>position: fixed</code> inside a game resolves against that container rather than the viewport. A "fixed" bottom bar lands somewhere surprising. Use <code>sticky</code>.</p>
<p><strong>A global <code>!important</code> that eats borders.</strong> There is an unlayered rule in our stylesheet:</p>
<pre><code class="language-css">[class*='bg-card'] {
  border: none !important;
}
</code></pre>
<p>Attribute-substring, deliberately, so that <code>bg-card/90</code> is caught too. It also means anything with <code>bg-card</code> anywhere in its class attribute cannot have a border, including every <code>&lt;Card&gt;</code> component. If a column needs a visible edge, <code>bg-muted/40</code> with <code>border-border border</code>, or an explicit <code>bg-border h-px</code> divider.</p>
<p>And one more: we hide every scrollbar in the app. So a nested <code>overflow-auto</code> region inside a game would scroll with no visible affordance at all. <code>GameSplitLayout</code> therefore adds no <code>overflow-*</code> and no fixed heights. The columns grow, and the one real scroll container in the shell scrolls, exactly as it did before.</p>
<h2>Was it worth 85 files?</h2>
<p>The diff was 85 files, +4521 / -3003. The honest answer is that about a third of that churn was mechanical and could have been a codemod if the games were more uniform, which they are not, because each one imitates a different real assessment with different item shapes.</p>
<p>What made it tractable was the byte-identical-mobile rule. It turned "re-check 56 screens on two form factors" into "re-check 56 screens on one", and it meant every regression I did find was a desktop regression, which is the one you can see on the machine you are working on.</p>
<p>You can resize any of the practice tests at <a href="https://cogniprep.app/games">cogniprep.app/games</a> across 1024px and watch the split appear. The candidate-facing reason this matters, incidentally, is that <a href="https://cogniprep.app/blogs/what-is-a-good-psychometric-test-score">most of these tests are timed per item</a>, so a scroll between reading and answering is not a cosmetic cost.</p>
]]></content:encoded></item><item><title><![CDATA[3.9 MB of answer keys, one anonymous CDN GET away]]></title><description><![CDATA[I went looking for something else and found this line in a loader:
const res = await fetch(`/game-questions/${bankId}.json`);

That is a relative fetch from the browser to a file in public/. Next.js c]]></description><link>https://cogniprep.hashnode.dev/3-9-mb-of-answer-keys-one-anonymous-cdn-get-away</link><guid isPermaLink="true">https://cogniprep.hashnode.dev/3-9-mb-of-answer-keys-one-anonymous-cdn-get-away</guid><category><![CDATA[Next.js]]></category><category><![CDATA[TypeScript]]></category><category><![CDATA[Security]]></category><category><![CDATA[webdev]]></category><category><![CDATA[api]]></category><dc:creator><![CDATA[Daniel Pertu]]></dc:creator><pubDate>Wed, 16 Sep 2026 17:16:52 GMT</pubDate><content:encoded><![CDATA[<p>I went looking for something else and found this line in a loader:</p>
<pre><code class="language-ts">const res = await fetch(`/game-questions/${bankId}.json`);
</code></pre>
<p>That is a relative fetch from the browser to a file in <code>public/</code>. Next.js copies <code>public/</code> to the CDN and serves it to whoever asks. No session, no account, no rate limit. The directory held 102 JSON files, 3.9 MB of authored questions, and every answer key in the product.</p>
<p>The cache header made it worse, in the way that good cache headers usually do:</p>
<pre><code>cache-control: public, max-age=31536000, immutable
</code></pre>
<p><code>public</code> means shared caches may store it. <code>immutable</code> means do not even revalidate. The edge was told, correctly, to hand this to the entire internet for a year.</p>
<h2>The ids were not a secret either</h2>
<p>My first thought was that you would have to guess the filenames. You do not. The bank id is a string literal inside the game engine that loads it, so it ships in the JavaScript chunk for that game:</p>
<pre><code class="language-ts">const questions = await loadQuestions('shl-numerical');
</code></pre>
<p>Download the chunk, grep for the strings, fetch 102 files. That is a loop, not an attack.</p>
<h2>What moving it actually buys</h2>
<p>The fix is a route. <code>lib/games/questions/banks/</code> instead of <code>public/game-questions/</code>, and one handler in front of it:</p>
<pre><code class="language-ts">// app/api/games/questions/[bankId]/route.ts
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';

export const GET = withApiHandler&lt;{ bankId: string }&gt;(
  async ({ params }) =&gt; {
    const { bankId } = await params;
    if (!isGameQuestionBankId(bankId)) {
      return apiError('Question bank not found', 404);
    }
    const json = await readQuestionBank(bankId);
    return new NextResponse(json, {
      headers: {
        'Content-Type': 'application/json',
        'Cache-Control': 'private, max-age=3600',
      },
    });
  },
  { rateLimit: 'default', errorMessage: 'Failed to load question bank' }
);
</code></pre>
<p>Here is the part I want to be honest about, because a lot of writing about this kind of change is not. This does not make the questions secret. A signed-in user is going to render those questions on their own screen. They can open their own network tab and read the response. Anything the browser can display, the browser can be made to dump.</p>
<p>What changed is who has to bother. Scraping the library used to cost an anonymous <code>for</code> loop. It now costs an account, it goes through the same rate limiter as everything else, and it is attributable in logs. That is a real difference in kind, and it is the whole return on the change.</p>
<p>Genuinely hiding the answer key from the person answering the question needs server-side grading: send the prompt, receive the choice, grade it on the server, never ship the key at all. We cannot do that yet, because the banks carry no stable per-question ids to grade against. That is a bigger piece of work, and pretending the route solved it would have been the easy lie.</p>
<h2>A type union does not exist at runtime</h2>
<p>The bank ids were a union type:</p>
<pre><code class="language-ts">export type GameQuestionBankId = 'analysis' | 'balance' | 'order' | 'patterns' /* ...98 more */;
</code></pre>
<p>Which is worth exactly nothing to a route handler holding <code>params.bankId</code>. TypeScript erases it. So the union became an array, and the type is derived from the array:</p>
<pre><code class="language-ts">export const GAME_QUESTION_BANK_IDS = ['analysis', 'balance', 'order', 'patterns' /* ... */] as const;

export type GameQuestionBankId = (typeof GAME_QUESTION_BANK_IDS)[number];
</code></pre>
<p>Adding a bank is still a one-line change, and the type and the runtime list cannot drift apart. The route checks membership against a <code>Set</code> built from that array.</p>
<p>I want to be specific about why it is an exact-match allowlist and not a sanitising regex. <code>params.bankId</code> is about to be concatenated into a filesystem path:</p>
<pre><code class="language-ts">export async function readQuestionBank(bankId: GameQuestionBankId): Promise&lt;string&gt; {
  return readFile(path.join(BANK_DIR, `${bankId}.json`), 'utf8');
}
</code></pre>
<p>A regex that strips <code>..</code> is a filter, and filters are a game of thinking of everything. A set membership check is structural: an id that is not one of the 102 known strings never reaches <code>readFile</code> at all, so path traversal is not filtered, it is unreachable. The allowlist runs before anything touches the disk.</p>
<p>Two smaller decisions in the same handler:</p>
<p><strong>404, not 400, for an unknown id.</strong> A 400 would tell a prober that the id was malformed and a 404 that it was well formed but missing, which slowly leaks the shape of the id space. Same status for both, learn nothing.</p>
<p><strong>Raw text, not <code>NextResponse.json</code>.</strong> The bank is already valid JSON on disk. Parsing it into objects so that <code>NextResponse.json</code> can serialise it straight back is CPU spent on every request to produce the bytes we started with, for a payload the server never inspects.</p>
<h2>The two headers that undo the work</h2>
<p><code>dynamic = 'force-dynamic'</code> and <code>Cache-Control: private</code> are not decoration. Without the first, a route with no request-dependent inputs can be prerendered at build time and served from the static output, which puts the file right back on the CDN with a different URL. Without the second, a shared cache is entitled to store one user's authenticated response and hand it to the next caller, which is precisely what <code>public, immutable</code> was doing before.</p>
<p>So the test suite asserts on the header, not just the status:</p>
<pre><code>✓ serves a bank to a signed-in user
✓ refuses an anonymous caller
✓ never lets a bank into a shared cache
✓ rejects ids outside the allowlist rather than touching the filesystem
✓ keeps the banks out of the public directory
</code></pre>
<p>That last one is a directory check. It fails the build if anyone ever drops a bank back into <code>public/</code>, which is exactly how this happened the first time: <code>public/</code> is where you put static JSON, right up until the static JSON is the answer key.</p>
<h2>Authenticate, but do not authorise</h2>
<p>The obvious next step is to check entitlement too, so a free account cannot pull a paid provider's bank. I did not do it, and the reason is worth writing down.</p>
<p>There is no bank-to-game mapping in the codebase. Bank ids are hardcoded literals inside each engine, and several banks legitimately serve more than one game: <code>asx-matrigma</code> feeds both Matrigma games, and three TestGroup ability banks also back a combined test. Building that map properly is worth doing. Guessing at it inside a route handler would have broken real games for real users mid-session, which is a worse failure than the one it prevents.</p>
<p>Authentication is a complete, correct, shippable step. Authorisation is a different change with a different blast radius, and bundling them would have meant shipping neither cleanly.</p>
<h2>Try it</h2>
<p>Open any of the practice tests at <a href="https://cogniprep.app/games">cogniprep.app/games</a> with your network tab open. You will see the bank arrive from <code>/api/games/questions/&lt;id&gt;</code> with <code>cache-control: private, max-age=3600</code>, and you will see nothing at all under <code>/game-questions/</code>. Sign out and request that path directly and you get a 401.</p>
<p>If you want the other half of the story, the reason these banks are hand-authored rather than generated is that the tests they imitate are measured instruments, and the <a href="https://cogniprep.app/blogs/what-is-a-good-psychometric-test-score">scoring side of that</a> is what makes a plausible-looking wrong question expensive.</p>
<p>The short version, for anyone auditing their own <code>public/</code> directory this afternoon: <code>public/</code> is a CDN, not a folder. The cache header you are proud of is the one that publishes it.</p>
]]></content:encoded></item></channel></rss>