In a world with , chapter 4.13.

THE PATH · SESSION 29 OF 66 · UNIT 4 · ACT I

The Second Contract

Another CRUD app with the same JWT auth

50 min read3 diagrams2 side quests1 runnable3,996 words6 code blocks

At a glance

PACE
Session 29 of 66 · suggested week 10. Continue when you are ready.
UNIT
Unit 4 · I Built a Party of Four: Mongo, Express, React, Node
ACT
Act I · Foundation
BEFORE
4.12 The Quest Slate
AFTER
4.14 Blood Oaths
ARTIFACT
A second authenticated CRUD app, a spellbook, built from the same contract without copying the todo files
READ
50 min · 3 diagrams · 6 code blocks

The hook#

You have built this shape once, with the notes open. A screen, a token, a JSON API, an owner on every row. Today the noun changes and the notes stay closed until something actually breaks. The noun is a spell. It has a name, a school, and a level. It does not have a title, and it does not have a done flag. If the only app you can build is a todo, you memorized a todo. Same contract, new noun.

Copying the files and renaming strings hides whether you understand the boundary. A folder produced by search-and-replace still has the holes of the folder you copied, including the early-saved state from the slate if you never fixed it, and it is missing every field you did not remember to add. The spellbook is a second project. New directory, new package.json, new model, new routes, new components, typed while the slate repository is closed. When something breaks, you open the document for the layer that broke. You do not open the whole stack, and you do not open the slate repository to transplant a file.

By the end you can draw a spell request on paper before you touch either project: method, path, header, body, success status, and the query that keeps one account's spells off another account's list. A spell without a school is rejected. A request to a todo path on this process does not hit a todo route, because this process does not have one. Each app only lists its own user's records.

A second door, cut to the same measure, proves the measure. A copied door proves the copy.

The idea#

Thinking in React is the page for the screen, and it asks you to start from the picture rather than from the files you already have. The picture is a login, a list of spells, and a form with three controls: name, school, and level. The component split falls out of the picture. LoginForm talks to /api/login. SpellForm holds the three drafts and calls onCreate. SpellList receives the array and renders a SpellRow per spell. App holds the token and decides which of those trees is on screen. The minimal state is the token, the spell array, and the three drafts. A count of spells in one school is derived during render from the array and a filter value. It is not another useState, and it is not an effect. You already know why. The new fact is that you can name that state list before any component file exists. If your list of state includes title or done, you are still holding the previous noun.

The model page is Mongoose models. A model is the object you call to talk to one collection. Spell talks to spells. It is not Todo with a different variable name. The schema is the whole noun.

JS
const mongoose = require('mongoose'); const schools = [  'abjuration',  'conjuration',  'divination',  'enchantment',  'evocation',  'illusion',  'necromancy',  'transmutation']; const spellSchema = new mongoose.Schema(  {    name: { type: String, required: true, trim: true },    school: { type: String, required: true, enum: schools },    level: { type: Number, required: true, min: 0, max: 9 },    owner: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true, index: true }  },  { timestamps: true }); const Spell = mongoose.model('Spell', spellSchema);module.exports = { Spell, schools };

enum means a school outside that list never reaches the collection. min and max mean a level of 10 never reaches it either. owner is required because a spell without an account is a spell you cannot authorize. The unique email still lives on the user schema, in this project, written again. You are allowed to remember that email is unique and that the password is stored as passwordHash. You are not allowed to paste the user file. Typing the ten lines is how you find out whether you remember which field is unique.

The routing guide is the contract's skeleton, and it does not mention spells. You do. The routes this process mounts are:

  • POST /api/register creates a user and returns 201, or 409 when the email is taken.
  • POST /api/login compares the password and returns a token, or 401.
  • GET /api/spells returns the array of spells whose owner is the token's sub.
  • POST /api/spells validates name, school, and level, creates the document with owner set from the token, and returns 201 with the document.
  • PATCH /api/spells/:id updates fields you allow, only when _id and owner both match, and returns the new document or 404.
  • DELETE /api/spells/:id deletes under the same pair of conditions and returns 204, or 404.

