Build QuestForge: Secure Quest Board

Build a game-styled quest board with a secure Node.js JSON API.

Introduction

30 Second Summary

A learning goal feels more achievable when it becomes a mission with a clear reward. Yet a form that looks strict in the browser can still let bad data reach the system behind it.

In this project, you will build a game-styled full-stack JavaScript quest board that connects a browser interface to a dependency-free Node.js JSON API. A built-in attack test exposes the trust boundary before server-side validation blocks the same hostile request.

What You'll Build

Your finished QuestForge board lets you submit a quest in Microsoft Edge, watch your XP and level update, then replay an attack to see the server reject it.

By the end of this project, you'll have:

  • A game-styled quest board where you can add focused learning missions and immediately see your XP total and player level update.
  • A repeatable security demo that first accepts a blank Legendary quest, then blocks the same request after the server defense is active.
  • A public GitHub repository that shows your secure Node.js server, browser code, and security explanation.
  • Secret Mission: Add a Boss Quest generator that randomizes a harder challenge and pre-fills the secure quest form.

Are there any prerequisites?

You need a Windows computer with internet access plus basic HTML and CSS knowledge. Step 1 sets up Visual Studio Code, Git for Windows, the remaining local tools, and your free GitHub account.

Before We Start

Before any hands-on work, lock in the purpose of QuestForge. This commitment keeps the project focused on one visible full-stack security lesson.

Set Up Your Windows Toolchain

QuestForge needs a dependable path from source code to browser. A reliable local toolchain removes setup uncertainty from every later result.

This step prepares Visual Studio Code, Node.js, Git for Windows, and Microsoft Edge as one Windows toolchain. You'll finish with a verified GitHub account plus an empty project folder open in the editor.

In this step, get ready to:
  • Prepare Visual Studio Code plus Microsoft Edge.
  • Install the required Node.js plus Git for Windows versions.
  • Configure your Git identity before opening the empty QuestForge folder.
Install the editor and browser

Visual Studio Code holds the QuestForge files that you build in later steps. Its recommended Windows User setup also makes the editor available from PowerShell.

Windows may ask whether an installer can change your computer. Confirm that the publisher matches the product before you approve the request.

  • Open the official Visual Studio Code download page in your browser.
  • Download the recommended Windows User setup that matches your computer architecture.
  • Run the downloaded Visual Studio Code installer.
  • Keep the recommended installation options.
  • Complete the installation.

Why Use Windows User Setup?

The User setup installs Visual Studio Code for your Windows account. It also adds the code command to your path after you restart PowerShell.

Microsoft Edge gives you the browser surface where QuestForge runs. Check whether it is already available before downloading another copy.

  • Press the Windows key to open Windows search.
  • Type Microsoft Edge into the search field.
  • Press Enter.

✔️ Microsoft Edge opens

Microsoft Edge is ready for the local QuestForge checks later in the project. Close the browser for now.

ⓧ Microsoft Edge is unavailable

  • Open the official Microsoft Edge download page.
  • Download Microsoft Edge for Windows.
  • Run the downloaded installer.
  • Complete the installation with the recommended options.
  • Repeat the Windows search check.

Microsoft Edge should open after the repeated check.

Your editor and browser are ready. The next checks confirm that Windows can run the development commands QuestForge needs.

Verify Node.js and Git

Node.js runs the QuestForge server outside the browser. This project uses Node.js 24.21.0 LTS so your runtime matches the code you build later.

  • Press the Windows key to open Windows search.
  • Type PowerShell into the search field.
  • Press Enter to open PowerShell.
  • Check the installed Node.js version by running:
node -v

What Does This Check Do?

The -v flag asks Node.js to print its installed version. The result tells you whether the required runtime is available in this PowerShell session.

✔️ I see v24.21.0

Your Node.js runtime matches the project version. Keep this PowerShell window open for the Git check.

ⓧ I see an older version

  • Open the official Node.js download page.
  • Download node-v24.21.0-x64.msi for an x64 computer or node-v24.21.0-arm64.msi for an ARM64 computer.
  • Run the downloaded installer.
  • Keep the default installation options.
  • Close PowerShell after the installation completes.
  • Reopen PowerShell through Windows search.
  • Verify the updated Node.js version by running:
node -v

What Does the Recheck Prove?

The repeated command reads the Node.js version from the refreshed path. This confirms that PowerShell now uses the newly installed runtime.

You should see v24.21.0 in the output.

ⓧ Command not found

  • Open the official Node.js download page.
  • Download node-v24.21.0-x64.msi for an x64 computer or node-v24.21.0-arm64.msi for an ARM64 computer.
  • Run the downloaded installer.
  • Keep the default installation options.
  • Close PowerShell after the installation completes.
  • Reopen PowerShell through Windows search.
  • Confirm that Node.js is available by running:
node -v

What Does the New Check Prove?

The command now checks the Node.js installation from a fresh PowerShell session. A version result confirms that Windows can locate the runtime.

You should see v24.21.0 in the output.

Still Missing the Node.js Version?

Close every PowerShell window after the installer finishes. A PowerShell window opened before installation can keep the old path.

Run the installer again if a fresh PowerShell window still cannot find Node.js. Confirm that the installer completes before reopening the terminal.

Help me fix my Node.js installation

Git records each meaningful change you make to QuestForge. This project uses Git for Windows 2.56.0(2) for local version control plus GitHub publishing.

  • Check the installed Git version in PowerShell by running:
git --version

What Does This Check Do?

The --version flag asks Git to print the installed suite version. The result shows whether Git for Windows is available from PowerShell.

✔️ I see version 2.56.0 or higher

Git for Windows is ready. Keep PowerShell open for your identity configuration.

ⓧ I see an older version

  • Open the official Git for Windows installation page.
  • Download the 2.56.0(2) standalone installer that matches your computer architecture.
  • Run the downloaded installer.
  • Keep the default installation options.
  • Close PowerShell after the installation completes.
  • Reopen PowerShell through Windows search.
  • Verify the updated Git version by running:
git --version

What Does the Recheck Prove?

The repeated command reads Git from the refreshed Windows path. This confirms that PowerShell now uses the updated Git for Windows installation.

You should see a Git version containing 2.56.0 or a later version.

ⓧ Command not found

  • Open the official Git for Windows installation page.
  • Download the 2.56.0(2) standalone installer that matches your computer architecture.
  • Run the downloaded installer.
  • Keep the default installation options.
  • Close PowerShell after the installation completes.
  • Reopen PowerShell through Windows search.
  • Confirm that Git is available by running:
git --version

What Does the New Check Prove?

The command checks the Git installation from a fresh PowerShell session. A version result confirms that Windows can locate Git.

You should see a Git version containing 2.56.0 or a later version.

Still Missing the Git Version?

Close every PowerShell window after Git finishes installing. Reopen PowerShell so it loads the updated path.

Run the standalone installer again if the version check still fails. Keep the option that makes Git available from the command line.

Help me fix my Git installation

Create your account and project folder

Git stores your project history locally. A free personal GitHub account gives that history a public home when you publish QuestForge in the final step.

  • Open the official GitHub signup page.
  • Enter the required account details in the signup flow.
  • Choose the free personal account option if GitHub asks you to select a plan.
  • Open the verification message sent to your email address.
  • Follow the verification link in that message.
  • Return to GitHub after verification.
  • Sign in to your verified account.

Why Verify the Account Now?

GitHub requires a verified email for basic tasks such as creating a repository. Completing verification now keeps the publishing step focused on your finished project.

Every Git commit carries an author name plus an email address. Global Git configuration applies that identity to repositories created by your Windows account.

  • Switch back to the PowerShell window from the version checks.
  • Configure your Git identity by replacing YOUR NAME plus YOUR_EMAIL while you run these commands:
git config --global user.name "YOUR NAME"
git config --global user.email YOUR_EMAIL

What Does This Configuration Store?

  • The user.name setting stores the author name attached to your commits.
  • The user.email setting stores the author email attached to your commits.
  • The --global option applies both settings to repositories used by your Windows account.

PowerShell should return to the prompt without an error. Your Git identity is now configured.

The last setup task creates the empty questforge folder on your Desktop. PowerShell stays inside that folder so the editor command opens the correct location.

  • Create the questforge folder plus move PowerShell into it by running these commands:
New-Item -Path ~\Desktop\questforge -ItemType Directory
Set-Location -Path ~\Desktop\questforge

What Do These Commands Do?

  • The first command creates an empty questforge directory on your Desktop.
  • The second command changes the current PowerShell location to that directory.

You should see information about the new directory. Your PowerShell prompt should now end with questforge.

Before you run the final check, what three results do you expect from the runtime check, the version-control check, plus the editor command?

  • Verify the complete Windows toolchain from the empty project folder by running:
node -v
git --version
code .

What Does the Final Check Prove?

  • The first command confirms that PowerShell can run Node.js 24.21.0 LTS.
  • The second command confirms that PowerShell can run Git for Windows.
  • The third command asks Visual Studio Code to open the current questforge folder.

You should see v24.21.0 plus a Git version in PowerShell. Visual Studio Code should open with the empty questforge folder named in the Explorer sidebar.

Does the Folder Fail to Open?

Close PowerShell after installing Visual Studio Code. Reopen it so Windows loads the editor command added by the User setup.

Check that the PowerShell prompt ends with questforge before retrying the editor command. This keeps Visual Studio Code pointed at the empty project folder.

Help me open the QuestForge folder

That's the setup complete. Your Windows toolchain can run the server, track its history, open its files, plus test the result in Microsoft Edge.

Forge the First Quest Board

Your Windows toolchain is ready. You now have Visual Studio Code, Node.js, and Microsoft Edge ready to turn the empty questforge folder into a working project.

A visible static board gives you an early result before networking enters the picture. It also defines the exact interface that the server-powered quest system uses later.

In this step, get ready to:
  • Create a local HTTP server for the QuestForge files.
  • Build the static quest form, player panel, sample card, and security panel.
  • Style the board and inspect the finished layout in Microsoft Edge.
Create the static server

The browser needs a local server that can read the QuestForge files and return them when requested. Node.js provides the file and networking tools for this first request-response loop.

Why use plain JavaScript and Node.js?

Plain browser JavaScript keeps the connection between the page and server visible. The built-in Node.js HTTP tools also let you see how each request receives a response.

