LEVEL 19 · LESSON 10 OF 10

Capstone Production

Final production challenge

Someone else's gift-card feature works in the demo and fails everything else. Find its problems, fix them step by step, and prove each fix with a test.

  • 120 min to read and try
  • You need: The whole course, especially the ShopFlow capstone
  • You build: A secure, transactional, tested and deployable gift-card and wallet service, fixed from a broken one
Test yourself

BY THE END OF THIS LESSON YOU CAN

  • Read someone else's code for security, correctness, architecture and operations problems, and name at least ten before changing anything
  • Replace string-built SQL and a check-then-act redemption with parameterized queries and one atomic UPDATE that closes the race
  • Move authentication to a session token and authorization to "only your own wallet", removing every endpoint that trusts a caller-supplied user id
  • Wrap a debit and credit in one transaction and roll both back when either fails
  • Write tests that fail on the original code and pass on the fix, including one that redeems the same card twice at once
  • Work through a production checklist (events, queues, caching, rate limits, observability, OpenAPI, deployment) against a feature that started as a prototype

The situation

ShopFlow wants gift cards. A customer types the code from a card and the amount goes into their wallet; they can also send wallet money to a friend. A teammate built it in an afternoon. The demo worked, the feature was merged, and now it is your job to get it ready for production.

This is the last lesson of the course, and it works differently: you get a running application with real problems, and a checklist. You find the problems, fix them, and prove each fix with a test. The key fixes have worked solutions, hidden until you open them. Try first.

The code you inherit

Create a folder, install what the capstone used, and add three files. The database has two users, Ada (id 1) and Linus (id 2), each with 10.00 in their wallet, and two unused gift cards. Each user has a session token; the database stores only its hash.

Terminal on your computer
mkdir giftcards && cd giftcards
npm init -y
npm pkg set type=module
npm install @zudojs/crypto @zudojs/errors @zudojs/http @zudojs/schema @electric-sql/pglite
…
npm install -D typescript tsx @types/node vitest
…
db.tsNode.js only
import { PGlite } from "@electric-sql/pglite";
import { generateSessionToken, hashToken } from "@zudojs/crypto";

/** A fresh database with two users, their wallets and two unused gift cards. */
export async function createDatabase() {
  const db = new PGlite();
  await db.exec(`
    CREATE TABLE users (id INT PRIMARY KEY, name TEXT NOT NULL, token_hash TEXT UNIQUE NOT NULL);
    CREATE TABLE wallets (user_id INT PRIMARY KEY REFERENCES users (id), balance_cents INT NOT NULL CHECK (balance_cents >= 0));
    CREATE TABLE gift_cards (code TEXT PRIMARY KEY, amount_cents INT NOT NULL, redeemed_by INT REFERENCES users (id));
    INSERT INTO gift_cards VALUES ('GIFT-ADA-5000', 5000, NULL), ('GIFT-LINUS-2000', 2000, NULL);
  `);
  const tokens: Record<"ada" | "linus", string> = { ada: await generateSessionToken(), linus: await generateSessionToken() };
  for (const [id, name] of [[1, "ada"], [2, "linus"]] as const) {
    await db.query("INSERT INTO users VALUES ($1, $2, $3)", [id, name, await hashToken(tokens[name])]);
    await db.query("INSERT INTO wallets VALUES ($1, 1000)", [id]);
  }
  return { db, tokens };
}

DELIBERATELY BROKEN CODE

The next file is the teammate's server. It is here so you can find what is wrong with it. Do not copy any of it into a real project.
server.tsNode.js only
// ⚠ DELIBERATELY BROKEN. Do not copy this file into a real project.
import type { PGlite } from "@electric-sql/pglite";
import { createHttpServer, createNodeHttpAdapter, createResponseContext, createRouter, type HttpRequestContext } from "@zudojs/http";

