Vibe-Code a Secure Feedback Wall
Build and harden an Express feedback wall with safe AI-assisted coding.
Introduction
30 Second Summary
A quick coding request can become a working app in minutes. The risky part begins when generated code handles words that other people can submit.
In this project, you will use GitHub Copilot Free to build a local feedback wall powered by Express. You will deliberately expose an unsafe rendering flaw before securing the app with server-side validation.
What You'll Build
Picture pasting HTML-looking feedback into your finished wall and watching it appear as harmless text.
By the end of this project, you'll have:
- A live feedback wall that turns each submitted name plus message into a new card without refreshing the page.
- A repeatable security test that exposes the naive renderer before proving the hardened renderer keeps the same payload harmless.
- A server-enforced security baseline that rejects missing or oversized values. You can trigger the rate limit yourself. You can inspect the dependency audit yourself.
- Secret Mission: Add a live character counter that keeps the server's 280-character check as the final security authority.
Are there any prerequisites?
You do not need coding experience or preinstalled tools. The setup step covers every required Windows tool and account.
Before We Start
Before any hands-on work, lock in your goal for the local secure feedback wall. Your commitment makes every AI-generated change earn trust through careful review plus testing.
Set Up Your Safe Vibe-Coding Workspace
A known local toolchain gives you a predictable place to inspect every AI-generated change. An authenticated assistant keeps the workflow inside tools you deliberately set up.
In this step, you will install Visual Studio Code with Node.js v26.13.0 LTS. You will connect GitHub Copilot Free through a verified GitHub account.
In this step, get ready to:
- Create a dedicated GitHub account with a verified email address.
- Install Visual Studio Code with Node.js v26.13.0 LTS on Windows.
- Activate Copilot Free with your verified account.
Create and verify your GitHub account
Your GitHub account identifies you when Copilot signs in. Email verification confirms that the account is ready for the authentication flow.
- Open the GitHub signup page in your browser.
- Create a personal account by following the on-screen prompts.
- Switch to your email inbox.
- Complete email verification from the message GitHub sends.
- Reserve this account for this learning project.
That account is ready. It becomes the identity Copilot uses in Visual Studio Code.
Protect the Account
GitHub strongly recommends configuring two-factor authentication after signup. It gives your new account an extra sign-in check.
Install Visual Studio Code and Node.js
Visual Studio Code gives you one place for editing files and running terminal commands. Node.js runs JavaScript locally.
The official Node.js Windows installer also provides npm. You use npm to install the app's packages in the next step.
- Open the official Visual Studio Code Windows setup page.
- Choose the Windows User setup download.
- Run the downloaded installer.
- Keep the default options throughout the setup wizard.
- Press the Windows key to open the search bar.
- Type Visual Studio Code and press Enter to open it.
You should see the Visual Studio Code editor. The open editor confirms that the User setup completed.
Windows may already have a different Node.js version installed. A version check tells you which setup path to follow.
- Select View from the Visual Studio Code menu bar.
- Select Terminal to open the integrated terminal.
- Check the installed Node.js version by running this command:
node --version
What Does This Command Check?
The --version flag asks Node.js to print its installed version. The result reveals whether your terminal already has the required runtime.
✔️ I see the required version
Great, your terminal already prints v26.13.0. Node.js is ready for this project.
ⓧ I see another Node.js version
This project uses Node.js v26.13.0 LTS. Installing the pinned release gives you the same runtime used throughout the walkthrough.
- Open the official Node.js release page for v26.13.0.
- Download node-v26.13.0-x64.msi for Windows.
- Run the downloaded installer.
- Keep the default options throughout the installer.
- Restart Visual Studio Code after the installation finishes.
- Return to the integrated terminal from the View menu.
- Run the version check shown above.
You should now see v26.13.0 in the terminal.
ⓧ Command not found
Windows cannot find Node.js yet. The official installer adds the runtime plus npm to your local toolchain.
- Open the official Node.js release page for v26.13.0.
- Download node-v26.13.0-x64.msi for Windows.
- Run the downloaded installer.
- Keep the default options throughout the installer.
- Restart Visual Studio Code after the installation finishes.
- Return to the integrated terminal from the View menu.
- Run the version check shown above.
You should now see v26.13.0 in the terminal.
Workspace Version Still Wrong?
- Close every Visual Studio Code window after the Node.js installer finishes.
- Reopen Visual Studio Code so its terminal receives the updated PATH.
- Confirm that the downloaded installer is named node-v26.13.0-x64.msi.
Why does my Visual Studio Code terminal still miss the required Node.js version.
Connect Copilot and verify your workspace
Copilot Chat becomes your in-editor generator and reviewer. Signing in links it to the verified account you created.
The sign-in can feel like a big permission step. An eligible account without a subscription is enrolled in Copilot Free.
- Select the Copilot icon in the Visual Studio Code Status Bar.
- Choose Use AI Features.
- Sign in with your verified GitHub account.
- Complete the browser sign-in flow if it opens.
- Return to Visual Studio Code.
- Select the Copilot icon again to show the chat panel.
You are through the authentication step. Copilot Chat is now available inside Visual Studio Code.
Before the final checks, predict whether the terminal and Copilot Chat will both respond successfully.
- Confirm the Node.js runtime from the Visual Studio Code terminal by running:
node --version
What Does This Result Prove?
This command runs inside Visual Studio Code's terminal. A successful result proves the editor can access the Node.js installation through Windows PATH.
The terminal prints v26.13.0.
- Test Copilot Chat by sending this exact prompt:
Explain server-side validation in one sentence.
Why Use This Prompt?
The response proves Copilot Chat can receive a focused request. It also introduces server-side validation, which checks untrusted input after it reaches your server.
Copilot returns a one-sentence explanation of server-side validation. That response confirms your coding assistant is ready.
Copilot Chat Not Available?
- Select the Copilot icon in the Status Bar again.
- Confirm that the browser sign-in flow completed with your verified GitHub account.
- Restart Visual Studio Code after authentication completes.
Why is Copilot Chat unavailable after I signed in.
Your local toolchain and coding assistant are ready. Next up, you will build the first visible version of your feedback wall.
Build the Visible Feedback Wall
Your local toolchain is ready. You now have a blank workspace where every generated file can be reviewed before it becomes part of your app.
A small visible version gives you something concrete to improve. In this step, you will use GitHub Copilot to propose a starter before building a reviewed Express app that runs in your browser.
In this step, get ready to:
- Create the project folders before reviewing a Copilot starter.
- Install Express before starting a local web server.
- Build a styled feedback form with one starter card.
Create the project skeleton
A safe AI workflow begins with a small scope. Clear constraints help Copilot produce a draft that you can understand before accepting any code.
- Return to the VS Code terminal from the previous step.
- Move to your Desktop by running this command:
cd ~/Desktop
What does this command do?
PowerShell uses cd to change its current folder. Starting from your Desktop makes the new project easy to find.
- Build the project folder structure by running these commands:
mkdir secure-feedback-wall
cd secure-feedback-wall
mkdir public
code .
What do these commands create?
- The first command creates the secure-feedback-wall folder on your Desktop.
- The second command moves the terminal into that folder.
- The third command creates the public folder for browser files.
- The final command opens the current folder in Visual Studio Code.
- Select Yes, I trust the authors if Visual Studio Code shows the Workspace Trust dialog.
- Check the Explorer sidebar.
You should see secure-feedback-wall as the open folder. Its public subfolder should appear underneath.
Folder did not open?
Check that the terminal prompt ends with secure-feedback-wall. Run the final command again after the prompt shows that folder.
If the terminal reports that the command is unavailable, restart Visual Studio Code so it receives the updated PATH.
Help me diagnose why my secure-feedback-wall folder did not open in Visual Studio Code.
The assistant now has a narrow job. Its proposal is a draft for review before you create the source files.
- Return to Copilot Chat from the previous step.
- Request a constrained starter by pasting this prompt:
Generate a four-file starter for a local feedback wall using package.json, a partial server.js, public/index.html, and public/styles.css. Use plain HTML, CSS, and Express. Do not add a database or authentication. Explain each file before suggesting code.
Why constrain the draft?
The prompt names the required files before limiting the stack. It also blocks extra systems that this local starter does not need.
The explanation request gives you a review point before any suggested code reaches the project.
- Read Copilot's explanation for each proposed file.
- Check that the proposal uses plain HTML.
- Check that the proposal uses plain CSS.
- Check that Express is the only server dependency.
- Reject any database suggestion.
- Reject any authentication suggestion.
- Keep the response open for comparison.
Configure Express and start the server
Express connects the browser files to a local Node.js process. The first server stays deliberately small so you can trace every line.
- Select the secure-feedback-wall folder in the Explorer sidebar.
- Select the New File button in the Explorer title bar.
- Enter package.json as the file name.
- Paste this reviewed package definition into package.json:
{
"name": "secure-feedback-wall",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "5.3.0"
}
}
What does this file control?
- The type setting lets server.js use ES module imports.
- The start script gives the project one repeatable launch command.
- The dependency list pins Express to version 5.3.0.
- The private setting prevents accidental publication as an npm package.
- Save package.json with Ctrl+S.
- Check the Explorer sidebar for package.json.
The file should now appear beside the public folder.
Package file shows an error?
Check that every property except the final one in each object ends with a comma. Confirm that every key uses double quotes.
Help me find the JSON syntax problem in my package file.
- Install the pinned Express dependency by running:
npm install express@5.3.0
What does this install create?
npm downloads Express into node_modules. It also records the resolved dependency tree in package-lock.json.
The source file remains small while npm tracks the full installation separately.
- Return to package.json after the installation finishes.
- Set the Express dependency value to 5.3.0 if the installer added a version prefix.
- Save package.json.
- Check the Explorer sidebar.
You should now see node_modules plus package-lock.json. That is the dependency installed and recorded locally.
Express did not install?
Check that the terminal prompt ends with secure-feedback-wall. Confirm that package.json has been saved.
Help me diagnose my Express installation failure.
- Select the secure-feedback-wall folder in Explorer.
- Select the New File button.
- Enter server.js as the file name.
- Paste this local server into server.js:
import express from "express";
const app = express();
const PORT = 3000;
app.use(express.static("public"));
app.listen(PORT, () => {
console.log(`Secure Feedback Wall: http://localhost:${PORT}`);
});
What does this server do?
- The Express import provides the local web server.
- The PORT constant keeps the browser address in one named value.
- The static middleware serves files from public.
- The listener starts the server before printing its local address.
- Save server.js with Ctrl+S.
Before you start the server, what local address do you expect it to print?
- Start the local app by running:
npm start
What does this command run?
npm reads the start script from package.json. That script launches server.js with Node.js.
The terminal stays occupied while the server listens for browser requests.
You should see Secure Feedback Wall: http://localhost:3000 in the terminal. Keep this process running while you build the page.
Server did not start?
Check that package.json contains the start script. Confirm that server.js is saved beside it.
Help me diagnose why npm start does not launch my local Express server.
Build and style the interface
The browser needs a complete HTML document before it can display the wall. Small visual checks after each addition keep the feedback loop short.
- Select the public folder in Explorer.
- Select the New File button.
- Enter index.html as the file name.
- Paste the page shell into public/index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta
name="description"
content="A local feedback wall for learning secure AI-assisted coding."
/>
<title>Secure Feedback Wall</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<main class="page-shell">
<section class="hero">
<p class="eyebrow">Secure vibe-coding lab</p>
<h1>Secure Feedback Wall</h1>
<p>Share a short message and watch it appear safely.</p>
</section>
</main>
</body>
</html>
What does this page shell do?
The head gives the page its title before linking styles.css. The main element provides one container for the visible interface.
The hero introduces the secure coding lab before the form appears.
- Save public/index.html.
- Open http://localhost:3000 in your browser.
You should see the Secure Feedback Wall heading with its short description. The page still uses plain browser styling.
Heading not visible?
Check that index.html is inside public. Confirm that the terminal still shows the running server.
Help me find why my Express server is not serving public/index.html.
The next complete section collects a name plus a short message. Its limits make the intended input size visible before server validation arrives later.
- Find the closing </section> tag directly above </main> in public/index.html.
- Insert this form section below that closing tag:
<section class="panel" aria-labelledby="form-title">
<h2 id="form-title">Add feedback</h2>
<form id="feedback-form">
<label for="name">Name</label>
<input id="name" name="name" maxlength="40" required />
<label for="message">Message</label>
<textarea id="message" name="message" rows="4" maxlength="280" required></textarea>
<button type="submit">Post feedback</button>
<p id="status-message" class="status" aria-live="polite"></p>
</form>
</section>
What does the form contain?
- Each label points to its matching input through the for attribute.
- The name field exposes a 40-character browser limit.
- The message field exposes a 280-character browser limit.
- The status element provides a place for later feedback from the app.
- Save public/index.html.
- Refresh the browser.
You should now see an Add feedback section with a name field. You should also see a message box plus a Post feedback button.
Form missing after refresh?
Check that the new section sits inside <main> before its closing tag. Confirm that every opening tag has a matching closing tag.
Help me find the HTML structure problem hiding my feedback form.
A starter card gives the wall visible content before the API exists. It also creates a concrete styling target for the final part of this step.
- Find the closing form section directly above </main>.
- Insert this feedback section below it:
<section class="panel" aria-labelledby="wall-title">
<h2 id="wall-title">Latest feedback</h2>
<div id="feedback-list" class="feedback-list">
<article class="feedback-card">
<h3>Ari</h3>
<p>The quick feedback loop feels great.</p>
</article>
</div>
</section>
What does the starter card prove?
The feedback list has a stable container for later dynamic cards. The article groups one name with one message.
This static card lets you judge the page before adding browser logic.
- Save public/index.html.
- Refresh the browser.
You should now see Latest feedback below the form. Ari's starter message should appear underneath.
Starter card not showing?
Check that the Latest feedback section is inside <main>. Make sure it sits below the form section.
Help me locate why my starter feedback card is missing.
CSS turns the plain structure into a page with clear spacing plus visible grouping. Each styling pass now has an immediate browser result.
- Select the public folder in Explorer.
- Select the New File button.
- Enter styles.css as the file name.
- Paste the base styles into public/styles.css:
:root {
color-scheme: light;
font-family: Inter, system-ui, sans-serif;
color: #172033;
background: #edf3f8;
}
* {
box-sizing: border-box;
}
body {
min-height: 100vh;
margin: 0;
background: linear-gradient(145deg, #edf3f8, #dbe8f3);
}
button,
input,
textarea {
font: inherit;
}
What do the base styles change?
The root establishes the page font plus its text colours. The universal box rule makes element sizing predictable.
The body gradient fills the browser window. Form controls inherit the same font as the page.
- Save public/styles.css.
- Refresh the browser.
You should see a pale blue gradient across the page. The form controls should use the same font as the headings.
Background still plain?
Check that styles.css is inside public. Confirm that the link in index.html points to /styles.css.
Help me diagnose why public/styles.css is not loading.
- Add the page layout styles at the end of public/styles.css:
.page-shell {
width: min(760px, calc(100% - 32px));
margin: 0 auto;
padding: 48px 0;
}
.hero {
margin-bottom: 24px;
}
.eyebrow {
margin: 0;
color: #0c6b58;
font-size: 0.8rem;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
}
h1,
h2,
p {
overflow-wrap: anywhere;
}
h1 {
margin: 8px 0;
font-size: clamp(2rem, 7vw, 3.7rem);
line-height: 1;
}
How does the layout respond?
The page shell stays centred while preserving space at narrow browser widths. The heading size adapts to the available viewport.
The eyebrow styling separates the lab label from the main title.
- Save public/styles.css.
- Refresh the browser.
You should see the page centred in a narrow column. Secure Feedback Wall should now appear as a large heading.
Layout still spans the window?
Check that the class is spelled page-shell in both files. Confirm that the new styles were added after the closing brace of the previous block.
Help me compare my page-shell class between HTML and CSS.
- Add the panel plus form layout styles at the end of public/styles.css:
.panel {
margin-top: 20px;
padding: 24px;
border: 1px solid #c5d4e1;
border-radius: 18px;
background: #ffffff;
box-shadow: 0 14px 40px rgb(34 62 88 / 12%);
}
form {
display: grid;
gap: 10px;
}
label {
margin-top: 8px;
font-weight: 750;
}
input,
textarea {
width: 100%;
padding: 12px;
border: 1px solid #91a8ba;
border-radius: 10px;
background: #fbfdff;
}
What creates the form panels?
The panel selector adds a white surface with spacing plus a soft shadow. The form grid creates consistent gaps between labels and controls.
The input styles give both fields the same width plus border treatment.
- Save public/styles.css.
- Refresh the browser.
You should see two rounded white panels. The name field plus message box should fill the form width.
Panels still look like plain text?
Check that both sections use the panel class. Confirm that the selector begins with a full stop.
Help me find why my panel CSS does not apply.
- Add the interactive control styles at the end of public/styles.css:
input:focus-visible,
textarea:focus-visible,
button:focus-visible {
outline: 3px solid #6ccdb9;
outline-offset: 2px;
}
button {
width: fit-content;
margin-top: 8px;
padding: 11px 18px;
border: 0;
border-radius: 10px;
color: #ffffff;
background: #0c6b58;
cursor: pointer;
font-weight: 800;
}
button:hover {
background: #084d40;
}
.status {
min-height: 1.5rem;
margin: 4px 0 0;
color: #8a2b20;
font-weight: 700;
}
Why style the control states?
The focus outline shows which control receives keyboard input. The hover colour gives the button a visible pointer response.
The status area reserves space for browser messages that arrive in the next step.
- Save public/styles.css.
- Refresh the browser.
- Move focus through the form with the Tab key.
You should see a green outline move between the fields plus the Post feedback button. The button should use a dark green background.
Focus outline missing?
Use the keyboard to focus the controls because the selector responds to visible keyboard focus. Check that each selector includes the :focus-visible suffix.
Help me diagnose why my focus-visible styles do not appear.
- Add the feedback card styles at the end of public/styles.css:
.feedback-list {
display: grid;
gap: 12px;
}
.feedback-card {
padding: 16px;
border-left: 5px solid #0c6b58;
border-radius: 10px;
background: #f3f8fb;
}
.feedback-card h3,
.feedback-card p {
margin: 0;
}
.feedback-card p {
margin-top: 6px;
}
What finishes the starter card?
The feedback list leaves space between future cards. Each card receives a tinted surface plus a green edge.
The final rules control the spacing between Ari's name and message.
- Save public/styles.css.
- Refresh the browser.
You should see Ari's feedback inside a pale card with a green left border. The name should sit directly above the message.
Starter card has no styling?
Check that the article uses the feedback-card class. Confirm that its parent uses the feedback-list class.
Help me compare my feedback card classes with the CSS selectors.
Use the reference below to catch any drift from the reviewed starter before the final check.
✔️ Awesome, I've got everything!
Your four source files now match the reviewed starter.
ⓧ I'd like to double check the full code
These references show the exact file contents for this checkpoint.
{
"name": "secure-feedback-wall",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "5.3.0"
}
}
import express from "express";
const app = express();
const PORT = 3000;
app.use(express.static("public"));
app.listen(PORT, () => {
console.log(`Secure Feedback Wall: http://localhost:${PORT}`);
});
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta
name="description"
content="A local feedback wall for learning secure AI-assisted coding."
/>
<title>Secure Feedback Wall</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<main class="page-shell">
<section class="hero">
<p class="eyebrow">Secure vibe-coding lab</p>
<h1>Secure Feedback Wall</h1>
<p>Share a short message and watch it appear safely.</p>
</section>
<section class="panel" aria-labelledby="form-title">
<h2 id="form-title">Add feedback</h2>
<form id="feedback-form">
<label for="name">Name</label>
<input id="name" name="name" maxlength="40" required />
<label for="message">Message</label>
<textarea id="message" name="message" rows="4" maxlength="280" required></textarea>
<button type="submit">Post feedback</button>
<p id="status-message" class="status" aria-live="polite"></p>
</form>
</section>
<section class="panel" aria-labelledby="wall-title">
<h2 id="wall-title">Latest feedback</h2>
<div id="feedback-list" class="feedback-list">
<article class="feedback-card">
<h3>Ari</h3>
<p>The quick feedback loop feels great.</p>
</article>
</div>
</section>
</main>
</body>
</html>
:root {
color-scheme: light;
font-family: Inter, system-ui, sans-serif;
color: #172033;
background: #edf3f8;
}
* {
box-sizing: border-box;
}
body {
min-height: 100vh;
margin: 0;
background: linear-gradient(145deg, #edf3f8, #dbe8f3);
}
button,
input,
textarea {
font: inherit;
}
.page-shell {
width: min(760px, calc(100% - 32px));
margin: 0 auto;
padding: 48px 0;
}
.hero {
margin-bottom: 24px;
}
.eyebrow {
margin: 0;
color: #0c6b58;
font-size: 0.8rem;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
}
h1,
h2,
p {
overflow-wrap: anywhere;
}
h1 {
margin: 8px 0;
font-size: clamp(2rem, 7vw, 3.7rem);
line-height: 1;
}
.panel {
margin-top: 20px;
padding: 24px;
border: 1px solid #c5d4e1;
border-radius: 18px;
background: #ffffff;
box-shadow: 0 14px 40px rgb(34 62 88 / 12%);
}
form {
display: grid;
gap: 10px;
}
label {
margin-top: 8px;
font-weight: 750;
}
input,
textarea {
width: 100%;
padding: 12px;
border: 1px solid #91a8ba;
border-radius: 10px;
background: #fbfdff;
}
input:focus-visible,
textarea:focus-visible,
button:focus-visible {
outline: 3px solid #6ccdb9;
outline-offset: 2px;
}
button {
width: fit-content;
margin-top: 8px;
padding: 11px 18px;
border: 0;
border-radius: 10px;
color: #ffffff;
background: #0c6b58;
cursor: pointer;
font-weight: 800;
}
button:hover {
background: #084d40;
}
.status {
min-height: 1.5rem;
margin: 4px 0 0;
color: #8a2b20;
font-weight: 700;
}
.feedback-list {
display: grid;
gap: 12px;
}
.feedback-card {
padding: 16px;
border-left: 5px solid #0c6b58;
border-radius: 10px;
background: #f3f8fb;
}
.feedback-card h3,
.feedback-card p {
margin: 0;
}
.feedback-card p {
margin-top: 6px;
}
Before the final refresh, which two white panels do you expect to see beneath the page heading?
- Check that the terminal still shows the running server.
- Refresh http://localhost:3000 in your browser.
You should see the styled Secure Feedback Wall form plus one starter feedback card. That is your first complete browser result.
Your visible feedback wall is running locally. Next up, you will connect the form to an API before testing how a naive renderer handles untrusted input.
Watch the Naive Version Break
Your styled feedback wall already gives visitors a form. It also displays a starter card.
Now you will add an Express API plus browser behavior.
The first interactive version trusts visitor input. You will test that assumption before relying on the generated code.
In this step, get ready to:
- Add in-memory feedback routes to the Express server.
- Connect the browser form to the feedback API.
- Test how the renderer handles a harmless markup string.
Add a temporary feedback API
A trust boundary appears when feedback moves from the browser into the server. This temporary API stores that feedback in memory without checking it.
- Switch back to server.js in the editor tab from earlier.
- Replace its contents with the temporary API code below:
import express from "express";
const app = express();
const PORT = 3000;
const feedbackItems = [
{
name: "Ari",
message: "The quick feedback loop feels great.",
},
];
app.use(express.json());
app.use(express.static("public"));
app.get("/api/feedback", (_req, res) => {
res.json(feedbackItems);
});
app.post("/api/feedback", (req, res) => {
feedbackItems.unshift(req.body);
res.status(201).json(req.body);
});
app.listen(PORT, () => {
console.log(`Secure Feedback Wall: http://localhost:${PORT}`);
});
What does this server code do?
- The feedbackItems array holds feedback while the server is running.
- The JSON parser places the submitted body in req.body.
- The GET /api/feedback route returns every stored item.
- The POST /api/feedback route places the submitted body at the start of the array.
- Save server.js.
- Return to the terminal panel from earlier.
- Stop the running server process.
- Restart the server by running this command:
npm start
What does this command do?
The npm start command runs the existing start script in package.json. That script starts server.js with Node.js.
You will see Secure Feedback Wall: http://localhost:3000 in the terminal.
- Open a new browser tab.
- Visit http://localhost:3000/api/feedback.
You will see Ari's starter feedback as JSON. That is your first live API response.
Cannot see the starter feedback?
Check that the terminal still shows the running server. Confirm that server.js contains the feedback routes above.
Make sure the browser address ends with /api/feedback.
Help me debug why my Express feedback API is not returning Ari's starter item.
Connect the browser to the API
The Fetch API lets browser code request feedback from the server. The renderer turns each returned item into a card.
- Use the file control in the VS Code sidebar to create public/app.js from the code below:
const form = document.querySelector("#feedback-form");
const feedbackList = document.querySelector("#feedback-list");
const statusMessage = document.querySelector("#status-message");
function renderFeedback(items) {
feedbackList.innerHTML = items
.map(
(item) => `
<article class="feedback-card">
<h3>${item.name}</h3>
<p>${item.message}</p>
</article>
`,
)
.join("");
}
async function loadFeedback() {
const response = await fetch("/api/feedback");
const items = await response.json();
renderFeedback(items);
}
loadFeedback();
How does the first renderer work?
- The three constants connect the script to the existing form, feedback list, and status message.
- The renderFeedback() function maps each feedback item into an HTML template.
- The combined template is assigned to feedbackList.innerHTML.
- The loadFeedback() function requests the stored items before passing them to the renderer.
- Save public/app.js.
- Confirm that app.js appears inside the public folder in the file sidebar.
Cannot find app.js?
Check that the file sits inside public. A file beside server.js will not load from the expected browser path.
Help me check where public/app.js should be saved in my Express project.
The browser needs a script reference before it can run app.js. The feedback list also needs an empty container that the renderer can fill.
- Switch back to public/index.html.
- Find the stylesheet link in the document head.
- Add the script reference directly below it so both lines look like this:
<link rel="stylesheet" href="/styles.css" />
<script src="/app.js" defer></script>
Why use defer?
The defer attribute waits until the page structure has been parsed before running the script. The form elements exist when app.js queries for them.
- Find the feedback-list container that currently holds Ari's static card.
- Replace that container with this empty version:
<div id="feedback-list" class="feedback-list"></div>
Why empty the list?
The browser script now owns the card list. It fills this container with the items returned by the API.
- Save public/index.html.
- Return to http://localhost:3000 in the browser.
- Refresh the page.
You will see one feedback card for Ari. The card now comes from the server's in-memory array.
Loading feedback proves the server-to-browser path works. Submitting the form requires one more browser event handler.
- Switch back to public/app.js.
- Find the final loadFeedback(); line.
- Insert this submit handler directly above that line:
form.addEventListener("submit", async (event) => {
event.preventDefault();
const formData = new FormData(form);
const payload = {
name: String(formData.get("name") ?? ""),
message: String(formData.get("message") ?? ""),
};
await fetch("/api/feedback", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
form.reset();
statusMessage.textContent = "Feedback added.";
await loadFeedback();
});
What does the submit handler do?
- The submit event prevents the browser's default page reload.
- The FormData object reads the current name and message.
- The request sends those values to POST /api/feedback as JSON.
- The final call to loadFeedback() redraws the wall from the updated server array.
- Save public/app.js.
- Refresh the feedback wall in your browser.
- Enter Sam in the name field.
- Enter The wall updates without a refresh. in the message field.
- Click Post feedback.
You will see Sam's card at the top of the wall. The status message reads Feedback added..
Good progress. Your form now sends feedback to the server before refreshing the visible wall.
New card does not appear?
Confirm that the terminal still runs the server. Check that the script reference points to /app.js.
Make sure the submit handler sits above the final loadFeedback(); call.
Help me debug why my feedback form submits without showing the new card.
✔️ Awesome, I've got everything!
Your API, browser script, and HTML connection are ready for the rendering test.
ⓧ I'd like to double check the full code
Check package.json
{
"name": "secure-feedback-wall",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "5.3.0"
}
}
What should match?
The project still has one dependency. The start script still runs server.js.
Check server.js
import express from "express";
const app = express();
const PORT = 3000;
const feedbackItems = [
{
name: "Ari",
message: "The quick feedback loop feels great.",
},
];
app.use(express.json());
app.use(express.static("public"));
app.get("/api/feedback", (_req, res) => {
res.json(feedbackItems);
});
app.post("/api/feedback", (req, res) => {
feedbackItems.unshift(req.body);
res.status(201).json(req.body);
});
app.listen(PORT, () => {
console.log(`Secure Feedback Wall: http://localhost:${PORT}`);
});
What should match?
The server contains the in-memory array plus both feedback routes. The JSON parser appears before the routes.
Check public/index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta
name="description"
content="A local feedback wall for learning secure AI-assisted coding."
/>
<title>Secure Feedback Wall</title>
<link rel="stylesheet" href="/styles.css" />
<script src="/app.js" defer></script>
</head>
<body>
<main class="page-shell">
<section class="hero">
<p class="eyebrow">Secure vibe-coding lab</p>
<h1>Secure Feedback Wall</h1>
<p>Share a short message and watch it appear safely.</p>
</section>
<section class="panel" aria-labelledby="form-title">
<h2 id="form-title">Add feedback</h2>
<form id="feedback-form">
<label for="name">Name</label>
<input id="name" name="name" maxlength="40" required />
<label for="message">Message</label>
<textarea id="message" name="message" rows="4" maxlength="280" required></textarea>
<button type="submit">Post feedback</button>
<p id="status-message" class="status" aria-live="polite"></p>
</form>
</section>
<section class="panel" aria-labelledby="wall-title">
<h2 id="wall-title">Latest feedback</h2>
<div id="feedback-list" class="feedback-list"></div>
</section>
</main>
</body>
</html>
What should match?
The script reference appears in the document head. The feedback list starts empty so the browser script can populate it.
Check public/styles.css
:root {
color-scheme: light;
font-family: Inter, system-ui, sans-serif;
color: #172033;
background: #edf3f8;
}
* {
box-sizing: border-box;
}
body {
min-height: 100vh;
margin: 0;
background: linear-gradient(145deg, #edf3f8, #dbe8f3);
}
button,
input,
textarea {
font: inherit;
}
.page-shell {
width: min(760px, calc(100% - 32px));
margin: 0 auto;
padding: 48px 0;
}
.hero {
margin-bottom: 24px;
}
.eyebrow {
margin: 0;
color: #0c6b58;
font-size: 0.8rem;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
}
h1,
h2,
p {
overflow-wrap: anywhere;
}
h1 {
margin: 8px 0;
font-size: clamp(2rem, 7vw, 3.7rem);
line-height: 1;
}
.panel {
margin-top: 20px;
padding: 24px;
border: 1px solid #c5d4e1;
border-radius: 18px;
background: #ffffff;
box-shadow: 0 14px 40px rgb(34 62 88 / 12%);
}
form {
display: grid;
gap: 10px;
}
label {
margin-top: 8px;
font-weight: 750;
}
input,
textarea {
width: 100%;
padding: 12px;
border: 1px solid #91a8ba;
border-radius: 10px;
background: #fbfdff;
}
input:focus-visible,
textarea:focus-visible,
button:focus-visible {
outline: 3px solid #6ccdb9;
outline-offset: 2px;
}
button {
width: fit-content;
margin-top: 8px;
padding: 11px 18px;
border: 0;
border-radius: 10px;
color: #ffffff;
background: #0c6b58;
cursor: pointer;
font-weight: 800;
}
button:hover {
background: #084d40;
}
.status {
min-height: 1.5rem;
margin: 4px 0 0;
color: #8a2b20;
font-weight: 700;
}
.feedback-list {
display: grid;
gap: 12px;
}
.feedback-card {
padding: 16px;
border-left: 5px solid #0c6b58;
border-radius: 10px;
background: #f3f8fb;
}
.feedback-card h3,
.feedback-card p {
margin: 0;
}
.feedback-card p {
margin-top: 6px;
}
What should match?
The stylesheet remains unchanged. Its existing card styles apply to the cards created by app.js.
Check public/app.js
const form = document.querySelector("#feedback-form");
const feedbackList = document.querySelector("#feedback-list");
const statusMessage = document.querySelector("#status-message");
function renderFeedback(items) {
feedbackList.innerHTML = items
.map(
(item) => `
<article class="feedback-card">
<h3>${item.name}</h3>
<p>${item.message}</p>
</article>
`,
)
.join("");
}
async function loadFeedback() {
const response = await fetch("/api/feedback");
const items = await response.json();
renderFeedback(items);
}
form.addEventListener("submit", async (event) => {
event.preventDefault();
const formData = new FormData(form);
const payload = {
name: String(formData.get("name") ?? ""),
message: String(formData.get("message") ?? ""),
};
await fetch("/api/feedback", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
form.reset();
statusMessage.textContent = "Feedback added.";
await loadFeedback();
});
loadFeedback();
What should match?
The script loads feedback when the page starts. It also posts form values before loading the updated list.
Test the renderer with markup
A normal submission proves the happy path. The next test checks how the renderer handles a message that resembles browser markup.
This harmless test stays inside your local page. It uses no private data.
- Enter Tester in the name field.
- Copy the harmless local test message from this block:
<img src=x onerror="document.body.style.background='tomato'">
What is this test checking?
The text resembles an image element with browser event behavior. It checks whether the renderer preserves the submission as text or interprets it as markup.
- Paste the test message into the message field.
Before you post this test, which outcome do you expect: a message inside the card or a change to the page? Hold your answer in mind.
- Click Post feedback.
You will see the page background turn tomato. The browser interpreted the submitted message as markup.
This is the intended cross-site scripting style failure. The submitted message is untrusted input.
The failure is the lesson
The server accepted the message without validation. The browser inserted it through innerHTML.
That property is the dangerous HTML sink in this version. It parses the submitted string as part of the page.
- Return to GitHub Copilot Chat in VS Code.
- Ask why the submitted image markup changed the page background.
- Compare Copilot's explanation with the code path you just tested.
- Hold off on applying any suggested fix.
You have now reproduced the shortfall on the main path. You can point to the visitor-controlled message plus feedbackList.innerHTML as the path that caused it.
You found the exact point where the naive version breaks. Next, you will harden that path before testing the same input again.
Harden and Audit the App
You traced the visible failure back to untrusted feedback reaching innerHTML. That experiment showed how a browser can turn submitted text into active markup.
Your Express app now needs several layers of protection. You will combine safe output handling with server-side controls so one missed check cannot reopen the cross-site scripting gap.
In this step, get ready to:
- Render submitted feedback as literal text.
- Constrain requests at the server boundary.
- Audit the dependencies and review the finished changes.
Render feedback as text
The Document Object Model lets JavaScript create elements without parsing feedback as HTML. Setting textContent keeps names and messages in the text lane.
- In public/app.js, find the existing renderFeedback() function.
- Replace that function by copying these two functions in its place:
function createFeedbackCard(item) {
const card = document.createElement("article");
card.className = "feedback-card";
const name = document.createElement("h3");
name.textContent = item.name;
const message = document.createElement("p");
message.textContent = item.message;
card.append(name, message);
return card;
}
function renderFeedback(items) {
feedbackList.textContent = "";
for (const item of items) {
feedbackList.append(createFeedbackCard(item));
}
}
What does this code do?
- The createFeedbackCard() function builds one article with browser-created elements.
- Each textContent assignment treats submitted data as visible characters.
- The renderFeedback() function clears the wall before appending the safe cards.
- Save public/app.js.
- Refresh http://localhost:3000 in your browser.
Before you submit the test again, do you expect the browser to interpret it as an image or display every character?
- Enter Security Test in the Name field.
- Enter <img src=x onerror="document.body.style.background='tomato'"> in the Message field.
- Click Post feedback.
You will see the full test string inside a feedback card. The page background stays unchanged because the browser received text instead of executable markup.
Does the background still change?
Search public/app.js for any remaining use of innerHTML. The feedback renderer should use document.createElement() with textContent.
Refresh the page after saving the file. Use this if the unsafe result continues: Help me find why my feedback renderer still executes submitted HTML.
The browser also needs to surface API failures instead of treating every response as success. The completed file checks response.ok before resetting the form.
- Compare public/app.js with the complete safe version in the second tab.
- Correct every difference in public/app.js.
✔️ Awesome, I've got everything!
Your feedback renderer now creates elements safely. Your request handlers also show API errors inside the existing status area.
ⓧ I'd like to double check the full code
const form = document.querySelector("#feedback-form");
const feedbackList = document.querySelector("#feedback-list");
const statusMessage = document.querySelector("#status-message");
function createFeedbackCard(item) {
const card = document.createElement("article");
card.className = "feedback-card";
const name = document.createElement("h3");
name.textContent = item.name;
const message = document.createElement("p");
message.textContent = item.message;
card.append(name, message);
return card;
}
function renderFeedback(items) {
feedbackList.textContent = "";
for (const item of items) {
feedbackList.append(createFeedbackCard(item));
}
}
async function loadFeedback() {
try {
const response = await fetch("/api/feedback");
if (!response.ok) {
throw new Error("Could not load feedback.");
}
const items = await response.json();
renderFeedback(items);
} catch (error) {
statusMessage.textContent = error.message;
}
}
form.addEventListener("submit", async (event) => {
event.preventDefault();
statusMessage.textContent = "Posting...";
const formData = new FormData(form);
const payload = {
name: String(formData.get("name") ?? ""),
message: String(formData.get("message") ?? ""),
};
try {
const response = await fetch("/api/feedback", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.error ?? "Could not post feedback.");
}
form.reset();
statusMessage.textContent = "Feedback added safely.";
await loadFeedback();
} catch (error) {
statusMessage.textContent = error.message;
}
});
loadFeedback();
- Save public/app.js.
- Refresh the feedback wall.
- Submit one normal name with one short message.
You will see the new card at the top of the wall. The status area reads Feedback added safely..
Harden the server boundary
Safe rendering closes the browser sink that caused the visible failure. The server still needs to limit what crosses its trust boundary before storing anything.
- Return to the terminal running the local server.
- Stop the server by pressing Ctrl+C.
- Install the two security dependencies by running:
npm install helmet@8.3.0 express-rate-limit@8.7.4
What does this command add?
Helmet adds HTTP response headers that reduce common browser risks. Express-rate-limit adds request counters that can block rapid repeated submissions.
The command pins both packages to the reviewed versions. It also updates package.json and the generated lock file.
- Confirm the terminal finishes the installation without a dependency error.
Did the installation fail?
Confirm the terminal is inside the secure-feedback-wall folder. That folder contains the project’s package.json file.
Use this if npm reports a network or package installation problem: Help me diagnose why the security dependencies did not install in my secure-feedback-wall project.
How do the server controls work together?
A 2kb parser limit constrains JSON before your route handles it. Type checks and trimmed length checks reject malformed feedback.
Field allowlisting stores only name and message. Structured logs record the event without recording submitted text.
Rate limiting allows five POST submissions during each one-minute window. Generic error responses keep internal failure details out of the browser.
- Replace server.js with the complete hardened version in the second tab.
- Compare package.json with the dependency list in the same tab.
✔️ Awesome, I've got everything!
Your package list contains the three pinned dependencies. Your server applies every control before it stores valid feedback.
ⓧ I'd like to double check the full code
{
"name": "secure-feedback-wall",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "5.3.0",
"express-rate-limit": "8.7.4",
"helmet": "8.3.0"
}
}
import express from "express";
import { rateLimit } from "express-rate-limit";
import helmet from "helmet";
const app = express();
const PORT = 3000;
const feedbackItems = [
{
name: "Ari",
message: "The quick feedback loop feels great.",
},
];
app.use(
helmet({
contentSecurityPolicy: {
directives: {
"upgrade-insecure-requests": null,
},
},
strictTransportSecurity: false,
}),
);
app.use(express.json({ limit: "2kb" }));
app.use(express.static("public"));
const feedbackLimiter = rateLimit({
windowMs: 60 * 1000,
limit: 5,
standardHeaders: "draft-8",
legacyHeaders: false,
message: {
error: "Too many submissions. Please try again in a minute.",
},
});
app.get("/api/feedback", (_req, res) => {
res.json(feedbackItems);
});
app.post("/api/feedback", feedbackLimiter, (req, res) => {
const { name, message } = req.body ?? {};
if (typeof name !== "string" || typeof message !== "string") {
console.warn(
JSON.stringify({
event: "input_validation_failed",
route: "POST /api/feedback",
}),
);
return res.status(400).json({ error: "Name and message are required." });
}
const cleanName = name.trim();
const cleanMessage = message.trim();
if (
cleanName.length < 1 ||
cleanName.length > 40 ||
cleanMessage.length < 1 ||
cleanMessage.length > 280
) {
console.warn(
JSON.stringify({
event: "input_validation_failed",
route: "POST /api/feedback",
}),
);
return res.status(400).json({
error: "Use 1 to 40 characters for the name and 1 to 280 for the message.",
});
}
const feedback = {
name: cleanName,
message: cleanMessage,
};
feedbackItems.unshift(feedback);
console.info(
JSON.stringify({
event: "feedback_created",
route: "POST /api/feedback",
}),
);
return res.status(201).json(feedback);
});
app.use((_req, res) => {
res.status(404).json({ error: "Not found." });
});
app.use((error, _req, res, next) => {
if (res.headersSent) {
return next(error);
}
const status =
Number.isInteger(error.status) && error.status >= 400 && error.status < 500
? error.status
: 500;
console.error(
JSON.stringify({
event: "request_failed",
name: error.name ?? "Error",
status,
}),
);
return res.status(status).json({
error: status === 500 ? "Something went wrong." : "Invalid request.",
});
});
app.listen(PORT, () => {
console.log(`Secure Feedback Wall: http://localhost:${PORT}`);
});
- Save server.js.
- Save package.json if you corrected a difference.
- Start the hardened server by running:
npm start
What should I see?
The terminal prints Secure Feedback Wall: http://localhost:3000. The server stays active so the browser can send requests.
- Refresh the feedback wall in your browser.
- Enter Validation Test in the Name field.
- Press the Spacebar three times in the Message field.
- Click Post feedback.
You will see Use 1 to 40 characters for the name and 1 to 280 for the message. The server trims the spaces before checking the message length.
Audit and prove the controls
An npm dependency audit checks the installed dependency tree against known vulnerability reports. Read the result before changing any package because an automated suggestion can alter the versions you reviewed.
- Return to the terminal running the server.
- Stop the server by pressing Ctrl+C.
- Audit the installed dependency tree by running:
npm audit
How do I read the audit?
The report summarizes known vulnerabilities in the installed dependency tree. A clean report states that it found no vulnerabilities.
If the report lists a finding, read the affected package and severity before changing anything. The goal is an informed decision.
Did the audit fail to run?
Confirm package-lock.json exists inside secure-feedback-wall. The earlier npm installation generates that lock file.
Use this for an audit error: Help me diagnose my npm audit result without changing dependencies yet.
GitHub Copilot now becomes a reviewer for the code it helped produce. Your prompt gives it a fixed checklist so each finding has to connect to a concrete risk.
- Return to Copilot Chat from earlier.
- Ask Copilot to review the hardened changes by sending this exact prompt:
Review these changes for untrusted input, unsafe output, leaked details, missing limits, and unnecessary dependencies. Explain each finding before suggesting code.
Why use a fixed review prompt?
The prompt directs the review toward trust boundaries and resource limits. Asking for explanations before suggestions keeps you in control of each proposed change.
- Compare every Copilot finding with the code you reviewed.
- Reject any suggestion that removes safe rendering or server-side validation.
- Return to the terminal after completing the review.
- Restart the app by running:
npm start
Why restart before testing?
A fresh process loads the saved server code. It also begins with a fresh in-memory rate-limit counter.
Before you submit the original test payload, do you expect the page background to change this time?
- Refresh http://localhost:3000.
- Enter Security Test in the Name field.
- Enter <img src=x onerror="document.body.style.background='tomato'"> in the Message field.
- Click Post feedback.
You will see the payload as literal text. The background stays unchanged because the dangerous HTML sink is gone.
- Enter Validation Test in the Name field.
- Press the Spacebar three times in the Message field.
- Click Post feedback.
You will see the length guidance again. This confirms the invalid-input path still works after the review.
The default local rate-limit counter resets when the Node.js process restarts. Reset it now so your earlier security checks do not affect the six-submission test.
- Return to the terminal running the server.
- Stop the server by pressing Ctrl+C.
- Reset the local counters by starting the server again:
npm start
What resets here?
The local limiter stores counters in the running Node.js process. Restarting that process begins a clean five-request allowance for this test.
- Refresh the feedback wall.
- Submit one valid name with one valid message.
- Repeat the valid submission cycle four more times within one minute.
Each of the first five submissions succeeds. The status area reads Feedback added safely. after each one.
Before the sixth submission, do you expect another card or a rate-limit message?
- Enter Rate Test in the Name field.
- Enter Sixth valid submission in the Message field.
- Click Post feedback.
You will see Too many submissions. Please try again in a minute. The sixth card does not reach the wall.
Was the wrong submission blocked?
Restart the Node.js process before repeating the test. Submit exactly five valid messages before the sixth attempt.
Use this if the limiter behaves differently: Help me check why my five-request limiter blocks the wrong submission.
That completes the security loop. Your wall now treats feedback as untrusted at the browser and server boundaries while giving users clear outcomes.
Secret mission
Add a Trustworthy Character Counter
Give visitors live feedback as they approach the 280-character limit. Build the counter with safe text updates while keeping the server as the final security authority.
Clean Up Your Resources
Clean Up Your Resources
The feedback wall runs entirely on your Windows computer, so keeping it has no ongoing cost. Choose whether to keep the local server active, stop it for later, or remove the project folder.
Resources you used:
- A local Node.js server for the feedback wall.
- The secure-feedback-wall folder containing your source files and installed dependencies.
- Temporary feedback entries held in the local server's memory.
Keep everything running
No action is needed. Choose this option if you want to keep testing the hardened feedback wall.
- Leave the local server running while you continue testing the wall.
- Keep the secure-feedback-wall folder open in Visual Studio Code.
Temporary feedback stays available while the current Node.js process runs. Restarting the server resets the wall to its starter feedback card.
Pause - I'll come back to this later
Pausing stops the local server while preserving every project file for your next session.
- Switch back to the terminal in Visual Studio Code that is running server.js.
- Press Ctrl+C to stop the local server.
- Refresh the existing feedback wall browser tab.
You'll see that the browser can no longer reach the local app. This confirms that the server has stopped.
- Keep the secure-feedback-wall folder on your computer.
Your project remains ready to start again from the same folder.
Delete - I don't want to use this again
Deleting looks final because it removes the whole local project folder. Everything outside that folder stays untouched.
- Switch back to the terminal in Visual Studio Code that is running server.js.
- Press Ctrl+C to stop the local server.
Stopping the server clears the temporary feedback entries held in memory.
- Right-click the secure-feedback-wall folder in the Visual Studio Code Explorer sidebar.
- Choose the option that reveals the folder in Windows File Explorer.
- Close Visual Studio Code.
- Select the secure-feedback-wall folder in File Explorer.
- Press Shift+Delete to remove the selected folder permanently.
- Confirm the removal if Windows asks for confirmation.
- Confirm that secure-feedback-wall no longer appears in File Explorer.
The local project is now removed. Your accounts and installed applications remain available.
Nice Work!
Nice Work!
You did it! Your local feedback wall now shows what secure AI-assisted development looks like in practice.
You've learned how to:
- Built a local Express feedback wall with an API-backed browser interface. Visitors can submit feedback. Each new card appears without a page refresh.
- Used GitHub Copilot to generate code. Reviewed each change before accepting it.
- Reproduced an XSS-style failure caused by innerHTML. Replaced unsafe HTML rendering with safe DOM rendering through textContent. Protected the server with validation, request-size limits, generic errors, structured logs, Helmet security headers, and rate limiting. Audited the dependency tree for known vulnerabilities.
- Secret Mission: Added a live character counter that updates safely with textContent. Kept the server's 280-character validation as the final security boundary.
Ready to quiz yourself?