This foundation makes the later security flaw easier to trace. Every trust decision stays in code you can inspect.

  • In the Visual Studio Code sidebar, select the open questforge folder.
  • Create a folder named public inside questforge.
  • Create server.cjs directly inside questforge.
  • Create index.html inside the public folder.
  • Create style.css inside the public folder.

You should see server.cjs beside the public folder. The folder should contain index.html and style.css.

  • Replace the empty contents of server.cjs with the static file configuration below:
const http = require("node:http");
const { readFile } = require("node:fs/promises");
const path = require("node:path");

const HOST = "127.0.0.1";
const PORT = 3000;
const PUBLIC_DIR = path.join(__dirname, "public");

const STATIC_FILES = {
  "/": { file: "index.html", type: "text/html; charset=utf-8" },
  "/style.css": { file: "style.css", type: "text/css; charset=utf-8" },
};

async function serveStatic(request, response) {
  const asset = STATIC_FILES[request.url];
  if (!asset) {
    return false;
  }

  const filePath = path.join(PUBLIC_DIR, asset.file);
  const contents = await readFile(filePath);
  response.writeHead(200, { "Content-Type": asset.type });
  response.end(contents);
  return true;
}

What does this code do?

  • The Node.js imports provide the HTTP server, asynchronous file reading, and Windows-safe path handling.
  • The STATIC_FILES object maps each browser path to the file and content type that the server returns.
  • The serveStatic() function reads the matching file from public before completing the response.
  • Save server.cjs.

The saved tab should no longer show an unsaved-change marker. Your server now knows where the page assets live.

Seeing red underlines in the server file?

Check that every opening brace has a matching closing brace. Confirm that the file is named server.cjs inside questforge.

Compare the quotes around node:http, node:fs/promises, and node:path.

Help me check the static file configuration.

  • Add the response handling and server startup code below serveStatic() in server.cjs:
function sendJson(response, statusCode, payload) {
  response.writeHead(statusCode, {
    "Content-Type": "application/json; charset=utf-8",
  });
  response.end(JSON.stringify(payload));
}

const server = http.createServer(async (request, response) => {
  try {
    if (request.method === "GET" && (await serveStatic(request, response))) {
      return;
    }

    sendJson(response, 404, { error: "Not found." });
  } catch (error) {
    console.error(error);
    sendJson(response, 500, { error: "Unexpected server error." });
  }
});

server.listen(PORT, HOST, () => {
  console.log(`QuestForge running at http://${HOST}:${PORT}`);
});

How does the server respond?

  • The sendJson() helper creates structured error responses for unknown paths or unexpected failures.
  • The request handler gives supported GET requests to serveStatic().
  • The server.listen() call starts the process on host 127.0.0.1 and port 3000.
  • Save server.cjs.

Before you run the server, predict whether PowerShell will return to a prompt or stay occupied while the server listens for requests.

  • Start the QuestForge server from the existing questforge terminal by running:
node server.cjs

What does this command do?

Node.js executes server.cjs and starts the HTTP listener. PowerShell stays occupied because the process remains available for browser requests.

You should see QuestForge running at http://127.0.0.1:3000 in PowerShell. That confirms the local server is listening.

Server did not start?

Confirm that the terminal is inside the open questforge folder. Check that Windows did not add a hidden text extension to server.cjs.

If PowerShell identifies a line number, compare that line with the two server code blocks above. A missing bracket or quote stops Node.js before it begins listening.

Help me start the QuestForge server.

Keep the server running

Leave this PowerShell terminal running while you build the page. The server reads the current HTML and CSS whenever Microsoft Edge requests them.

✔️ Awesome, I've got everything!

Your server is listening on http://127.0.0.1:3000. Keep its PowerShell terminal running.

ⓧ I'd like to double check the full code

const http = require("node:http");
const { readFile } = require("node:fs/promises");
const path = require("node:path");

const HOST = "127.0.0.1";
const PORT = 3000;
const PUBLIC_DIR = path.join(__dirname, "public");

const STATIC_FILES = {
  "/": { file: "index.html", type: "text/html; charset=utf-8" },
  "/style.css": { file: "style.css", type: "text/css; charset=utf-8" },
};

function sendJson(response, statusCode, payload) {
  response.writeHead(statusCode, {
    "Content-Type": "application/json; charset=utf-8",
  });
  response.end(JSON.stringify(payload));
}

async function serveStatic(request, response) {
  const asset = STATIC_FILES[request.url];
  if (!asset) {
    return false;
  }

  const filePath = path.join(PUBLIC_DIR, asset.file);
  const contents = await readFile(filePath);
  response.writeHead(200, { "Content-Type": asset.type });
  response.end(contents);
  return true;
}

const server = http.createServer(async (request, response) => {
  try {
    if (request.method === "GET" && (await serveStatic(request, response))) {
      return;
    }

    sendJson(response, 404, { error: "Not found." });
  } catch (error) {
    console.error(error);
    sendJson(response, 500, { error: "Unexpected server error." });
  }
});

server.listen(PORT, HOST, () => {
  console.log(`QuestForge running at http://${HOST}:${PORT}`);
});
Build the board interface

The page begins with the player-facing information that frames the project as a quest board. Each section also creates a stable place for the dynamic data added in the next step.

  • Replace the empty contents of public/index.html with the page shell below:
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>QuestForge</title>
    <link rel="stylesheet" href="/style.css" />
  </head>
  <body>
    <main class="shell">
      <header class="hero">
        <p class="eyebrow">FULL-STACK SECURITY QUEST</p>
        <h1>QuestForge</h1>
        <p>Forge learning missions. Earn XP. Defend the server.</p>
      </header>

      <section class="player-panel" aria-label="Player progress">
        <div>
          <span>PLAYER LEVEL</span>
          <strong id="player-level">Level 1</strong>
        </div>
        <div>
          <span>AVAILABLE XP</span>
          <strong id="total-xp">0 XP</strong>
        </div>
      </section>
    </main>
  </body>
</html>

What does this page shell contain?

  • The document metadata gives the page a title and connects it to style.css.
  • The hero introduces QuestForge as a full-stack security challenge.
  • The player panel provides fixed targets for the level and XP values that JavaScript updates later.
  • Save public/index.html.
  • Press the Windows key to open Windows search.
  • Type Microsoft Edge and press Enter.
  • Enter http://127.0.0.1:3000 in the address bar.
  • Press Enter to load the page.

You should see the QuestForge heading followed by the player level and XP values. The page has plain browser styling at this stage.

Page missing or unavailable?

Return to the PowerShell terminal and confirm the QuestForge server is still running. Check that index.html is inside public.

Confirm that the address uses port 3000 and begins with http://.

Help me load the QuestForge page.

A quest form turns the board into an interface with a clear future request payload. The title, focus, and difficulty fields become the values sent to the API later.

  • In public/index.html, find </main> near the bottom of the file.
  • Insert the quest form immediately above </main> using this code:
      <section class="panel">
        <h2>Forge a Quest</h2>
        <form id="quest-form">
          <label for="title">Quest title</label>
          <input id="title" name="title" type="text" minlength="3" maxlength="60" required />

          <div class="form-grid">
            <label>
              Focus path
              <select id="focus" name="focus">
                <option value="frontend">Frontend</option>
                <option value="security">Security</option>
                <option value="game">Game Dev</option>
                <option value="ai">AI</option>
              </select>
            </label>

            <label>
              Difficulty
              <select id="difficulty" name="difficulty">
                <option value="easy">Easy</option>
                <option value="medium">Medium</option>
                <option value="hard">Hard</option>
              </select>
            </label>
          </div>

          <button type="submit">Add Quest</button>
          <p id="form-message" class="message" aria-live="polite"></p>
        </form>
      </section>

How is the quest form structured?

  • The title field limits normal browser input to between 3 and 60 characters.
  • The focus selector offers the four paths recognized by QuestForge.
  • The difficulty selector offers the three values that later determine XP.
  • The form-message element provides a visible status area for future submissions.
  • Save public/index.html.
  • Return to Microsoft Edge.
  • Press Enter in the address bar to request the current page again.

You should see the Forge a Quest section beneath the player panel. It includes one text field, two selectors, and the Add Quest button.

Quest form not visible?

Check that the new section sits inside <main class="shell"> and above </main>.

Confirm that the form ends with both </form> and </section>.

Help me find the missing quest form.

A static sample card previews the data that the API returns later. Its classes also give the stylesheet a concrete quest to format now.

  • In public/index.html, find </main> again.
  • Insert the Active Quests section immediately above </main> using this code:
      <section class="panel">
        <div class="section-heading">
          <h2>Active Quests</h2>
          <span id="quest-count">1 quest</span>
        </div>
        <div id="quest-list" class="quest-list">
          <article class="quest-card hard">
            <h3>Patch the login gate</h3>
            <div class="meta">
              <span>Security</span>
              <span>Hard</span>
              <span>+50 XP</span>
            </div>
          </article>
        </div>
      </section>

Why add a static quest?

The sample card makes the layout testable before the board can request data. Its title, focus, difficulty, and XP preview the shape of a rendered quest.

The quest-list container becomes the destination for server-backed cards in the next step.

  • Save public/index.html.
  • Return to Microsoft Edge.
  • Press Enter in the address bar to request the page again.

You should see an Active Quests section with the Patch the login gate sample. The card should show Security, Hard, and +50 XP.

Sample quest missing?

Confirm that the article sits inside the element with id="quest-list".

Check that the Active Quests section is above the closing </main> tag.

Help me restore the static quest card.

The security panel reserves a visible place for the trust-boundary experiment. Its attack button becomes the controlled bypass test when the API is connected.

  • In public/index.html, find the closing </main> tag.
  • Insert the security panel immediately above </main> using this code:
      <section class="security-panel">
        <div>
          <p class="eyebrow">SECURITY LAB</p>
          <h2>Can the client bypass the rules?</h2>
          <p>Send an invalid quest directly to the API and watch the server decide.</p>
        </div>
        <button id="attack-button" type="button" class="danger-button">
          Run Attack Test
        </button>
        <p id="security-status" class="message" aria-live="polite"></p>
      </section>

What is the security panel for?

The Run Attack Test button marks the browser-controlled request used in the security demonstration. The security-status element provides a visible place for the server's decision.

The controls now define the complete static interface. The next step connects them to real request behavior.

  • Save public/index.html.
  • Return to Microsoft Edge.
  • Press Enter in the address bar to request the page again.