GET /api/todos is not on this list. If that path answers with a todo array, you are still running the slate, or you mounted the slate's router inside the spellbook. The evidence is a different shape and a process that does not know the old noun. Use a different port, 4000, so a slate you forgot to stop cannot answer on 3000 and convince you the new client works. Point the spellbook's fetch base at 4000. A client that still says localhost:3000 is a renamed client, whatever the button text says.

The header is the same sentence as the slate, and the server still verifies it. Authorization: Bearer plus the token. The middleware reads sub into req.userId. Create assigns owner from that id and ignores any owner in the body. List filters on it. Update and delete filter on _id together with it. Show saved only after a 2xx. Keep the three drafts if the request fails. The token stays out of the URL and out of the console. None of those sentences is a new idea. The session is whether you can implement them without a file open beside you.

What you may not soften: the password still goes through bcrypt with the cost factor you already chose. A shorter password rule, or a password stored in clear so the demo is easier to reset, is the failure mode the authentication sheet is about. The second app is where that shortcut feels harmless, because you have done the careful version once and you are tired. The careful version is the contract. Eight characters was the floor on the slate. Keep a floor. Keep the hash. If login accepts the password you typed and the database shows that same password, this project failed even if the list renders.

A paper contract, written before the editor, fits on one page.

  • Noun: name, school, level, owner.
  • Identity: register and login, hash, token, sub.
  • Header: Authorization: Bearer.
  • Collection routes under /api/spells, each with its method and its success status.
  • Owner in every query. Owner never taken from the client.
  • Saved only after response.ok. Drafts kept otherwise.
  • This process has no todo route. This client has no todo component.

Tape that page to the side of the screen. Implement from it. When a test disagrees with the page, the test wins and you fix the page too, so the page stays true. The page is how the evidence named Closed notes is met: you can draw the request before you open the slate repository.

Session 4.13: The working model. Noun: A spell has a name, a school, and a level. That is the whole schema. Contract: The same four operations and the same token header as the todo API. Screen: List, form, and a logout. No new ideas. The repetition is the point.
Fig 1 · The working model: Noun → Contract → Screen

A create path that only accepts a spell#

The handler is short because the schema is doing real work. You still check the types before you call create, so a wrong JSON shape becomes a 400 you wrote, with a message you recognize, rather than a validation error you have to decode under pressure.

JS
const { Spell, schools } = require('./spell'); async function createSpell(req, res) {  const body = req.body || {};  const name = typeof body.name === 'string' ? body.name.trim() : '';  const school = typeof body.school === 'string' ? body.school : '';  const level = body.level;  if (name === '') return res.status(400).json({ error: 'name required' });  if (schools.indexOf(school) === -1) return res.status(400).json({ error: 'unknown school' });  if (typeof level !== 'number' || level < 0 || level > 9) {    return res.status(400).json({ error: 'level must be a number from 0 to 9' });  }  const spell = await Spell.create({    name: name,    school: school,    level: level,    owner: req.userId  });  res.status(201).json(spell);}

Send { "name": "Mage Armor", "school": "abjuration", "level": 1 } and expect 201. Send { "name": "Mage Armor", "level": 1 } and expect 400. Send { "title": "Oil the hinges", "done": false } and expect 400, because name and school are missing and level is not a number. That third body is the renamed-todo test. A handler that reads req.body.title and stores a spell-shaped document with an empty name will fail it. A handler that was copied and still inserts into the todos collection will fail it in a worse way: the request "succeeds" and the spellbook's list, which reads the spells collection, stays empty. Success against the wrong collection is the bug a rename produces when you only look at the status line.

Mount the handler on a router that does not know any other noun.