export function createGiftCardServer(db: PGlite) {
  const router = createRouter();
  const body = (bytes: unknown) => JSON.parse(new TextDecoder().decode(bytes as Uint8Array));

  router.get("/giftcards", async (ctx) => {
    const { rows } = await db.query(`SELECT * FROM gift_cards WHERE code = '${ctx.query["code"]}'`);
    return createResponseContext().json(rows);
  });

  router.get("/wallets/:userId", async (ctx) => {
    const { rows } = await db.query(`SELECT * FROM wallets WHERE user_id = ${ctx.params["userId"]}`);
    return createResponseContext().json(rows[0]);
  });

  router.post("/giftcards/redeem", async (ctx) => {
    const { code, userId } = body(ctx.request.body);
    const { rows } = await db.query<{ amount_cents: number; redeemed_by: number | null }>(
      `SELECT * FROM gift_cards WHERE code = '${code}'`,
    );
    if (!rows[0] || rows[0].redeemed_by !== null) return createResponseContext({ status: 400 }).json({ error: "invalid card" });
    await db.query(`UPDATE wallets SET balance_cents = balance_cents + ${rows[0].amount_cents} WHERE user_id = ${userId}`);
    await db.query(`UPDATE gift_cards SET redeemed_by = ${userId} WHERE code = '${code}'`);
    return createResponseContext().json({ ok: true });
  });

  router.post("/wallets/transfer", async (ctx) => {
    const { from, to, amountCents } = body(ctx.request.body);
    await db.query(`UPDATE wallets SET balance_cents = balance_cents - ${amountCents} WHERE user_id = ${from}`);
    await db.query(`UPDATE wallets SET balance_cents = balance_cents + ${amountCents} WHERE user_id = ${to}`);
    return createResponseContext().json({ ok: true });
  });

  return createHttpServer({
    adapter: createNodeHttpAdapter({ host: "127.0.0.1", port: 0 }),
    handler: async (request: HttpRequestContext) => (await router.dispatch(request)).response,
  });
}

It compiles, and for Ada redeeming her own card it works. Now use it the way the internet will. try-it.ts is a normal request followed by five things any curious user could try:

try-it.tsNode.js only
import { createDatabase } from "./db.js";
import { createGiftCardServer } from "./server.js";

const { db } = await createDatabase();
const server = createGiftCardServer(db);
await server.start();
const url = (path: string) => `http://127.0.0.1:${server.address?.port}${path}`;
const post = (path: string, data: unknown) => fetch(url(path), { method: "POST", body: JSON.stringify(data) });
const balances = async () => {
  const { rows } = await db.query<{ user_id: number; balance_cents: number }>("SELECT user_id, balance_cents FROM wallets ORDER BY user_id");
  return rows.map((row) => `user ${row.user_id}: ${row.balance_cents}`).join(", ");
};

console.log("start:", await balances());

console.log("1. Ada redeems her own card:", (await post("/giftcards/redeem", { code: "GIFT-ADA-5000", userId: 1 })).status);

const leaked = await (await fetch(url(`/giftcards?code=${encodeURIComponent("x' OR '1'='1")}`))).json();
console.log("2. SQL injection lists every card:", leaked.map((card: { code: string }) => card.code));

console.log("3. Ada redeems Linus's card into her wallet:", (await post("/giftcards/redeem", { code: "GIFT-LINUS-2000", userId: 1 })).status);

console.log("4. Ada 'sends' Linus -3000 cents:", (await post("/wallets/transfer", { from: 1, to: 2, amountCents: -3000 })).status);

console.log("5. Linus sends 1000 to user 99, who does not exist:", (await post("/wallets/transfer", { from: 2, to: 99, amountCents: 1000 })).status);

console.log("6. Anyone reads Ada's wallet:", await (await fetch(url("/wallets/1"))).json());
console.log("end:", await balances());
await server.stop();
await db.close();
Output of npx tsx try-it.ts
start: user 1: 1000, user 2: 1000
1. Ada redeems her own card: 200
2. SQL injection lists every card: [ 'GIFT-LINUS-2000', 'GIFT-ADA-5000' ]
3. Ada redeems Linus's card into her wallet: 200
4. Ada 'sends' Linus -3000 cents: 500
5. Linus sends 1000 to user 99, who does not exist: 200
6. Anyone reads Ada's wallet: { user_id: 1, balance_cents: 11000 }
end: user 1: 11000, user 2: 0