You should see the Security Lab beneath the sample quest. It includes the Run Attack Test button.

Security panel in the wrong place?

Confirm that the security panel appears after the Active Quests section. Keep it inside the main element.

Check that the button uses id="attack-button" and type="button".

Help me fix the security panel structure.

✔️ Awesome, I've got everything!

The static page now contains the hero, player panel, form, sample quest, and security panel. Save public/index.html before continuing.

ⓧ I'd like to double check the full code

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>QuestForge</title>
    <link rel="stylesheet" href="/style.css" />
  </head>
  <body>
    <main class="shell">
      <header class="hero">
        <p class="eyebrow">FULL-STACK SECURITY QUEST</p>
        <h1>QuestForge</h1>
        <p>Forge learning missions. Earn XP. Defend the server.</p>
      </header>

      <section class="player-panel" aria-label="Player progress">
        <div>
          <span>PLAYER LEVEL</span>
          <strong id="player-level">Level 1</strong>
        </div>
        <div>
          <span>AVAILABLE XP</span>
          <strong id="total-xp">0 XP</strong>
        </div>
      </section>

      <section class="panel">
        <h2>Forge a Quest</h2>
        <form id="quest-form">
          <label for="title">Quest title</label>
          <input id="title" name="title" type="text" minlength="3" maxlength="60" required />

          <div class="form-grid">
            <label>
              Focus path
              <select id="focus" name="focus">
                <option value="frontend">Frontend</option>
                <option value="security">Security</option>
                <option value="game">Game Dev</option>
                <option value="ai">AI</option>
              </select>
            </label>

            <label>
              Difficulty
              <select id="difficulty" name="difficulty">
                <option value="easy">Easy</option>
                <option value="medium">Medium</option>
                <option value="hard">Hard</option>
              </select>
            </label>
          </div>

          <button type="submit">Add Quest</button>
          <p id="form-message" class="message" aria-live="polite"></p>
        </form>
      </section>

      <section class="panel">
        <div class="section-heading">
          <h2>Active Quests</h2>
          <span id="quest-count">1 quest</span>
        </div>
        <div id="quest-list" class="quest-list">
          <article class="quest-card hard">
            <h3>Patch the login gate</h3>
            <div class="meta">
              <span>Security</span>
              <span>Hard</span>
              <span>+50 XP</span>
            </div>
          </article>
        </div>
      </section>

      <section class="security-panel">
        <div>
          <p class="eyebrow">SECURITY LAB</p>
          <h2>Can the client bypass the rules?</h2>
          <p>Send an invalid quest directly to the API and watch the server decide.</p>
        </div>
        <button id="attack-button" type="button" class="danger-button">
          Run Attack Test
        </button>
        <p id="security-status" class="message" aria-live="polite"></p>
      </section>
    </main>
  </body>
</html>
Style and verify the board

The page structure is complete. The stylesheet now turns those sections into a dark game interface with neon highlights, responsive panels, and readable form controls.

  • Replace the empty contents of public/style.css with the page foundation below:
:root {
  color-scheme: dark;
  font-family: Inter, system-ui, sans-serif;
  background: #07111f;
  color: #e7f7ff;
}

* {
  box-sizing: border-box;
}