JS
const express = require('express');const spells = express.Router(); spells.use(requireUser);spells.get('/', listSpells);spells.post('/', createSpell);spells.patch('/:id', updateSpell);spells.delete('/:id', deleteSpell); app.use('/api/spells', spells);

requireUser on the router means you cannot forget it on one of the four lines. Login and register stay on app, above this mount, because they are how you get a token. A GET /api/spells without a token is 401. A GET /api/todos falls through to a JSON 404, which you should mount at the end on purpose so a stray client sees { "error": "not found" } and not an HTML stack.

The audience, so a token has a home#

jwt.sign and jwt.verify accept an audience option. The readme for jsonwebtoken documents it next to expiresIn. Sign the spellbook token with audience: 'spellbook'. Verify with the same audience. A token whose audience is slate, or a token that has no audience because the slate signed it that way, fails verify on this process.

JS
function signSpellbookToken(userId, secret) {  return jwt.sign({ sub: userId }, secret, { expiresIn: '2h', audience: 'spellbook' });} function readSpellbookToken(token, secret) {  return jwt.verify(token, secret, { audience: 'spellbook' });}

Here is the bug the diagnosis names. Both projects read JWT_SECRET from the environment. In a hurry you export one secret and launch both servers from the same shell. verify checks the signature and the expiry. If you never set an audience, a token minted by the slate verifies on the spellbook, and a token minted by the spellbook verifies on the slate. The sub is a user id. If the two apps share a database and a users collection, that id is the same person, and logging into the spellbook has just authorized the todo API. If the databases are separate, the id might not exist on the other side, or it might collide with a different person who received the same ObjectId by chance in a tiny database you reset a lot. You will not notice. The request returns 200 and a list. The list might even be empty, which looks like privacy and is only an empty query.

It is acceptable, for a single afternoon on your own machine, to know that you reused a secret. It is not acceptable to reuse it and be unable to say so. The fatal version is the one you discover when a second application is public and a token from the smaller one still opens the larger one. Set the audience now, in both apps if you are willing to touch the slate for two lines, so that a slate token presented to the spellbook is a 401 you have seen with your eyes. Record that 401 in the notes. That is the shared-token bug, understood, rather than the shared-token bug, shipped.

The owner filter is still required after the audience check. Audience answers "which application minted this token." Owner answers "which rows may this sub see." A spellbook token for Sera must not list Bran's spells. Audience does not do that. find({ owner: req.userId }) does. Both lines stay.

The screen, built from the picture#

The form's drafts are strings until submit. The level control is a select or a number input whose value is a string, because DOM inputs yield strings. The handler converts it at the edge.

JSX
function SpellForm(props) {  const [name, setName] = useState('');  const [school, setSchool] = useState('abjuration');  const [level, setLevel] = useState('1');  const [notice, setNotice] = useState('');   async function onSubmit(event) {    event.preventDefault();    setNotice('');    const parsedLevel = Number(level);    const response = await fetch(props.apiBase + '/api/spells', {      method: 'POST',      headers: {        'Content-Type': 'application/json',        Authorization: 'Bearer ' + props.token      },      body: JSON.stringify({ name: name, school: school, level: parsedLevel })    });    if (!response.ok) {      setNotice('not saved');      return;    }    const saved = await response.json();    props.onCreated(saved);    setName('');    setNotice('saved');  }   return (    <form onSubmit={onSubmit}>      <input value={name} onChange={function (event) { setName(event.target.value); }} aria-label="Spell name" />      <select value={school} onChange={function (event) { setSchool(event.target.value); }} aria-label="School">        {props.schools.map(function (item) {          return <option key={item} value={item}>{item}</option>;        })}      </select>      <input value={level} onChange={function (event) { setLevel(event.target.value); }} aria-label="Level" />      <button type="submit">Inscribe</button>      <p>{notice}</p>    </form>  );}