The wallets started with 20.00 in total, plus 70.00 of gift cards. They end with 110.00 in Ada's wallet and nothing left for Linus. Every line after the first is a real incident:

  1. A code that closes the SQL string and adds OR '1'='1' turned the lookup into "every card". Ada now knows Linus's unused code.
  2. The server believed the userId in the body, so anyone can act as anyone, with no login at all.
  3. A negative amount was not refused. The first UPDATE added 30.00 to Ada; the second failed on the database's CHECK and answered 500, but the first had already been saved, because nothing wrapped the two writes in a transaction.
  4. A transfer to a user who does not exist took 10.00 from Linus and gave it to nobody, and still answered 200.
  5. Anyone can read anyone's balance.

Step 1: name the problems

TRY IT YOURSELF

Write the list

Before you change any code, write down every problem you can find in server.ts, including ones the script did not show. Sort them into security, correctness, architecture and operations. Aim for at least ten.

Work it out first, on paper or in your head. Then use the hints, and compare with the solution.

Step 2: the checklist

The feature is ready when every line holds. Work through them in this order; each one links to the lesson that taught the tool.

✓AreaAcceptance criteria
☐Validation (lesson)Every body, parameter and query is parsed by a schema. Amounts are whole cents from 1 to 100000. A bad request gets 400, never 500.
☐PersistenceEvery SQL statement uses $1 placeholders. No SELECT * reaches a client.
☐Authentication (lesson)The user comes only from the session token. No endpoint accepts a user id for "who am I".
☐Authorization (lesson)A user can read and move only their own money. Support staff can read any wallet but cannot move money.
☐Transactions (lesson)Redeem and transfer are all-or-nothing. A card pays once, even under parallel requests.
☐Tests (lesson)Every incident above has a test that failed on the old code and passes now.
☐Events and queues (events, queue)A wallet.credited event after each commit; the confirmation e-mail goes through a queue with retries.
☐Caching (lesson)Balances are never cached. If card lookups are cached, a redemption invalidates the entry.
☐Abuse protection (lesson)Code lookups and redemptions are rate-limited per user and per IP; ten wrong codes in a row lock redemption for 15 minutes.
☐Observability (lesson)JSON logs with a request id and no codes or tokens in them; metrics for redemptions, transfers and failures; an audit row for every money movement.
☐OpenAPI (lesson)/openapi.json documents every route, its body schema and its error answers.
☐Production (lesson)Configuration checked at startup, /health and /ready, graceful shutdown, timeouts.
☐Deployment (lesson)Runs in Docker Compose behind HTTPS, with migrations, backups and an alert on the failure metric.

Step 3: fix security and data

TRY IT YOURSELF

Validation, SQL, authentication and transactions

Move the rules out of the route handlers into a wallets.ts module with four operations: authenticate, card (look up one code), redeem and transfer. Then write a new, thin HTTP layer on top of it. When you are done, run the same six attacks again: each must be refused with the right status, and the money must add up.

Hints:

  • A gift code has a known shape. A schema with a regular expression refuses anything else before SQL ever sees it. Parameters ($1) are still required: validation is one wall, parameters are the second.
  • Find the user by hashing the bearer token and looking up the hash, as in the capstone's users.ts.
  • Replace "SELECT, then UPDATE" with one UPDATE … WHERE code = $1 AND redeemed_by IS NULL RETURNING …. It checks and claims the card in one step, so the race disappears.
  • For a transfer, debit with AND balance_cents >= $1, then credit, inside db.transaction. Check affectedRows after each UPDATE and throw if it is 0: the throw rolls back the whole transaction.
  • Throw errors from @zudojs/errors. @zudojs/http turns them into 400, 401, 404 and 409 answers for you.

Write it in the editor, run it on your computer, then press Check and paste what it printed. Hints and the solution open up once you have checked your output.

Before you trust the fix

REASON IT OUT

Why does folding the check into the UPDATE close the race, and does the fix need a test to prove it?