body {
  min-height: 100vh;
  margin: 0;
  background:
    radial-gradient(circle at top right, rgba(32, 211, 181, 0.18), transparent 30%),
    linear-gradient(145deg, #07111f, #0d1930);
}

How does the theme begin?

  • The root colors establish the dark canvas and pale text used across the board.
  • The universal sizing rule keeps borders and padding inside each element's calculated width.
  • The layered background adds a teal glow over the dark blue page.
  • Save public/style.css.
  • Return to Microsoft Edge.
  • Press Enter in the address bar to request the page again.

You should see the white page replaced by a dark blue background with a teal glow in the upper-right area.

Background still white?

Confirm that style.css is inside public and saved. Check that index.html links to /style.css.

Help me connect the QuestForge stylesheet.

  • Add the shared control styling below the body rule in public/style.css:
button,
input,
select {
  font: inherit;
}

button {
  border: 0;
  border-radius: 0.7rem;
  padding: 0.8rem 1rem;
  background: #20d3b5;
  color: #04130f;
  font-weight: 800;
  cursor: pointer;
}

button:hover {
  filter: brightness(1.08);
}

What changes for the controls?

The form controls inherit the page font for a consistent interface. Buttons receive a teal surface, dark text, rounded corners, and a brighter hover state.

  • Save public/style.css.
  • Return to Microsoft Edge.
  • Press Enter in the address bar to reload the page.

You should see teal Add Quest and Run Attack Test buttons with rounded corners.

Buttons still look plain?

Check that the closing brace for the body rule appears before the button selectors. A missing brace can absorb the new rules.

Help me fix the QuestForge button styles.

  • Add the page width and hero styling below the button rules:
.shell {
  width: min(920px, calc(100% - 2rem));
  margin: 0 auto;
  padding: 3rem 0;
}

.hero h1 {
  margin: 0;
  color: #7df9e1;
  font-size: clamp(2.5rem, 8vw, 5rem);
  letter-spacing: -0.06em;
}

.hero p:last-child {
  max-width: 42rem;
  color: #9db2c8;
}

.eyebrow {
  margin-bottom: 0.4rem;
  color: #20d3b5;
  font-size: 0.78rem;
  font-weight: 900;
  letter-spacing: 0.14em;
}

How does the hero take shape?

  • The shell rule centers the interface while keeping space at narrow screen widths.
  • The main title scales with the viewport and uses the brightest neon color.
  • The eyebrow style creates the compact labels used above the hero and security sections.
  • Save public/style.css.
  • Return to Microsoft Edge.
  • Press Enter in the address bar to reload the page.

You should see a centered layout with a large neon QuestForge title and compact teal eyebrow labels.

Hero not centered?

Confirm that the page content remains inside <main class="shell"> in index.html.

Help me align the QuestForge hero.

  • Add the shared panel and player progress styling below the hero rules:
.player-panel,
.panel,
.security-panel {
  margin-top: 1rem;
  border: 1px solid #243957;
  border-radius: 1rem;
  background: rgba(9, 23, 42, 0.92);
  box-shadow: 0 18px 50px rgba(0, 0, 0, 0.22);
}

.player-panel {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 1rem;
  padding: 1rem;
}

.player-panel div {
  display: flex;
  flex-direction: column;
  gap: 0.25rem;
}

.player-panel span,
.meta span,
.section-heading span {
  color: #8ba3bd;
  font-size: 0.82rem;
}

How do the panels work?

The shared rule gives every major section the same dark surface, border, rounded shape, and shadow. The player panel uses two equal columns for level and XP.

Smaller labels use a muted color so the changing values remain prominent.

  • Save public/style.css.
  • Return to Microsoft Edge.
  • Press Enter in the address bar to reload the page.

You should see the player, form, quest, and security areas grouped into rounded dark panels.

Panels missing their borders?

Check that the HTML uses player-panel, panel, and security-panel exactly. CSS class names must match the HTML spelling.

Help me match the QuestForge panel classes.

  • Add the form layout rules below the player panel styles:
.panel,
.security-panel {
  padding: 1.25rem;
}

form {
  display: grid;
  gap: 0.8rem;
}

.form-grid {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 0.8rem;
}

label {
  display: grid;
  gap: 0.35rem;
  color: #c9d8e6;
  font-weight: 700;
}

How is the form arranged?

The form uses consistent gaps between controls. The form-grid class places focus and difficulty in two equal columns.

Labels use a brighter text color and stronger weight to separate field names from their values.

  • Save public/style.css.
  • Return to Microsoft Edge.
  • Press Enter in the address bar to reload the page.

You should see the Focus path and Difficulty fields aligned in two columns.

Selectors stacked too early?

Confirm that both selectors remain inside <div class="form-grid">. Check that the class includes the hyphen.

Help me repair the form grid.

  • Add the field and quest grid styling below the form layout rules:
input,
select {
  width: 100%;
  border: 1px solid #385070;
  border-radius: 0.65rem;
  padding: 0.75rem;
  background: #07111f;
  color: #e7f7ff;
}

.section-heading,
.security-panel {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  flex-wrap: wrap;
}

.quest-list {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
  gap: 0.8rem;
}

What changes in the form and quest list?

Inputs and selectors now match the dark theme while retaining clear borders. The quest list automatically creates as many card columns as the available width supports.

The shared flex layout separates section headings from counts or actions while allowing narrow screens to wrap.

  • Save public/style.css.
  • Return to Microsoft Edge.
  • Press Enter in the address bar to reload the page.

You should see dark form fields with blue-gray borders. The Active Quests heading and quest count should sit apart on the same row.

Form fields difficult to read?

Check that the input and select rule includes both the dark background and pale text color. Confirm that commas separate the combined selectors.

Help me fix the form control colors.

  • Add the quest card color rules below the quest grid:
.quest-card {
  border-left: 4px solid #20d3b5;
  border-radius: 0.75rem;
  padding: 1rem;
  background: #101f37;
}

.quest-card.medium {
  border-left-color: #ffc857;
}

.quest-card.hard {
  border-left-color: #ff627d;
}

.quest-card h3 {
  margin-top: 0;
}

How does difficulty affect a card?

Every quest receives the same dark card surface and teal border by default. Medium quests switch to yellow while hard quests switch to pink.

The sample card uses the hard class, so its left edge displays the hard-difficulty color.

  • Save public/style.css.
  • Return to Microsoft Edge.
  • Press Enter in the address bar to reload the page.

You should see the sample quest inside a dark card with a pink left border.

Sample card has no pink edge?

Confirm that the sample article uses both quest-card and hard in its class value. Check that the CSS selector contains no space between those class names.

Help me fix the hard quest card.

  • Add the quest metadata and security button styles below the card rules:
.meta {
  display: flex;
  gap: 0.6rem;
  flex-wrap: wrap;
}

.meta span {
  border-radius: 999px;
  padding: 0.25rem 0.55rem;
  background: #07111f;
}

.security-panel {
  border-color: #754054;
}

.danger-button {
  background: #ff627d;
  color: #26030a;
}

How are security details highlighted?

Quest metadata becomes a row of compact pills that can wrap when space is tight. The security panel and attack button use pink accents to distinguish the experiment from normal quest creation.

  • Save public/style.css.
  • Return to Microsoft Edge.
  • Press Enter in the address bar to reload the page.

You should see pill-shaped metadata on the sample card and a pink Run Attack Test button.

Attack button still teal?

Confirm that the attack button has class="danger-button" in index.html. Check that the .danger-button rule comes after the general button rule.

Help me apply the security colors.

  • Add the message colors and responsive layout at the bottom of public/style.css:
.message {
  min-height: 1.4rem;
  margin: 0;
  color: #9db2c8;
}

.message.safe {
  color: #7df9e1;
}

.message.danger {
  color: #ff8ba0;
}

@media (max-width: 620px) {
  .form-grid,
  .player-panel {
    grid-template-columns: 1fr;
  }
}

How does the layout handle status and screen size?

Message elements reserve space for future feedback so panels remain steady when text appears. Safe feedback uses teal while danger feedback uses pink.

At widths of 620px or less, the two-column form and player panel switch to one column.

  • Save public/style.css.
  • Return to Microsoft Edge.
  • Press Enter in the address bar to reload the page.

You should see consistent empty space below the form and security controls for future messages. Narrowing the browser should stack the player values and form selectors into one column.

Mobile layout not stacking?

Confirm that the media rule appears after the main grid rules. Check that both .form-grid and .player-panel are inside the media rule.

Help me fix the responsive layout.

✔️ Awesome, I've got everything!

Your complete public/style.css file now covers the theme, panels, form controls, quest cards, messages, and responsive layout.

ⓧ I'd like to double check the full code

:root {
  color-scheme: dark;
  font-family: Inter, system-ui, sans-serif;
  background: #07111f;
  color: #e7f7ff;
}

* {
  box-sizing: border-box;
}

body {
  min-height: 100vh;
  margin: 0;
  background:
    radial-gradient(circle at top right, rgba(32, 211, 181, 0.18), transparent 30%),
    linear-gradient(145deg, #07111f, #0d1930);
}

button,
input,
select {
  font: inherit;
}

button {
  border: 0;
  border-radius: 0.7rem;
  padding: 0.8rem 1rem;
  background: #20d3b5;
  color: #04130f;
  font-weight: 800;
  cursor: pointer;
}

button:hover {
  filter: brightness(1.08);
}

.shell {
  width: min(920px, calc(100% - 2rem));
  margin: 0 auto;
  padding: 3rem 0;
}

.hero h1 {
  margin: 0;
  color: #7df9e1;
  font-size: clamp(2.5rem, 8vw, 5rem);
  letter-spacing: -0.06em;
}

.hero p:last-child {
  max-width: 42rem;
  color: #9db2c8;
}

.eyebrow {
  margin-bottom: 0.4rem;
  color: #20d3b5;
  font-size: 0.78rem;
  font-weight: 900;
  letter-spacing: 0.14em;
}

.player-panel,
.panel,
.security-panel {
  margin-top: 1rem;
  border: 1px solid #243957;
  border-radius: 1rem;
  background: rgba(9, 23, 42, 0.92);
  box-shadow: 0 18px 50px rgba(0, 0, 0, 0.22);
}

.player-panel {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 1rem;
  padding: 1rem;
}

.player-panel div {
  display: flex;
  flex-direction: column;
  gap: 0.25rem;
}

.player-panel span,
.meta span,
.section-heading span {
  color: #8ba3bd;
  font-size: 0.82rem;
}

.panel,
.security-panel {
  padding: 1.25rem;
}

form {
  display: grid;
  gap: 0.8rem;
}

.form-grid {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 0.8rem;
}

label {
  display: grid;
  gap: 0.35rem;
  color: #c9d8e6;
  font-weight: 700;
}

input,
select {
  width: 100%;
  border: 1px solid #385070;
  border-radius: 0.65rem;
  padding: 0.75rem;
  background: #07111f;
  color: #e7f7ff;
}

.section-heading,
.security-panel {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  flex-wrap: wrap;
}

.quest-list {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
  gap: 0.8rem;
}

.quest-card {
  border-left: 4px solid #20d3b5;
  border-radius: 0.75rem;
  padding: 1rem;
  background: #101f37;
}

.quest-card.medium {
  border-left-color: #ffc857;
}

.quest-card.hard {
  border-left-color: #ff627d;
}

.quest-card h3 {
  margin-top: 0;
}

.meta {
  display: flex;
  gap: 0.6rem;
  flex-wrap: wrap;
}

.meta span {
  border-radius: 999px;
  padding: 0.25rem 0.55rem;
  background: #07111f;
}

.security-panel {
  border-color: #754054;
}

.danger-button {
  background: #ff627d;
  color: #26030a;
}

.message {
  min-height: 1.4rem;
  margin: 0;
  color: #9db2c8;
}

.message.safe {
  color: #7df9e1;
}

.message.danger {
  color: #ff8ba0;
}

@media (max-width: 620px) {
  .form-grid,
  .player-panel {
    grid-template-columns: 1fr;
  }
}

Before the final check, predict which elements should use teal accents and which control should use the pink security accent.

  • Return to Microsoft Edge.
  • Enter http://127.0.0.1:3000 in the address bar.
  • Press Enter to request the finished static board.

You should see the neon QuestForge header, player level, XP total, quest form, sample quest card, security panel, and Run Attack Test button. The server-backed behavior arrives in the next step.

Finished board does not match?

Check the running PowerShell terminal for a file-reading error. Confirm that server.cjs is beside the public folder.

Press F12 to open Microsoft Edge DevTools. Look for a failed request involving style.css if the structure appears without styling.

Help me diagnose the finished QuestForge board.

That is the first playable-looking slice complete. Next, you will replace the static quest preview with live API data and use the attack button to expose a real trust-boundary flaw.

Connect the Vulnerable API

Your static QuestForge board already proves the interface works. Now the board needs a Node.js server that stores quests during the current session.

The first JSON API deliberately trusts fields controlled by the browser. The attack button gives you a visible test of why browser restrictions cannot protect a server.

In this step, get ready to:
  • Add in-memory quest data plus vulnerable API routes to the server.
  • Load API quests into the browser interface.
  • Submit a valid quest before bypassing the form with the attack test.
Add the vulnerable API routes

An API gives the browser a structured way to read or create quests. Each route combines an HTTP method with a URL.

Why plain Node.js?

The built-in HTTP module exposes each request and response directly. This keeps the browser-to-server exchange visible before a framework adds more layers.

  • Switch back to the server.cjs tab in Visual Studio Code.
  • Find the STATIC_FILES object near the top of the file.
  • Replace that object with this version so the server can serve the browser script:
const STATIC_FILES = {
  "/": { file: "index.html", type: "text/html; charset=utf-8" },
  "/style.css": { file: "style.css", type: "text/css; charset=utf-8" },
  "/app.js": { file: "app.js", type: "text/javascript; charset=utf-8" },
};

What changed in the static files?

The new /app.js entry maps a browser request to public/app.js. Its content type tells the browser to treat the response as JavaScript.

  • Add the in-memory quest state below STATIC_FILES by pasting this code:
let quests = [
  {
    id: 1,
    title: "Patch the login gate",
    focus: "security",
    difficulty: "hard",
    xp: 50,
  },
  {
    id: 2,
    title: "Prototype a player HUD",
    focus: "game",
    difficulty: "medium",
    xp: 25,
  },
];
let nextId = 3;

How does the quest state work?

  • The quests array starts with two cards so the API returns useful data immediately.
  • The nextId value gives each new quest a unique numeric identifier.
  • The data remains in memory. Restarting the server restores these two seeded quests.
  • Add the JSON response helper plus the vulnerable request reader below nextId by pasting this code:
function sendJson(response, statusCode, payload) {
  response.writeHead(statusCode, {
    "Content-Type": "application/json; charset=utf-8",
  });
  response.end(JSON.stringify(payload));
}

async function readJson(request) {
  let body = "";

  for await (const chunk of request) {
    body += chunk;
  }

  return body ? JSON.parse(body) : {};
}

What do these helpers do?

  • The sendJson() function sets the response content type before converting a JavaScript value into JSON.
  • The readJson() function collects the incoming request body before parsing it.
  • This first request reader has no body-size limit. You will close that gap when you defend the server.
  • Find the static-file condition inside the http.createServer() callback.
  • Paste these API route conditions immediately above that static-file condition:
if (request.method === "GET" && request.url === "/api/quests") {
  sendJson(response, 200, { quests });
  return;
}

if (request.method === "POST" && request.url === "/api/quests") {
  const payload = await readJson(request);

  const quest = {
    id: nextId,
    title: payload.title,
    focus: payload.focus,
    difficulty: payload.difficulty,
  };

  nextId += 1;
  quests.push(quest);
  sendJson(response, 201, { quest });
  return;
}

How do the vulnerable routes work?

  • The GET /api/quests route returns the current in-memory array.
  • The POST /api/quests route copies the submitted title directly into a new quest.
  • The route also copies the submitted focus plus difficulty without checking their allowed values.
  • The response uses status 201 to report that the server created a quest.
  • Save server.cjs by pressing Ctrl+S.
  • Return to the terminal running the server.
  • Stop the current process by pressing Ctrl+C.
  • Start the updated server by running this command:
node server.cjs

What should the server report?

You should see QuestForge running at http://127.0.0.1:3000 in the terminal. The process stays active so it can respond to browser requests.

  • Open a new Microsoft Edge tab.
  • Enter http://127.0.0.1:3000/api/quests in the address bar.
  • Press Enter to request the quest collection.

You should see JSON containing two quests. This proves the browser can reach the new API route.

Do you see an error response?

Check that server.cjs is saved. Confirm that the terminal still shows the running QuestForge process.

Compare the API route with the code above if the browser reports that the page was not found.

Help me debug the quest API route.

Load quests into the board

The browser needs a client script to request API data. The Fetch API sends the request while the DOM turns each returned quest into a visible card.

  • Select the public folder in the Visual Studio Code Explorer sidebar.
  • Right-click the public folder.
  • Select New File.
  • Enter app.js as the file name.
  • Add the labels plus page references by pasting this first chunk:
const FOCUS_LABELS = {
  frontend: "Frontend",
  security: "Security",
  game: "Game Dev",
  ai: "AI",
};

const DIFFICULTY_LABELS = {
  easy: "Easy",
  medium: "Medium",
  hard: "Hard",
};

const questForm = document.getElementById("quest-form");
const titleInput = document.getElementById("title");
const focusInput = document.getElementById("focus");
const difficultyInput = document.getElementById("difficulty");
const questList = document.getElementById("quest-list");
const questCount = document.getElementById("quest-count");
const totalXp = document.getElementById("total-xp");
const playerLevel = document.getElementById("player-level");
const formMessage = document.getElementById("form-message");
const attackButton = document.getElementById("attack-button");
const securityStatus = document.getElementById("security-status");

What does this setup hold?

  • The label objects convert stored values into readable card labels.
  • The DOM references connect the script to the form fields plus status elements already present in index.html.
  • Add the message helper plus request helper below the DOM references:
function setMessage(element, text, tone = "") {
  element.textContent = text;
  element.className = `message ${tone}`.trim();
}

async function requestJson(url, options) {
  const response = await fetch(url, options);
  const data = await response.json();

  if (!response.ok) {
    throw new Error(data.error || `Request failed with ${response.status}`);
  }

  return data;
}

How does the browser handle responses?

  • The setMessage() helper updates a status element with plain text plus a visual tone.
  • The requestJson() helper sends a request before parsing its JSON response.
  • The response check converts unsuccessful HTTP statuses into errors the page can display.
  • Start the quest-rendering function below requestJson() by pasting this chunk:
function renderQuests(quests) {
  questList.replaceChildren();

  const xpTotal = quests.reduce((sum, quest) => sum + quest.xp, 0);
  totalXp.textContent = `${xpTotal} XP`;
  playerLevel.textContent = `Level ${Math.floor(xpTotal / 100) + 1}`;
  questCount.textContent = `${quests.length} ${quests.length === 1 ? "quest" : "quests"}`;

  for (const quest of quests) {
    const card = document.createElement("article");
    card.className = `quest-card ${quest.difficulty}`;

    const title = document.createElement("h3");
    title.textContent = quest.title || "Untitled quest";

    const meta = document.createElement("div");
    meta.className = "meta";

What does the first rendering chunk do?

This part clears old cards before recalculating the XP total plus player level. It also starts one article for every quest returned by the API.

The function remains open at the bottom of this chunk. The next chunk completes each card before you save the file.

  • Complete renderQuests() by pasting this code directly below the previous chunk:
    const focus = document.createElement("span");
    focus.textContent = FOCUS_LABELS[quest.focus] || quest.focus;

    const difficulty = document.createElement("span");
    difficulty.textContent = DIFFICULTY_LABELS[quest.difficulty] || quest.difficulty;

    const xp = document.createElement("span");
    xp.textContent = `+${quest.xp || 0} XP`;

    meta.appendChild(focus);
    meta.appendChild(difficulty);
    meta.appendChild(xp);
    card.appendChild(title);
    card.appendChild(meta);
    questList.appendChild(card);
  }
}

async function loadQuests() {
  try {
    const data = await requestJson("/api/quests");
    renderQuests(data.quests);
  } catch (error) {
    setMessage(formMessage, error.message, "danger");
  }
}

How are the cards completed?

  • Each card receives focus plus difficulty labels from the lookup objects.
  • A missing XP value displays as 0. This becomes visible during the attack test.
  • The loadQuests() function requests the collection before passing it to the renderer.
  • Add this final function call at the bottom of app.js so the page loads quests immediately:
loadQuests();

Why call loadQuests here?

The browser runs this line after defining the helpers. It requests the current quest collection as soon as the script loads.

  • Switch back to public/index.html.
  • Find the Active Quests section.
  • Replace the static sample-card markup with this dynamic section:
<section class="panel">
  <div class="section-heading">
    <h2>Active Quests</h2>
    <span id="quest-count">0 quests</span>
  </div>
  <div id="quest-list" class="quest-list"></div>
</section>

What changed in the quest section?

The static card is gone. The script now owns the empty quest-list container plus its live quest count.

  • Find the closing </body> tag near the bottom of public/index.html.
  • Add this script element immediately above that closing tag:
<script src="/app.js"></script>

How does the page start the client?

The script element requests /app.js after the page elements exist. The client can safely find each element by its ID.

  • Save public/app.js.
  • Save public/index.html.
  • Return to the QuestForge tab in Microsoft Edge.
  • Refresh http://127.0.0.1:3000.

You should see two API-backed cards. The player panel should show 75 XP plus Level 1.

Are the quest cards missing?

Confirm that the server terminal is still running. Check that app.js sits inside the public folder.

Check the script path plus each element ID against the snippets above. One mismatched ID prevents that page element from updating.

Help me debug the missing API quest cards.

Submit a quest and run the attack

The form can now send browser values to the server. The attack button will send its own JSON directly to the same route without using the form controls.

  • Switch back to public/app.js.
  • Add the form submission handler immediately above loadQuests(); by pasting this code:
questForm.addEventListener("submit", async (event) => {
  event.preventDefault();
  setMessage(formMessage, "Submitting quest...");

  const payload = {
    title: titleInput.value,
    focus: focusInput.value,
    difficulty: difficultyInput.value,
  };

  try {
    await requestJson("/api/quests", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(payload),
    });
    questForm.reset();
    setMessage(formMessage, "Quest accepted by the server.", "safe");
    await loadQuests();
  } catch (error) {
    setMessage(formMessage, error.message, "danger");
  }
});

How does form submission work?

  • The submit listener prevents the browser from reloading the page.
  • The payload reads the current title plus selected focus plus selected difficulty.
  • The request sends that payload as JSON to /api/quests.
  • A successful response clears the form before refreshing the rendered cards.
  • Add the attack handler immediately above loadQuests(); by pasting this code:
attackButton.addEventListener("click", async () => {
  setMessage(securityStatus, "Sending invalid quest...");

  const response = await fetch("/api/quests", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      title: "",
      focus: "security",
      difficulty: "legendary",
    }),
  });
  const data = await response.json();

  if (response.ok) {
    setMessage(
      securityStatus,
      "Attack succeeded: the server trusted invalid input.",
      "danger",
    );
    await loadQuests();
    return;
  }

  setMessage(securityStatus, `Attack blocked: ${data.error}`, "safe");
});