props.schools is a constant array in the client, duplicated from the server's enum. That duplication is honest. The client needs the list to render options. It does not import the Mongoose schema to get it. If the two lists drift, the server remains the authority: an option the server does not know becomes a 400, and the notice stays on "not saved." The facade is the JSON contract. The schema stays in the server project. A client that reaches into validation error internals and displays Mongoose path names is starting to know too much, but a client that displays "unknown school" from your 400 body is using the contract you wrote. Prefer the body you wrote.

onCreated receives the server document and the parent appends it. The form does not append. The form does not know the owner's id. The row's key is spell._id. School and level render from the document, so a successful create shows the school you sent, not a hardcoded "abjuration" left over from the todo row you used as a visual template.

Logout, load-on-token, and sessionStorage are the same decisions as the slate. Implement them from the paper. If the load effect fetches /api/todos, you copied the effect and changed the heading. The network panel is the judge. The path on the request is /api/spells.

The demonstration#

Stop the slate first, in both terminals: Ctrl-C on the API and Ctrl-C on its Vite dev server, so nothing answers on 3000 and the spellbook's own Vite prints 5173 again. Then close the slate. Put it in another editor window only if you enjoy losing the session, and then the session is already lost. Create ~/projects/spellbook-api and ~/projects/spellbook-client as new directories. npm init -y and install express, mongoose, bcrypt, jsonwebtoken, and cors in the API. Scaffold the client with Vite's React template the way you did for the primer. You are allowed to remember those commands. You are not allowed to cp -R the slate and then run a rename.

Write the schema from the block above. Write register and login again, with audience: 'spellbook' on sign and on verify. Write the four spell routes on a router mounted at /api/spells. Write a final 404 that returns JSON. Listen on port 4000. Mount cors({ origin }) with the origin read from PAGE_ORIGIN, the way the slate did; that origin is http://localhost:5173, which is what Vite prints once the slate's dev server is stopped. Write the API's .env the same way too: MONGODB_URI=mongodb://127.0.0.1:27017/spellbook, JWT_SECRET, and PAGE_ORIGIN=http://localhost:5173, started with node --env-file=.env server.js. The database name is spellbook, not slate, so a careless find cannot show you the slate's todos and look like success. A MONGODB_URI still exported in the shell wins over the file, so unset MONGODB_URI first.

In the client, hard-code the API base as http://localhost:4000. Build login, the list, and the form. The list effect depends on the token and requests /api/spells. The form is the one in the idea section, or yours, as long as the body has name, school, and level, and the saved notice waits for response.ok.

Then run the four checks that tell a rebuild from a copy.

First, the shape. POST /api/spells with no school returns 400 and the collection is unchanged. POST with Mage Armor, abjuration, level 1 returns 201. The document in MongoDB has those three fields and an owner. It does not have title.

Second, the other noun. GET http://localhost:4000/api/todos with a valid spellbook token returns 404. If it returns an array, a todo router is mounted or the client is pointed at the wrong port.

Third, the other account. Register Sera and Bran. Inscribe a spell as Sera. Log in as Bran. The list is empty. Call DELETE on Sera's spell id with Bran's token. Expect 404. Sera's list still shows the spell after she logs in again.

Fourth, the audience, if the slate is still around to mint a token. Present a slate token to GET /api/spells. Expect 401 from verify. If you get 200, the audience option is missing on one side or both, and you are living in the shared-token bug. Fix the spellbook first. Note whether the slate still accepts a spellbook token. That note is the diagnosis, written down while the fact is in front of you.

The checkpoint is a model of the shape test, the audience test, and the owner test. It does not connect to MongoDB. Your two terminals do.