The original code ran a SELECT for an unused card, then a separate UPDATE. Two redemptions of the same code sent together could both pass that SELECT before either UPDATE ran, so the card would pay twice. The fix folds the check into the UPDATE itself. Why does that close the race, and is reading the code enough, or do you still need a test that fires two redemptions at once?

Show the reasoning

UPDATE gift_cards SET redeemed_by = $1 WHERE code = $2 AND redeemed_by IS NULL RETURNING amount_cents is one atomic statement: PostgreSQL locks the row for whichever request's UPDATE reaches it first, and the second request's UPDATE now matches redeemed_by IS NULL against a row that already has a redeemer, so it claims zero rows and throws NotFoundError. Reading the code is a hypothesis, not proof: a test that calls redeem twice with Promise.allSettled and asserts exactly one call is fulfilled is what turns "this should close the race" into a fact — the test Step 4 asks you to write.

Step 4: prove it with tests

TRY IT YOURSELF

One test per incident

Write Vitest tests for the wallet module. Give every test a fresh database, so tests cannot affect each other. Include the race: redeem the same card twice at the same time with Promise.allSettled, and require exactly one success.

Write it in the editor and run it. Hints and the solution open up once you have tried.

Step 5: make it production-ready

The rest of the checklist uses what you built in the capstone and the production lessons. Each exercise lists what "done" means and where to look; the solutions describe the approach rather than giving code.

TRY IT YOURSELF

Events, queues, caching and abuse protection

After a successful redemption or transfer, publish wallet.credited and queue a confirmation e-mail. Rate-limit the code endpoints. Decide what, if anything, to cache.

Work it out first, on paper or in your head. Then use the hints, and compare with the solution.

TRY IT YOURSELF

Observability, audit and OpenAPI

Make every money movement traceable and every route documented.

Work it out first, on paper or in your head. Then use the hints, and compare with the solution.

TRY IT YOURSELF

Configure, deploy, monitor and handle failures

Ship it, and decide what happens when things go wrong at 3 a.m.

Work it out first, on paper or in your head. Then use the hints, and compare with the solution.

You are now ready to build with ZudoJS

Look at the distance you have covered. You started with console.log("Hello"). You learned JavaScript, Node.js, how the web and databases work, and TypeScript. You built a backend with no framework and felt why frameworks exist. Then you built the Task API and ShopFlow with ZudoJS: modules and a runtime, dependency injection, routing and middleware, configuration, validation, errors, databases and transactions, authentication, permissions and security, caching, events, queues, CQRS, services, tests and observability. And in these last lessons you took an app to production and learned to judge someone else's code by what it does under attack, not in the demo.

  • Every input is validated, every query is parameterized, every identity comes from a verified session, and every permission is checked where the user is known.
  • Every unit of work is one transaction, and side effects happen after it commits.
  • Every rule that matters has a test that would fail without it.
  • Every production app has configuration checks, logs, metrics, health checks, graceful shutdown, backups you have restored, and a way back.

Where to go next: finish the ShopFlow milestones; read the ZudoJS package documentation for the packages you use most; build something of your own, small, and put it on a real server. When a lesson or a package surprises you, that is worth reporting; frameworks get better because their users say where they hurt.

You are now ready to build with ZudoJS.

Recap

  • Working in a demo is not the same as working in production. Attack your own endpoints the way the internet will.
  • The inherited app had SQL injection, identity taken from the request, no authorization, no validation, two-step writes without a transaction, a check-then-act race, and no tests.
  • The fix moved rules into a module, validated every input with a schema, used parameters in every query, took the user only from the session, claimed the card and moved money in single transactions with conditional updates, and proved each fix with a test.
  • Events, queues, rate limits, a ledger, logs, metrics, OpenAPI, configuration, health checks and a deployment finish the job.

Test yourself

Five questions, picked at random from this lesson's question bank. Some ask you to choose an answer, some to predict what code prints, and some to write code and run it in the terminal. Get 4 of 5 right to pass. If you don't, read the explanations and try again: you get 5 different questions.