How does the attack bypass the form?

The click handler constructs JSON without reading the visible inputs. It can send an empty title plus the unsupported legendary difficulty even though the form would reject those values.

The handler checks the server response before reporting whether the request succeeded. The server makes the final security decision.

  • Save public/app.js.
  • Return to the terminal running QuestForge.
  • Stop the current process by pressing Ctrl+C.

Before you restart the server, how many seeded quests do you expect the board to show?

  • Restart QuestForge by running this command:
node server.cjs

What did the restart reset?

The new process recreates the in-memory array with two seeded quests. You should see the local QuestForge address in the terminal again.

  • Refresh the QuestForge page in Microsoft Edge.
  • Enter Review session permissions in the Quest title field.
  • Select Security from the Focus path menu.
  • Select Medium from the Difficulty menu.
  • Click Add Quest.

You should see the new quest card plus Quest accepted by the server. below the form. The total should rise to 100 XP while the player reaches Level 2.

Before you run the attack, do you think the server will enforce the form's title plus difficulty rules?

  • Click Run Attack Test in the security panel.

You should see a fourth card created from the hostile request. Its blank title appears as Untitled quest while its unsupported difficulty appears as legendary.

The card is worth +0 XP. The security panel reads Attack succeeded: the server trusted invalid input.

This failure is intentional

The form had restrictions, yet a direct request bypassed all of them. The vulnerable server accepted the browser-controlled values because it did not enforce its own rules.

Does the attack button do nothing?

Confirm that attack-button matches the button ID in index.html. Confirm that the attack listener sits above the final loadQuests(); call.

Restart the server if the terminal stopped. Refresh Microsoft Edge after saving app.js.

Help me debug the QuestForge attack button.

Use these tabs to compare your cumulative project files with the vulnerable working version.

✔️ Awesome, I've got everything!

Great work. Your browser can now load quests plus submit them to the server.

The attack result also proves that the current server trusts invalid input.

ⓧ I'd like to double check the full code

Compare your server.cjs with this vulnerable version.

const http = require("node:http");
const { readFile } = require("node:fs/promises");
const path = require("node:path");

const HOST = "127.0.0.1";
const PORT = 3000;
const PUBLIC_DIR = path.join(__dirname, "public");

const STATIC_FILES = {
  "/": { file: "index.html", type: "text/html; charset=utf-8" },
  "/style.css": { file: "style.css", type: "text/css; charset=utf-8" },
  "/app.js": { file: "app.js", type: "text/javascript; charset=utf-8" },
};

let quests = [
  {
    id: 1,
    title: "Patch the login gate",
    focus: "security",
    difficulty: "hard",
    xp: 50,
  },
  {
    id: 2,
    title: "Prototype a player HUD",
    focus: "game",
    difficulty: "medium",
    xp: 25,
  },
];
let nextId = 3;

function sendJson(response, statusCode, payload) {
  response.writeHead(statusCode, {
    "Content-Type": "application/json; charset=utf-8",
  });
  response.end(JSON.stringify(payload));
}

async function readJson(request) {
  let body = "";

  for await (const chunk of request) {
    body += chunk;
  }

  return body ? JSON.parse(body) : {};
}

async function serveStatic(request, response) {
  const asset = STATIC_FILES[request.url];
  if (!asset) {
    return false;
  }

  const filePath = path.join(PUBLIC_DIR, asset.file);
  const contents = await readFile(filePath);
  response.writeHead(200, { "Content-Type": asset.type });
  response.end(contents);
  return true;
}

const server = http.createServer(async (request, response) => {
  try {
    if (request.method === "GET" && request.url === "/api/quests") {
      sendJson(response, 200, { quests });
      return;
    }

    if (request.method === "POST" && request.url === "/api/quests") {
      const payload = await readJson(request);

      const quest = {
        id: nextId,
        title: payload.title,
        focus: payload.focus,
        difficulty: payload.difficulty,
      };

      nextId += 1;
      quests.push(quest);
      sendJson(response, 201, { quest });
      return;
    }

    if (request.method === "GET" && (await serveStatic(request, response))) {
      return;
    }

    sendJson(response, 404, { error: "Not found." });
  } catch (error) {
    console.error(error);
    sendJson(response, 500, { error: "Unexpected server error." });
  }
});

server.listen(PORT, HOST, () => {
  console.log(`QuestForge running at http://${HOST}:${PORT}`);
});