JScheckpoint-4-13.js
function check(condition, message) {  if (!condition) throw new Error(message);  console.log('PASS:', message);} var schools = [  'abjuration',  'conjuration',  'divination',  'enchantment',  'evocation',  'illusion',  'necromancy',  'transmutation']; function validateSpell(body) {  if (!body || typeof body.name !== 'string' || body.name.trim() === '') return 400;  if (typeof body.school !== 'string' || schools.indexOf(body.school) === -1) return 400;  if (typeof body.level !== 'number' || body.level < 0 || body.level > 9) return 400;  return 201;} check(validateSpell({ name: 'Mage Armor', school: 'abjuration', level: 1 }) === 201, 'a spell with name, school, and level is accepted');check(validateSpell({ name: 'Mage Armor', level: 1 }) === 400, 'a spell without a school is rejected');check(validateSpell({ title: 'Oil the hinges', done: false }) === 400, 'a todo body is not a spell'); function audienceAllows(tokenAudience, routeAudience) {  return tokenAudience === routeAudience;}check(audienceAllows('spellbook', 'spellbook') === true, 'a spellbook token matches the spellbook audience');check(audienceAllows('slate', 'spellbook') === false, 'a slate token does not match the spellbook audience');check(audienceAllows('', 'spellbook') === false, 'a token with no audience does not match a route that requires one'); function routeExists(method, path) {  var routes = {    'GET /api/spells': true,    'POST /api/spells': true,    'GET /api/todos': false  };  return routes[method + ' ' + path] === true;}check(routeExists('GET', '/api/spells') === true, 'the spell list route exists');check(routeExists('GET', '/api/todos') === false, 'this process has no todo route'); function listedFor(spells, userId) {  return spells.filter(function (spell) { return spell.owner === userId; });}var book = [  { name: 'Mage Armor', owner: 'sera' },  { name: 'Silent Image', owner: 'bran' }];var seraSees = listedFor(book, 'sera');check(seraSees.length === 1 && seraSees[0].name === 'Mage Armor', 'each account sees only its own spells'); console.log('ok: checkpoint-4-13');

Where it breaks#

Your turn#

The quest#

Session 4.13: What done looks like. Different shape: A spell without a school is rejected. A todo route does not exist in this process. Closed notes: You can draw the request on paper before you open the slate repository. Both apps: Each app only lists its own user's records.
Fig 3 · What done looks like: Different shape · Closed notes · Both apps

CONTRACT.md is the drawing, written first. SPELLBOOK.md is the record of what the running process did, written after.

  • Different shape. The 400 body for a spell with no school, and the 404 for GET /api/todos on port 4000. Quote the status lines.
  • Closed notes. State the time you wrote the contract relative to the first route, and one place where the running app disagreed with the paper. What you changed.
  • Both apps, or this app's two users if the slate is gone. Who created which spell, and what the other account's list contained. The status when the other token hit DELETE.
  • Audience. The status a token from the other audience received, or the status a token signed without an audience received.
  • One defect you noticed because you were not looking at the old file, and the line you wrote instead.

Commit the new projects and both notes. Leave the slate's history untouched. The spellbook should read like a sibling, not like a branch of a rename. The next session adds a relationship the spellbook does not have. You will want the habit of choosing a shape on purpose, which is all this hour was.

Side quests#

The spellbook client should meet a facade over Mongoose rather than the schema, and loosening the password rules to make the second demo easier is the authentication-failures page you should read before you touch the hash.

Side quests from this session

  • FacadeThe API your React app calls is a facade over Mongoose. If the second app's client knows about schemas, the facade has leaked. The sheet is the line.
    Design Patterns
  • A07:2025 Authentication FailuresYou will be tempted to loosen the password rules to make the demo easier. The authentication-failures page is the argument against that shortcut.
    Security

Before next session#

Next session a spell's single owner is no longer the interesting relationship. A party has many members, and a member stands in many parties. That is a different shape from a ref on one side. Read the opening of Mongoose populate and notice what it claims to replace: paths in the documents you got back, by a separate query, before the results return. Come in able to say what your spell's owner field stores when you look at it in the database. It stores an id. Populate is not how that id got there. You are about to need a place to store a fact that belongs to the pair, not to either person, and an array on each side will not be that place.

See also