This version intentionally has no server-side field validation or request-size limit.

Compare your public/index.html with this dynamic page.

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>QuestForge</title>
    <link rel="stylesheet" href="/style.css" />
  </head>
  <body>
    <main class="shell">
      <header class="hero">
        <p class="eyebrow">FULL-STACK SECURITY QUEST</p>
        <h1>QuestForge</h1>
        <p>Forge learning missions. Earn XP. Defend the server.</p>
      </header>

      <section class="player-panel" aria-label="Player progress">
        <div>
          <span>PLAYER LEVEL</span>
          <strong id="player-level">Level 1</strong>
        </div>
        <div>
          <span>AVAILABLE XP</span>
          <strong id="total-xp">0 XP</strong>
        </div>
      </section>

      <section class="panel">
        <h2>Forge a Quest</h2>
        <form id="quest-form">
          <label for="title">Quest title</label>
          <input
            id="title"
            name="title"
            type="text"
            minlength="3"
            maxlength="60"
            required
          />

          <div class="form-grid">
            <label>
              Focus path
              <select id="focus" name="focus">
                <option value="frontend">Frontend</option>
                <option value="security">Security</option>
                <option value="game">Game Dev</option>
                <option value="ai">AI</option>
              </select>
            </label>

            <label>
              Difficulty
              <select id="difficulty" name="difficulty">
                <option value="easy">Easy</option>
                <option value="medium">Medium</option>
                <option value="hard">Hard</option>
              </select>
            </label>
          </div>

          <button type="submit">Add Quest</button>
          <p id="form-message" class="message" aria-live="polite"></p>
        </form>
      </section>

      <section class="panel">
        <div class="section-heading">
          <h2>Active Quests</h2>
          <span id="quest-count">0 quests</span>
        </div>
        <div id="quest-list" class="quest-list"></div>
      </section>

      <section class="security-panel">
        <div>
          <p class="eyebrow">SECURITY LAB</p>
          <h2>Can the client bypass the rules?</h2>
          <p>Send an invalid quest directly to the API and watch the server decide.</p>
        </div>
        <button id="attack-button" type="button" class="danger-button">
          Run Attack Test
        </button>
        <p id="security-status" class="message" aria-live="polite"></p>
      </section>
    </main>

    <script src="/app.js"></script>
  </body>
</html>

This file leaves the quest list empty so app.js can render API data into it.

Compare your public/app.js with this browser client.

const FOCUS_LABELS = {
  frontend: "Frontend",
  security: "Security",
  game: "Game Dev",
  ai: "AI",
};

const DIFFICULTY_LABELS = {
  easy: "Easy",
  medium: "Medium",
  hard: "Hard",
};

const questForm = document.getElementById("quest-form");
const titleInput = document.getElementById("title");
const focusInput = document.getElementById("focus");
const difficultyInput = document.getElementById("difficulty");
const questList = document.getElementById("quest-list");
const questCount = document.getElementById("quest-count");
const totalXp = document.getElementById("total-xp");
const playerLevel = document.getElementById("player-level");
const formMessage = document.getElementById("form-message");
const attackButton = document.getElementById("attack-button");
const securityStatus = document.getElementById("security-status");

function setMessage(element, text, tone = "") {
  element.textContent = text;
  element.className = `message ${tone}`.trim();
}

async function requestJson(url, options) {
  const response = await fetch(url, options);
  const data = await response.json();

  if (!response.ok) {
    throw new Error(data.error || `Request failed with ${response.status}`);
  }

  return data;
}

function renderQuests(quests) {
  questList.replaceChildren();

  const xpTotal = quests.reduce((sum, quest) => sum + quest.xp, 0);
  totalXp.textContent = `${xpTotal} XP`;
  playerLevel.textContent = `Level ${Math.floor(xpTotal / 100) + 1}`;
  questCount.textContent = `${quests.length} ${quests.length === 1 ? "quest" : "quests"}`;

  for (const quest of quests) {
    const card = document.createElement("article");
    card.className = `quest-card ${quest.difficulty}`;

    const title = document.createElement("h3");
    title.textContent = quest.title || "Untitled quest";

    const meta = document.createElement("div");
    meta.className = "meta";

    const focus = document.createElement("span");
    focus.textContent = FOCUS_LABELS[quest.focus] || quest.focus;

    const difficulty = document.createElement("span");
    difficulty.textContent = DIFFICULTY_LABELS[quest.difficulty] || quest.difficulty;

    const xp = document.createElement("span");
    xp.textContent = `+${quest.xp || 0} XP`;

    meta.appendChild(focus);
    meta.appendChild(difficulty);
    meta.appendChild(xp);
    card.appendChild(title);
    card.appendChild(meta);
    questList.appendChild(card);
  }
}

async function loadQuests() {
  try {
    const data = await requestJson("/api/quests");
    renderQuests(data.quests);
  } catch (error) {
    setMessage(formMessage, error.message, "danger");
  }
}

questForm.addEventListener("submit", async (event) => {
  event.preventDefault();
  setMessage(formMessage, "Submitting quest...");

  const payload = {
    title: titleInput.value,
    focus: focusInput.value,
    difficulty: difficultyInput.value,
  };

  try {
    await requestJson("/api/quests", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(payload),
    });
    questForm.reset();
    setMessage(formMessage, "Quest accepted by the server.", "safe");
    await loadQuests();
  } catch (error) {
    setMessage(formMessage, error.message, "danger");
  }
});

attackButton.addEventListener("click", async () => {
  setMessage(securityStatus, "Sending invalid quest...");

  const response = await fetch("/api/quests", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      title: "",
      focus: "security",
      difficulty: "legendary",
    }),
  });
  const data = await response.json();

  if (response.ok) {
    setMessage(
      securityStatus,
      "Attack succeeded: the server trusted invalid input.",
      "danger",
    );
    await loadQuests();
    return;
  }

  setMessage(securityStatus, `Attack blocked: ${data.error}`, "safe");
});

loadQuests();

The client loads quests on startup. It also sends valid form requests plus the deliberately invalid attack request.

Your existing public/style.css should match this stylesheet.

:root {
  color-scheme: dark;
  font-family: Inter, system-ui, sans-serif;
  background: #07111f;
  color: #e7f7ff;
}

* {
  box-sizing: border-box;
}

body {
  min-height: 100vh;
  margin: 0;
  background:
    radial-gradient(circle at top right, rgba(32, 211, 181, 0.18), transparent 30%),
    linear-gradient(145deg, #07111f, #0d1930);
}

button,
input,
select {
  font: inherit;
}

button {
  border: 0;
  border-radius: 0.7rem;
  padding: 0.8rem 1rem;
  background: #20d3b5;
  color: #04130f;
  font-weight: 800;
  cursor: pointer;
}

button:hover {
  filter: brightness(1.08);
}

.shell {
  width: min(920px, calc(100% - 2rem));
  margin: 0 auto;
  padding: 3rem 0;
}

.hero h1 {
  margin: 0;
  color: #7df9e1;
  font-size: clamp(2.5rem, 8vw, 5rem);
  letter-spacing: -0.06em;
}

.hero p:last-child {
  max-width: 42rem;
  color: #9db2c8;
}

.eyebrow {
  margin-bottom: 0.4rem;
  color: #20d3b5;
  font-size: 0.78rem;
  font-weight: 900;
  letter-spacing: 0.14em;
}

.player-panel,
.panel,
.security-panel {
  margin-top: 1rem;
  border: 1px solid #243957;
  border-radius: 1rem;
  background: rgba(9, 23, 42, 0.92);
  box-shadow: 0 18px 50px rgba(0, 0, 0, 0.22);
}

.player-panel {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 1rem;
  padding: 1rem;
}

.player-panel div {
  display: flex;
  flex-direction: column;
  gap: 0.25rem;
}

.player-panel span,
.meta span,
.section-heading span {
  color: #8ba3bd;
  font-size: 0.82rem;
}

.panel,
.security-panel {
  padding: 1.25rem;
}

form {
  display: grid;
  gap: 0.8rem;
}

.form-grid {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 0.8rem;
}

label {
  display: grid;
  gap: 0.35rem;
  color: #c9d8e6;
  font-weight: 700;
}

input,
select {
  width: 100%;
  border: 1px solid #385070;
  border-radius: 0.65rem;
  padding: 0.75rem;
  background: #07111f;
  color: #e7f7ff;
}

.section-heading,
.security-panel {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  flex-wrap: wrap;
}

.quest-list {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
  gap: 0.8rem;
}

.quest-card {
  border-left: 4px solid #20d3b5;
  border-radius: 0.75rem;
  padding: 1rem;
  background: #101f37;
}

.quest-card.medium {
  border-left-color: #ffc857;
}

.quest-card.hard {
  border-left-color: #ff627d;
}

.quest-card h3 {
  margin-top: 0;
}

.meta {
  display: flex;
  gap: 0.6rem;
  flex-wrap: wrap;
}

.meta span {
  border-radius: 999px;
  padding: 0.25rem 0.55rem;
  background: #07111f;
}

.security-panel {
  border-color: #754054;
}

.danger-button {
  background: #ff627d;
  color: #26030a;
}

.message {
  min-height: 1.4rem;
  margin: 0;
  color: #9db2c8;
}

.message.safe {
  color: #7df9e1;
}

.message.danger {
  color: #ff8ba0;
}

@media (max-width: 620px) {
  .form-grid,
  .player-panel {
    grid-template-columns: 1fr;
  }
}

The stylesheet already supports dynamic cards plus safe and danger status messages.

You have connected the board to a working API and watched client-side restrictions fail. Next, you will move the quest rules to the server so the same hostile request is rejected.

Defend the Trust Boundary

Your attack test just crossed the browser-to-server trust boundary. The server accepted client-controlled JSON that the form would never allow.

This step moves the quest rules into Node.js through server-side validation. The server will also limit request sizes before it parses them.

In this step, get ready to:
  • Define the allowed focus values and server-awarded XP values.
  • Limit request bodies and reject malformed quest data.
  • Restart QuestForge and prove that valid quests pass while the attack fails.
Define the server rules

An allowlist names every value the server accepts. QuestForge also needs its own XP table so a browser cannot award an arbitrary score.

  • Switch back to the PowerShell terminal running QuestForge.
  • Stop the current server by pressing Ctrl+C.
  • Select server.cjs in the VS Code Explorer sidebar.
  • Add the body limit directly below const PUBLIC_DIR = path.join(__dirname, "public"); by pasting:
const MAX_BODY_LENGTH = 1000;

Why limit the request body?

The server accumulates incoming request data in memory. MAX_BODY_LENGTH gives that process a fixed boundary before JSON parsing begins.

  • Find the STATIC_FILES object in server.cjs.
  • Define the accepted difficulty rewards and focus values immediately after that object's closing brace by pasting:
const XP_BY_DIFFICULTY = Object.freeze({
  easy: 10,
  medium: 25,
  hard: 50,
});

const ALLOWED_FOCUS = new Set(["frontend", "security", "game", "ai"]);

What do these rules protect?

  • The frozen XP_BY_DIFFICULTY object maps each supported difficulty to an XP reward controlled by the server.
  • The ALLOWED_FOCUS set supports exact membership checks for the four documented focus values.
  • These rules keep browser-controlled values from becoming trusted quest data.
  • Save server.cjs.
  • Check that the new constants load correctly by running:
node server.cjs

You should see QuestForge running at http://127.0.0.1:3000. This confirms that Node.js can parse the new rules.

Server stopped before the address appeared?

Check that the XP object has matching braces. Check that every string in ALLOWED_FOCUS has matching quotation marks.

Compare the line identified by the terminal with the two code blocks above.

Help me fix the new QuestForge constants.

Bound and validate request data

A request body arrives as a stream of smaller chunks. The server must stop oversized input while reading that stream.

  • Stop the server by pressing Ctrl+C in the PowerShell terminal.
  • Find the closing brace of sendJson() in server.cjs.
  • Add the bounded JSON reader immediately below sendJson() by pasting:
async function readJson(request) {
  let body = "";
  for await (const chunk of request) {
    body += chunk;
    if (body.length > MAX_BODY_LENGTH) throw new Error("REQUEST_TOO_LARGE");
  }
  return body ? JSON.parse(body) : {};
}

How does the body reader work?

  • The body string collects each incoming chunk.
  • The length check stops accumulation once the request exceeds MAX_BODY_LENGTH.
  • JSON.parse() converts a permitted body into a JavaScript value after the size check passes.
  • Save server.cjs.
  • Confirm that the body reader has valid syntax by running:
node server.cjs

You should see the QuestForge address again. The running server proves that the new asynchronous reader loaded successfully.

Server not starting after readJson()?

Check that readJson() sits outside sendJson(). Confirm that the loop and function each have a closing brace.

Help me debug the bounded JSON reader.

  • Stop the server again by pressing Ctrl+C.
  • Add the quest validator immediately below readJson() by pasting:
function validateQuest(payload) {
  if (!payload || typeof payload !== "object" || Array.isArray(payload)) return "Request body must be a JSON object.";
  if (typeof payload.title !== "string") return "Title must be text.";
  const title = payload.title.trim();
  if (title.length < 3 || title.length > 60) return "Title must be 3 to 60 characters.";
  if (!ALLOWED_FOCUS.has(payload.focus)) return "Focus must be frontend, security, game, or ai.";
  if (XP_BY_DIFFICULTY[payload.difficulty] === undefined) return "Difficulty must be easy, medium, or hard.";
  return null;
}

What does validateQuest() enforce?

  • The first check requires the request body to be a JSON object.
  • The title checks require text with a trimmed length from 3 to 60 characters.
  • The membership checks accept only the focus and difficulty values defined by the server.
  • A null result means the payload passed every rule.
  • Save server.cjs.
  • Confirm that the validator loads by running:
node server.cjs

You should see the local QuestForge address once more. The server now has reusable validation rules ready for the POST route.

Validator causing a syntax error?

Check each returned message for matching quotation marks. Confirm that validateQuest() closes before serveStatic() begins.

Help me repair validateQuest().

Secure and test the POST route

The vulnerable POST route currently creates a quest from unchecked fields. The secured route reads a bounded body before it validates every field.

  • Stop the server by pressing Ctrl+C.
  • Find the branch beginning with if (request.method === "POST" && request.url === "/api/quests") {.
  • Select the entire POST branch up to the following GET branch.
  • Replace the selected branch with this secured version:
    if (request.method === "POST" && request.url === "/api/quests") {
      let payload;
      try { payload = await readJson(request); } catch (error) {
        sendJson(response, error.message === "REQUEST_TOO_LARGE" ? 413 : 400, { error: error.message === "REQUEST_TOO_LARGE" ? "Request body is too large." : "Request body must be valid JSON." });
        return;
      }
      const validationError = validateQuest(payload);
      if (validationError) { sendJson(response, 400, { error: validationError }); return; }
      const quest = { id: nextId, title: payload.title.trim(), focus: payload.focus, difficulty: payload.difficulty, xp: XP_BY_DIFFICULTY[payload.difficulty] };
      nextId += 1;
      quests.push(quest);
      sendJson(response, 201, { quest });
      return;
    }

How does the secured route respond?

  • An oversized body receives HTTP status 413.
  • Malformed JSON and invalid quest fields receive HTTP status 400.
  • A valid quest receives HTTP status 201 after the server trims its title and calculates its XP.
  • The browser can request a difficulty. Only the server decides the matching score.
  • Save server.cjs.
  • Switch to public/app.js in the VS Code Explorer sidebar.
  • Confirm that renderQuests() assigns submitted titles through textContent.

Why keep textContent?

The textContent property renders a submitted title as plain text. User-controlled markup stays visible as text instead of becoming browser content.

✔️ Awesome, I've got everything!

Your server now limits request bodies, validates every quest field, and computes XP from its own rules. Double check that you saved server.cjs.

ⓧ I'd like to double check the full code

Compare your complete server.cjs file with this secured version.

const http = require("node:http");
const { readFile } = require("node:fs/promises");
const path = require("node:path");

const HOST = "127.0.0.1";
const PORT = 3000;
const PUBLIC_DIR = path.join(__dirname, "public");
const MAX_BODY_LENGTH = 1000;

const STATIC_FILES = {
  "/": { file: "index.html", type: "text/html; charset=utf-8" },
  "/style.css": { file: "style.css", type: "text/css; charset=utf-8" },
  "/app.js": { file: "app.js", type: "text/javascript; charset=utf-8" },
};

const XP_BY_DIFFICULTY = Object.freeze({
  easy: 10,
  medium: 25,
  hard: 50,
});

const ALLOWED_FOCUS = new Set(["frontend", "security", "game", "ai"]);

let quests = [
  { id: 1, title: "Patch the login gate", focus: "security", difficulty: "hard", xp: 50 },
  { id: 2, title: "Prototype a player HUD", focus: "game", difficulty: "medium", xp: 25 },
];
let nextId = 3;

function sendJson(response, statusCode, payload) {
  response.writeHead(statusCode, { "Content-Type": "application/json; charset=utf-8" });
  response.end(JSON.stringify(payload));
}

async function readJson(request) {
  let body = "";
  for await (const chunk of request) {
    body += chunk;
    if (body.length > MAX_BODY_LENGTH) throw new Error("REQUEST_TOO_LARGE");
  }
  return body ? JSON.parse(body) : {};
}

function validateQuest(payload) {
  if (!payload || typeof payload !== "object" || Array.isArray(payload)) return "Request body must be a JSON object.";
  if (typeof payload.title !== "string") return "Title must be text.";
  const title = payload.title.trim();
  if (title.length < 3 || title.length > 60) return "Title must be 3 to 60 characters.";
  if (!ALLOWED_FOCUS.has(payload.focus)) return "Focus must be frontend, security, game, or ai.";
  if (XP_BY_DIFFICULTY[payload.difficulty] === undefined) return "Difficulty must be easy, medium, or hard.";
  return null;
}

async function serveStatic(request, response) {
  const asset = STATIC_FILES[request.url];
  if (!asset) return false;
  const contents = await readFile(path.join(PUBLIC_DIR, asset.file));
  response.writeHead(200, { "Content-Type": asset.type });
  response.end(contents);
  return true;
}

const server = http.createServer(async (request, response) => {
  try {
    if (request.method === "GET" && request.url === "/api/quests") {
      sendJson(response, 200, { quests });
      return;
    }
    if (request.method === "POST" && request.url === "/api/quests") {
      let payload;
      try { payload = await readJson(request); } catch (error) {
        sendJson(response, error.message === "REQUEST_TOO_LARGE" ? 413 : 400, { error: error.message === "REQUEST_TOO_LARGE" ? "Request body is too large." : "Request body must be valid JSON." });
        return;
      }
      const validationError = validateQuest(payload);
      if (validationError) { sendJson(response, 400, { error: validationError }); return; }
      const quest = { id: nextId, title: payload.title.trim(), focus: payload.focus, difficulty: payload.difficulty, xp: XP_BY_DIFFICULTY[payload.difficulty] };
      nextId += 1;
      quests.push(quest);
      sendJson(response, 201, { quest });
      return;
    }
    if (request.method === "GET" && (await serveStatic(request, response))) return;
    sendJson(response, 404, { error: "Not found." });
  } catch (error) {
    console.error(error);
    sendJson(response, 500, { error: "Unexpected server error." });
  }
});

server.listen(PORT, HOST, () => {
  console.log(`QuestForge running at http://${HOST}:${PORT}`);
});

Before you restart, do you expect the blank Legendary quest from the vulnerable run to remain in memory?

  • Switch back to the PowerShell terminal.
  • Restart QuestForge with the secured server by running:
node server.cjs

You should see QuestForge running at http://127.0.0.1:3000. Restarting also resets the in-memory array to the two seeded quests.

Server failed after the route replacement?

Check that the secured POST branch remains inside the try block of the server callback. Confirm that the following GET branch still appears after it.

Help me fix the secured POST route.

  • Switch back to Microsoft Edge.
  • Refresh http://127.0.0.1:3000.

You should see the two seeded quests with 75 XP total. The blank Legendary quest has disappeared because the server restarted its in-memory data.

  • Enter Map secure inventory in the Quest title field.
  • Select Security from the Focus path menu.
  • Select Medium from the Difficulty menu.
  • Click Add Quest.

You should see the new quest card with +25 XP. The player panel should now show 100 XP and Level 2.

Before you run the attack again, do you expect the same hostile request to create another blank quest?

  • Click Run Attack Test.

You should see Attack blocked: Title must be 3 to 60 characters. in the security panel. No blank Legendary card should appear.

Attack still succeeding?

Confirm that you stopped the previous Node.js process before starting the secured server. Check that the POST route calls validateQuest(payload) before it creates a quest.

Help me find why the invalid quest is still accepted.

You have closed the trust-boundary gap. Next, you will turn the secured project into a public GitHub repository that another developer can review.

Publish QuestForge to GitHub

Your secured QuestForge board now rejects hostile input at the server boundary. The next goal is to make that work reviewable outside your computer.

A local build is difficult to show. Git records the secure source as a commit.

GitHub publishes that commit in a public repository. Reviewers can inspect the working code plus its security explanation.

In this step, get ready to:
  • Add README.md with local run steps plus the security lesson.
  • Capture the secure project in a local Git commit.
  • Publish the main branch in a public GitHub repository.
Document and commit the secure board

A README gives another developer the context needed to run QuestForge. A Git commit captures the finished secure project as a reviewable checkpoint.

  • Create README.md inside the questforge folder with the Explorer sidebar's file creation control in Visual Studio Code.

You'll see an empty README.md tab beside your existing project files.

  • Add the project overview to README.md by pasting this content:
# QuestForge

QuestForge is a game-styled full-stack JavaScript learning board. It pairs a browser interface with a dependency-free Node.js JSON API.

## Security lesson

The first API version trusts browser-side form restrictions. Its built-in attack test bypasses those restrictions and submits invalid JSON data. The secured version validates title length, focus, and difficulty on the server, bounds the request body, computes XP on the server, and renders submitted text with `textContent`.

## Run locally

1. Open this folder in PowerShell.
2. Run `node server.cjs`.
3. Open `http://127.0.0.1:3000` in Microsoft Edge.
4. Submit a quest, then run the attack test.

The quests are stored in memory and reset whenever the Node.js process restarts.

What does this README explain?

  • The opening identifies QuestForge as a full-stack JavaScript learning board.
  • The security section records the browser validation bypass plus the server-side defense.
  • The local run section gives another developer a short path to launch the project.
  • The final note explains why restarting the Node.js process resets every quest.
  • Save README.md in Visual Studio Code.
  • Confirm the saved file by locating README.md beside server.cjs in the Explorer sidebar.

You now have a project landing page that explains both the working demo plus the trust-boundary lesson.

Missing README.md?

Check that the file sits directly inside questforge. A file created inside public would be published in the wrong location.

Help me place the README correctly.

✔️ Awesome, I've got everything!

Great. Your saved README.md now documents the finished secure project.

ⓧ I'd like to double check the full code

Compare your complete README.md with this reference:

# QuestForge

QuestForge is a game-styled full-stack JavaScript learning board. It pairs a browser interface with a dependency-free Node.js JSON API.

## Security lesson

The first API version trusts browser-side form restrictions. Its built-in attack test bypasses those restrictions and submits invalid JSON data. The secured version validates title length, focus, and difficulty on the server, bounds the request body, computes XP on the server, and renders submitted text with `textContent`.

## Run locally

1. Open this folder in PowerShell.
2. Run `node server.cjs`.
3. Open `http://127.0.0.1:3000` in Microsoft Edge.
4. Submit a quest, then run the attack test.

The quests are stored in memory and reset whenever the Node.js process restarts.

What should match?

  • Confirm that the security section describes the vulnerable version plus the secured version.
  • Confirm that the run instructions use server.cjs plus the local QuestForge address.
  • Confirm that the final paragraph explains the in-memory reset behavior.

The source files are ready for version control. The first commit records the secure board plus its documentation in one checkpoint.

  • Start a second PowerShell session in the existing terminal panel.
  • Confirm that its prompt path ends with questforge.
  • Initialize the main branch plus its first commit by running these commands:
git init -b main
git add .
git commit -m "Build secure QuestForge board"

What did these commands capture?

  • The first command initializes Git inside the existing questforge folder with main as its branch.
  • The second command stages every project file for the checkpoint.
  • The third command stores the staged files under the commit name Build secure QuestForge board.
  • Read the final lines printed in PowerShell.

You'll see Git confirm a new commit on main with the message Build secure QuestForge board.

Commit did not complete?

If Git asks for an identity, return to the name setup from Step 1. Repeat the email setup there.

If no files were captured, confirm that PowerShell is inside questforge before retrying the three commands.

Help me complete the first QuestForge commit.

Create the public GitHub repository

A public repository exposes every file you push. QuestForge contains no API keys or passwords, so its source is ready to share.

  • Return to GitHub in Microsoft Edge.
  • Select New repository.

You'll see the form for defining the repository's name plus its visibility.

  • Enter questforge-secure-board as the repository name.
  • Choose Public as the visibility.

The form now identifies the exact public repository that receives your local main branch.

  • Keep the repository empty by leaving every initialization option unselected.
  • Click Create repository.

You'll see the new questforge-secure-board repository with setup instructions for an existing local project.

  • Copy the HTTPS remote URL into REMOTE-URL.

Why keep the repository empty?

Your local commit already contains the finished README plus every source file. An empty GitHub repository gives that existing history a clean destination.

Push the main branch and verify it

A Git remote connects your local history to another repository. The label origin points this project at the HTTPS URL you just copied.

  • Switch back to the second PowerShell session in Visual Studio Code.
  • Connect the local repository to GitHub by running this command:
git remote add origin [[REMOTE_URL="REMOTE-URL"]]

What does origin mean?

The name origin is the local label for your public GitHub repository. Future publication commands can use that short label instead of repeating the full URL.

  • Check the saved remote URL by running:
git remote -v

What should the remote show?

PowerShell prints the URL attached to origin. Seeing the address for questforge-secure-board proves that the local project points at the intended repository.

Remote points somewhere else?

Compare the printed URL with the HTTPS address on your questforge-secure-board page. Stop before pushing if the repository names differ.

Help me correct the QuestForge remote.

This sign-in keeps your credentials out of the command. Git Credential Manager opens a browser sign-in during the first authenticated HTTPS push.

Before you push, picture which project files you expect GitHub to show.

  • Push the local main branch to origin by running this command:
  • Complete the GitHub sign-in if Git Credential Manager opens your browser.
git push -u origin main

What did the push publish?

The push copies your local main branch plus the Build secure QuestForge board commit to origin. GitHub can now display the project files from that published history.

PowerShell shows transfer progress followed by confirmation that main reached the remote repository.

Push did not reach GitHub?

Follow any authentication prompt still waiting in PowerShell. Complete the browser sign-in before retrying the push.

If GitHub rejects the push, confirm that the remote repository was created without pre-populated files. Check the URL shown by git remote -v before another attempt.

Help me troubleshoot the GitHub push.

  • Return to the public questforge-secure-board repository in Microsoft Edge.
  • Refresh the repository page.

The branch selector shows main.

You'll see README.md at the repository root. You'll also see server.cjs beside the public folder.

You did it. QuestForge now has a public source history that proves both the full-stack build plus its server-side security defense.

Secret mission

Roll a Boss Quest

Your secure quest board already accepts challenges through its validated API. Extend it with a Boss Quest generator that randomly pre-fills one of four harder missions for a 50 XP submission.

Clean Up Your Resources

Clean Up Your Resources

QuestForge uses free local resources plus a GitHub Free repository, so there are no ongoing costs from this project. Decide whether to keep your work available, pause the local server, or delete every project resource.

Resources you used:

  • The local Node.js process running node server.cjs.
  • The local questforge project folder, including its Git history.
  • The public questforge-secure-board repository on GitHub.

Keep everything running

No action is needed while you are actively building or demonstrating QuestForge. Your local server remains available while its PowerShell process is running.

  • Keep the local questforge folder for future development.
  • Leave the public questforge-secure-board repository available as evidence of your work.
  • Continue using the board at http://127.0.0.1:3000 while the server is running.
  • Press Ctrl+C in PowerShell when you finish using the board.

Pause - I'll come back to this later

Stop the local server to free its port while preserving both copies of your project. Your source code, commits, and public repository remain ready for your return.

  • Switch back to the PowerShell terminal running node server.cjs.
  • Press Ctrl+C to stop the server.

The PowerShell prompt returns. The local QuestForge address stops responding.

  • Restart the local server from the questforge folder by running:
node server.cjs

What does this command do?

This starts the QuestForge server from server.cjs. The in-memory quest list resets to its two seeded quests each time the process starts.

  • Return to http://127.0.0.1:3000 in Microsoft Edge.

You'll see the QuestForge board load with the seeded quests. The Roll Boss Quest button remains ready to generate another hard challenge.

Delete - I don't want to use this again

Deleting both copies removes your public source plus your local Git history. Choose this option only when you are sure you no longer need the project.

  • Switch back to the PowerShell terminal running node server.cjs.
  • Press Ctrl+C to stop the local server.

The PowerShell prompt returns. This confirms that the local Node.js process has stopped.

  • Use File Explorer to locate the questforge folder that is currently open in Visual Studio Code.
  • Close Visual Studio Code.
  • Select the questforge folder in File Explorer.
  • Press the Delete key.
  • Empty the Windows Recycle Bin to remove the local project permanently.

The local folder is now removed. The public repository still exists until you complete the GitHub deletion steps.

  • Go to the public questforge-secure-board repository on GitHub.
  • Select the repository's Settings tab.
  • Scroll to Danger Zone.
  • Select Delete this repository.
  • Review GitHub's deletion warnings.
  • Enter the repository name when GitHub requests confirmation.
  • Complete the final repository deletion confirmation.

GitHub removes the public repository after the confirmation succeeds. Your local folder, local server, and published copy are now gone.

Nice Work!

Nice Work!

You did it! QuestForge now runs as a game-styled browser board backed by a secure Node.js JSON API.

You've learned how to:

  • Build a browser-to-server quest flow for focus paths plus difficulty choices. Render accepted quests as cards with live XP totals. Calculate player levels from accepted XP.
  • Expose a client-side validation bypass with the built-in attack test. Defend the server trust boundary with allowlist validation. Reject oversized request bodies. Compute XP on the server. Render submitted titles safely with textContent.
  • Create a Git history that records the secure build. Publish QuestForge to a public GitHub repository on the main branch.
  • Secret Mission: Build a randomized Boss Quest generator that pre-fills a valid hard challenge for secure submission worth 50 XP.

Ready to quiz yourself?