Emmanuel Akinfulubi

Project all 1 written

One small authenticated notes app, end to end: built, served, requested and tested by panels that really ran.

31 One project end to end

This is the lesson where the pieces become one app. Shelf is a small notes app: people sign up, log in, and keep a list of short notes about the books they are reading. It is deliberately small enough to read in an hour, and it uses what the earlier lessons taught, with nothing new: file-based routes and layouts (Part 2), server components and Server Actions (Part 3, Part 5), route handlers and the proxy, metadata, a local image and font (Part 6), sessions by hand (Part 7) and tests (lesson 28). It is a separate project from the one the other lessons used, built and started once for this lesson. The React course ends with its own project; this one is the server-side counterpart.

The project, as built

The panel builds the project and prints three things: the files of the project (a script lists the directory the build ran in), the build log with its route table, and the checks. The files shown above the output are the core of it: the data layer, the session module, the two server-side entry points (the actions and the route handler), the proxy and the configuration. The remaining files (the pages) are small and you can read them in tools/projects/proj/.

Building the shelf project

ran at build time · next build · Next 16.3.1

package.json

{
  "name": "shelf",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start"
  },
  "dependencies": {
    "next": "16.3.1",
    "react": "19.2.7",
    "react-dom": "19.2.7"
  }
}

dot-env

# A demo value, so that this project runs the same everywhere. A real deployment sets its own
# long random secret in the host's environment and never commits it.
SESSION_SECRET=shelf-demo-secret-replace-me-in-real-life

proxy.js

import { NextResponse } from 'next/server';

// An optimistic check only: no cookie, no notes page. Whether the cookie is any good is decided
// by app/_lib/session.js, in every page and action that needs a user.
export function proxy(request) {
  if (!request.cookies.has('shelf_session')) {
    return NextResponse.redirect(new URL('/login', request.url));
  }
  return NextResponse.next();
}

export const config = {
  matcher: ['/notes/:path*'],
};

app/_lib/store.js

// The data layer, with no Next.js and no hidden state: a store is made from an adapter, so the
// same code runs against a file in the app and against memory in a test.
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname } from 'node:path';

const empty = () => ({ users: [], notes: [], next: 1 });

/** An adapter is { read() -> data | null, write(data) }. */
export function fileAdapter(path) {
  return {
    read() {
      try {
        return JSON.parse(readFileSync(path, 'utf8'));
      } catch {
        return null;
      }
    },
    write(data) {
      mkdirSync(dirname(path), { recursive: true });
      writeFileSync(path, JSON.stringify(data, null, 2));
    },
  };
}

export function memoryAdapter() {
  let saved = null;
  return {
    read: () => (saved === null ? null : structuredClone(saved)),
    write: (data) => { saved = structuredClone(data); },
  };
}

export function createStore(adapter) {
  const load = () => adapter.read() ?? empty();
  return {
    addUser({ name, email, passwordHash }) {
      const data = load();
      if (data.users.some((user) => user.email === email)) return null;
      const user = { id: data.next++, name, email, passwordHash };
      data.users.push(user);
      adapter.write(data);
      return user;
    },
    findUser: (email) => load().users.find((user) => user.email === email) ?? null,
    findUserById: (id) => load().users.find((user) => user.id === id) ?? null,
    addNote(userId, title) {
      const data = load();
      const note = { id: data.next++, userId, title };
      data.notes.push(note);
      adapter.write(data);
      return note;
    },
    notesFor: (userId) => load().notes.filter((note) => note.userId === userId),
  };
}

app/_lib/db.js

// The one store the app uses: a JSON file under data/ in the project directory.
import { join } from 'node:path';
import { createStore, fileAdapter } from './store.js';

export const db = createStore(fileAdapter(join(process.cwd(), 'data', 'shelf.json')));

app/_lib/session.js

import 'server-only';
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
import { readSession, signSession } from './session-core.js';
import { db } from './db.js';

export const COOKIE = 'shelf_session';
const SECRET = process.env.SESSION_SECRET;
if (!SECRET) throw new Error('SESSION_SECRET is not set');

export async function createSession(userId) {
  const token = signSession({ sub: userId, exp: Date.now() + 60 * 60 * 1000 }, SECRET);
  (await cookies()).set(COOKIE, token, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    path: '/',
    maxAge: 60 * 60,
  });
}

export async function getSession() {
  const payload = readSession((await cookies()).get(COOKIE)?.value, SECRET);
  return (payload && db.findUserById(payload.sub)) || null;
}

export async function verifySession() {
  const user = await getSession();
  if (!user) redirect('/login');
  return user;
}

export async function destroySession() {
  (await cookies()).delete(COOKIE);
}

app/auth/actions.js

'use server';
import { redirect } from 'next/navigation';
import { createSession, destroySession } from '../_lib/session.js';
import { hashPassword, verifyPassword } from '../_lib/session-core.js';
import { db } from '../_lib/db.js';
import { validateSignup } from '../_lib/validate.js';

const back = (path, message) => redirect(`${path}?error=${encodeURIComponent(message)}`);

export async function signup(formData) {
  const values = {
    name: String(formData.get('name') ?? ''),
    email: String(formData.get('email') ?? '').trim().toLowerCase(),
    password: String(formData.get('password') ?? ''),
  };
  const problems = Object.values(validateSignup(values));
  if (problems.length > 0) back('/signup', problems[0]);
  const user = db.addUser({ name: values.name.trim(), email: values.email, passwordHash: hashPassword(values.password) });
  if (!user) back('/signup', 'That email is already registered.');
  await createSession(user.id);
  redirect('/notes');
}

export async function login(formData) {
  const user = db.findUser(String(formData.get('email') ?? '').trim().toLowerCase());
  if (!user || !verifyPassword(String(formData.get('password') ?? ''), user.passwordHash)) back('/login', 'Wrong email or password.');
  await createSession(user.id);
  redirect('/notes');
}

export async function logout() {
  await destroySession();
  redirect('/login');
}

app/notes/actions.js

'use server';
import { redirect } from 'next/navigation';
import { verifySession } from '../_lib/session.js';
import { db } from '../_lib/db.js';
import { validateNote } from '../_lib/validate.js';

export async function addNote(formData) {
  const user = await verifySession();
  const title = String(formData.get('title') ?? '');
  const problem = validateNote(title);
  if (problem) redirect(`/notes?error=${encodeURIComponent(problem)}`);
  db.addNote(user.id, title.trim());
  redirect('/notes');
}

app/api/notes/route.js

import { getSession } from '../../_lib/session.js';
import { db } from '../../_lib/db.js';

export async function GET() {
  const user = await getSession();
  if (!user) return Response.json({ error: 'not signed in' }, { status: 401 });
  return Response.json({ notes: db.notesFor(user.id).map((note) => note.title) });
}

shelf-check.mjs

import { existsSync, readdirSync, readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';

const root = process.env.APP_DIR;
const lines = [];
function walk(dir, depth) {
  const entries = readdirSync(dir, { withFileTypes: true })
    .filter((entry) => !(depth === 0 && (entry.name === 'node_modules' || entry.name === '.next')))
    .sort((a, b) => (a.name < b.name ? -1 : 1));
  for (const entry of entries) {
    lines.push('  '.repeat(depth) + entry.name + (entry.isDirectory() ? '/' : ''));
    if (entry.isDirectory()) walk(join(dir, entry.name), depth + 1);
  }
}
walk(root, 0);
console.log('the project (without node_modules and .next):');
console.log(lines.join('\n'));

const log = readFileSync(join(dirname(root), 'build.out'), 'utf8');
const symbol = (path) => (new RegExp('^[┌├└│]\\s+([○●ƒ◐])\\s+' + path.replace(/[/]/g, '[/]') + '(?:\\s|$)', 'm').exec(log) || [])[1];
const secret = /^SESSION_SECRET=(.+)$/m.exec(readFileSync(root + '/.env', 'utf8'))[1];
const chunks = readdirSync(root + '/.next/static', { recursive: true, withFileTypes: true })
  .filter((entry) => entry.isFile() && entry.name.endsWith('.js'))
  .map((entry) => readFileSync(join(entry.parentPath, entry.name), 'utf8'));

check('the home page is static and every page or handler that reads the session is dynamic', ['/', '/signup', '/login', '/notes', '/api/notes'].map(symbol), ['○', 'ƒ', 'ƒ', 'ƒ', 'ƒ']);
check('the proxy was built', existsSync(root + '/.next/server/middleware-manifest.json'), true);
check('there are client scripts to search for the secret', chunks.length > 0, true);
check('and the session secret is in none of them', chunks.some((chunk) => chunk.includes(secret)), false);
check('the build did not create the data folder: the store writes it on first use', existsSync(root + '/data'), false);

What next build printed (Next 16.3.1), then the checks, when the build ran it

next build

▲ Next.js 16.3.1 (Turbopack)
- Environments: .env
✓ Running next.config.mjs took <t>

  Creating an optimized production build ...
✓ Compiled successfully in <t>
  Running TypeScript ...
  Finished TypeScript in <t> ...
  Collecting page data using <n> workers ...
✓ Generating static pages using <n> workers (7/7) in <t>
  Finalizing page optimization ...

Route (app)
┌ ○ /
├ ○ /_not-found
├ ƒ /api/notes
├ ƒ /login
├ ƒ /notes
└ ƒ /signup


ƒ Proxy (Middleware)

○  (Static)   prerendered as static content
ƒ  (Dynamic)  server-rendered on demand

ok   the log shows "Route (app)"
ok   the log shows "○ /_not-found"
ok   the log shows "ƒ /notes"
ok   the log shows "ƒ /api/notes"
ok   the log shows "ƒ Proxy (Middleware)"
ok   the log shows "○  (Static)"
ok   the log shows "ƒ  (Dynamic)"
the project (without node_modules and .next):
.env
app/
  _lib/
    db.js
    session-core.js
    session.js
    store.js
    validate.js
  api/
    notes/
      route.js
  auth/
    actions.js
  fonts/
    Inter-400.woff2
  globals.css
  layout.js
  login/
    page.js
  notes/
    actions.js
    page.js
  page.js
  signup/
    page.js
next.config.mjs
package.json
proxy.js
public/
  shelf.png
ok   the home page is static and every page or handler that reads the session is dynamic
ok   the proxy was built
ok   there are client scripts to search for the secret
ok   and the session secret is in none of them
ok   the build did not create the data folder: the store writes it on first use

The check script ran after the build, with APP_DIR set to the project, and read the files in .next.

How it fits together

The store (store.js) is the one idea in this project that the earlier lessons did not use. It is a set of functions over an adapter, a pair of read() and write(data). The app's adapter keeps one JSON file (db.js joins the working directory and data/shelf.json); a test's adapter keeps the same data in memory. The code is the same in both, which is what lets the unit tests below run in milliseconds with nothing on disk. A real app would put a database behind the same functions. The session module is lesson 26's, with the user table replaced by the store, and with SESSION_SECRET required: the module throws when it is loaded if it is not set, because a missing secret should be a loud failure and not a quiet fallback. The secret comes from the project's .env (stored here as dot-env, as in lesson 22). The panel checked that its value is in no script sent to browsers. The actions are glue: they read the form, call the pure functions, write through the store and redirect. The proxy sends visitors without a cookie away from /notes; the pages and actions do the real check (lesson 27). The route handler answers 401 or the notes as JSON.

Using it

Now the running app. The panel loads the home page and reads its metadata, its picture and its font; asks the optimiser for the picture; signs up through the form with no JavaScript; and opens the file the store wrote. That file is where an app like this keeps its data, so it is worth seeing once.

The home page, the optimiser, a sign-up and the file the store wrote

ran at build time · next build + next start · Next 16.3.1

app/layout.js

import Link from 'next/link';
import localFont from 'next/font/local';
import './globals.css';

const inter = localFont({ src: './fonts/Inter-400.woff2', weight: '400', style: 'normal', display: 'swap' });

export const metadata = {
  title: { template: '%s | Shelf', default: 'Shelf' },
  description: 'Notes on the books you are reading.',
};

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body className={inter.className}>
        <nav>
          <Link href="/">Home</Link>
          <Link href="/notes">Notes</Link>
        </nav>
        {children}
      </body>
    </html>
  );
}

app/page.js

import Image from 'next/image';
import Link from 'next/link';

export default function Home() {
  return (
    <main>
      <h1>Shelf</h1>
      <Image src="/shelf.png" alt="Five books on a shelf" width={120} height={80} priority />
      <p>Keep notes on the books you are reading.</p>
      <p>
        <Link href="/signup">Sign up</Link> or <Link href="/login">log in</Link>.
      </p>
    </main>
  );
}

walk.mjs

import { readFileSync } from 'node:fs';

const base = process.env.BASE_URL;
const ids = (html) => [...html.matchAll(/name="(\$ACTION_ID_[0-9a-f]+)"/g)].map((m) => m[1]);
const attr = (tag, name) => {
  const m = new RegExp(' ' + name + '="([^"]*)"').exec(tag);
  return m ? m[1].replace(/&amp;/g, '&') : null;
};

const home = await (await fetch(base + '/')).text();
const signupPage = await (await fetch(base + '/signup')).text();
const img = /<img[^>]*>/.exec(home)[0];
const font = /<link rel="preload" href="([^"]*)" as="font"/.exec(home);
const webp = await fetch(base + attr(img, 'src'), { headers: { accept: 'image/webp' } });

console.log('title of /       :', /<title>([^<]*)<\/title>/.exec(home)[1]);
console.log('title of /signup :', /<title>([^<]*)<\/title>/.exec(signupPage)[1]);
console.log('picture          :', attr(img, 'width') + ' x ' + attr(img, 'height'), 'through the optimiser:', attr(img, 'src').startsWith('/_next/image?url=%2Fshelf.png'));
console.log('optimiser says   :', webp.status, webp.headers.get('content-type'));
console.log('font preloaded   :', font[1].startsWith('/_next/static/media/Inter_400'));

const body = new FormData();
body.append(ids(signupPage)[0], '');
for (const [name, value] of Object.entries({ name: 'Ada', email: 'ada-shelf@example.com', password: 'correct horse' })) body.append(name, value);
const res = await fetch(base + '/signup', { method: 'POST', body, redirect: 'manual', headers: { origin: base } });
const raw = readFileSync(process.env.APP_DIR + '/data/shelf.json', 'utf8');
const saved = JSON.parse(raw);
const user = saved.users.find((u) => u.email === 'ada-shelf@example.com');

console.log('sign-up          :', res.status, new URL(res.headers.get('location'), base).pathname);
console.log('data/shelf.json  : keys', Object.keys(saved).join(', '), '| user keys', Object.keys(user).join(', '));
console.log('stored hash      :', user.passwordHash.replace(/[0-9a-f]/g, 'x'));

check('the home page uses the layout\u2019s default title', /<title>([^<]*)<\/title>/.exec(home)[1], 'Shelf');
check('a page in a child segment gets the title template', /<title>([^<]*)<\/title>/.exec(signupPage)[1], 'Sign up | Shelf');
check('the picture is sized and goes through the optimiser', [attr(img, 'width'), attr(img, 'height'), attr(img, 'src').startsWith('/_next/image?url=%2Fshelf.png')], ['120', '80', true]);
check('which answers with WebP', [webp.status, webp.headers.get('content-type')], [200, 'image/webp']);
check('the layout preloads the local font file', font[1].startsWith('/_next/static/media/Inter_400') && font[1].endsWith('.woff2'), true);
check('signing up redirects to the notes page', [res.status, new URL(res.headers.get('location'), base).pathname], [303, '/notes']);
check('the store wrote its file on first use, with users, notes and a counter', Object.keys(saved).sort(), ['next', 'notes', 'users']);
check('the user is saved with an id, a name, an email and a hash', Object.keys(user).sort(), ['email', 'id', 'name', 'passwordHash']);
check('the hash has the salt:scrypt form of lesson 26, and the password is nowhere in the file', [/^[0-9a-f]{32}:[0-9a-f]{64}$/.test(user.passwordHash), raw.includes('correct horse')], [true, false]);

What the driver printed (Next 16.3.1, next build + next start), when the build ran it

node walk.mjs

title of /       : Shelf
title of /signup : Sign up | Shelf
picture          : 120 x 80 through the optimiser: true
optimiser says   : 200 image/webp
font preloaded   : true
sign-up          : 303 /notes
data/shelf.json  : keys users, notes, next | user keys id, name, email, passwordHash
stored hash      : xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
ok   the home page uses the layout’s default title
ok   a page in a child segment gets the title template
ok   the picture is sized and goes through the optimiser
ok   which answers with WebP
ok   the layout preloads the local font file
ok   signing up redirects to the notes page
ok   the store wrote its file on first use, with users, notes and a counter
ok   the user is saved with an id, a name, an email and a hash
ok   the hash has the salt:scrypt form of lesson 26, and the password is nowhere in the file

The driver ran against next start -H 127.0.0.1 -p 0 of project proj, right after its next build; BASE_URL is the address Next printed.

The tests

Two files of node:test tests, as in lesson 28. The first needs no server: it tests the store through its memory adapter, and the validation, password and token functions. The second talks to the running app over HTTP and walks through what a person would do: be sent to the login page, sign up, add notes, be refused when the cookie is forged, and sign out. A shared state matters here: the store is a file, so every test signs up its own email, and the tests run in the order they are written.

Unit tests: the store, validation, passwords and tokens

ran at build time · node:test + next start · Next 16.3.1

unit-tests.mjs

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createStore, memoryAdapter } from '../app/app/_lib/store.js';
import { hashPassword, readSession, signSession, verifyPassword } from '../app/app/_lib/session-core.js';
import { validateNote, validateSignup } from '../app/app/_lib/validate.js';

test('store: a new user gets an id and is found by email', () => {
  const store = createStore(memoryAdapter());
  const user = store.addUser({ name: 'Ada', email: 'ada@example.com', passwordHash: 'x' });
  assert.equal(store.findUser('ada@example.com').id, user.id);
  assert.equal(store.findUser('nobody@example.com'), null);
});

test('store: the same email cannot register twice', () => {
  const store = createStore(memoryAdapter());
  store.addUser({ name: 'Ada', email: 'ada@example.com', passwordHash: 'x' });
  assert.equal(store.addUser({ name: 'Ada again', email: 'ada@example.com', passwordHash: 'y' }), null);
});

test('store: notes belong to the user who wrote them', () => {
  const store = createStore(memoryAdapter());
  const ada = store.addUser({ name: 'Ada', email: 'ada@example.com', passwordHash: 'x' });
  const grace = store.addUser({ name: 'Grace', email: 'grace@example.com', passwordHash: 'x' });
  store.addNote(ada.id, 'Middlemarch, chapter 3');
  assert.deepEqual(store.notesFor(ada.id).map((note) => note.title), ['Middlemarch, chapter 3']);
  assert.deepEqual(store.notesFor(grace.id), []);
});

test('store: a second store over the same adapter sees what the first saved', () => {
  const adapter = memoryAdapter();
  createStore(adapter).addUser({ name: 'Ada', email: 'ada@example.com', passwordHash: 'x' });
  assert.equal(createStore(adapter).findUser('ada@example.com').name, 'Ada');
});

test('validateNote: empty and over-long notes are refused, with the reason', () => {
  assert.equal(validateNote('   '), 'Write something.');
  assert.equal(validateNote('x'.repeat(81)), 'Keep it under 80 characters.');
  assert.equal(validateNote('Dracula, chapter 2'), '');
});

test('validateSignup: a short password and a bad email are both reported', () => {
  assert.deepEqual(Object.keys(validateSignup({ name: 'Ada', email: 'nope', password: 'short' })).sort(), ['email', 'password']);
});

test('passwords: verified against the hash, not stored as text', () => {
  const stored = hashPassword('correct horse');
  assert.equal(stored.includes('correct horse'), false);
  assert.equal(verifyPassword('correct horse', stored), true);
  assert.equal(verifyPassword('battery staple', stored), false);
});

test('tokens: a signed session reads back with the same secret', () => {
  assert.equal(readSession(signSession({ sub: 4, exp: 9_000 }, 's3cret'), 's3cret', 1_000).sub, 4);
});

test('tokens: one signed with another secret is refused', () => {
  assert.equal(readSession(signSession({ sub: 4, exp: 9_000 }, 'other'), 's3cret', 1_000), null);
});

What the tests printed (Next 16.3.1, node:test + next start), when the build ran them

node unit-tests.mjs

ok   store: a new user gets an id and is found by email
ok   store: the same email cannot register twice
ok   store: notes belong to the user who wrote them
ok   store: a second store over the same adapter sees what the first saved
ok   validateNote: empty and over-long notes are refused, with the reason
ok   validateSignup: a short password and a bad email are both reported
ok   passwords: verified against the hash, not stored as text
ok   tokens: a signed session reads back with the same secret
ok   tokens: one signed with another secret is refused

The tests ran under node against next start -H 127.0.0.1 -p 0 of project proj, right after its next build; BASE_URL is the address Next printed. The reporter is the course's own, tools/test-reporter.mjs: one line per test, with no durations. Run node --test yourself to see Node's usual report.

Tests over HTTP: a person using the app

ran at build time · node:test + next start · Next 16.3.1

http-tests.mjs

import { test } from 'node:test';
import assert from 'node:assert/strict';

const base = process.env.BASE_URL;
const ids = (html) => [...html.matchAll(/name="(\$ACTION_ID_[0-9a-f]+)"/g)].map((m) => m[1]);
const get = (path, cookie) => fetch(base + path, { redirect: 'manual', headers: cookie ? { cookie } : {} });
const pathOf = (res) => new URL(res.headers.get('location'), base).pathname;
const cookieOf = (res) => (res.headers.getSetCookie()[0] || '').split('; ')[0];

// Submit the form of a page the way a browser with JavaScript off would.
async function submit(path, fields, { cookie, form = 0 } = {}) {
  const body = new FormData();
  body.append(ids(await (await get(path, cookie)).text())[form], '');
  for (const [name, value] of Object.entries(fields)) body.append(name, value);
  return fetch(base + path, { method: 'POST', body, redirect: 'manual', headers: { origin: base, ...(cookie ? { cookie } : {}) } });
}

let cookie = '';

test('notes without a cookie redirect to the login page', async () => {
  const res = await get('/notes');
  assert.deepEqual([res.status, pathOf(res)], [307, '/login']);
});

test('signing up sets the session cookie and goes to the notes page', async () => {
  const res = await submit('/signup', { name: 'Tess', email: 'tess@example.com', password: 'correct horse' });
  cookie = cookieOf(res);
  assert.deepEqual([res.status, pathOf(res), cookie.startsWith('shelf_session=')], [303, '/notes', true]);
});

test('signing up again with the same email is refused', async () => {
  const res = await submit('/signup', { name: 'Tess', email: 'tess@example.com', password: 'correct horse' });
  assert.equal(res.status, 303);
  assert.match(res.headers.get('location'), /^\/signup\?error=That%20email%20is%20already%20registered\./);
});

test('the notes page says who is signed in', async () => {
  const html = await (await get('/notes', cookie)).text();
  assert.match(html, /Notes for tess@example\.com/);
});

test('a note is added and listed', async () => {
  const res = await submit('/notes', { title: 'Persuasion, chapter 1' }, { cookie });
  assert.deepEqual([res.status, pathOf(res)], [303, '/notes']);
  const html = await (await get('/notes', cookie)).text();
  assert.deepEqual([...html.matchAll(/<li>([^<]*)<\/li>/g)].map((m) => m[1]), ['Persuasion, chapter 1']);
});

test('an empty note is refused with a message', async () => {
  const res = await submit('/notes', { title: '   ' }, { cookie });
  assert.match(res.headers.get('location'), /^\/notes\?error=Write%20something\./);
});

test('the route handler lists the notes for the session and refuses without one', async () => {
  const signedIn = await get('/api/notes', cookie);
  assert.deepEqual([signedIn.status, await signedIn.json()], [200, { notes: ['Persuasion, chapter 1'] }]);
  const anonymous = await get('/api/notes');
  assert.deepEqual([anonymous.status, await anonymous.json()], [401, { error: 'not signed in' }]);
});

test('a cookie whose payload was edited to another user is refused', async () => {
  const [payload, signature] = cookie.slice(cookie.indexOf('=') + 1).split('.');
  const edited = { ...JSON.parse(Buffer.from(payload, 'base64url').toString()), sub: 1 };
  const forged = 'shelf_session=' + Buffer.from(JSON.stringify(edited)).toString('base64url') + '.' + signature;
  const page = await get('/notes', forged);
  assert.deepEqual([page.status, pathOf(page)], [307, '/login']);
  assert.equal((await get('/api/notes', forged)).status, 401);
});

test('a wrong password goes back to the login form with a message', async () => {
  const res = await submit('/login', { email: 'tess@example.com', password: 'wrong horse' });
  assert.deepEqual([res.status, res.headers.get('location'), res.headers.getSetCookie().length], [303, '/login?error=Wrong%20email%20or%20password.', 0]);
});

test('the right password signs in again', async () => {
  const res = await submit('/login', { email: 'tess@example.com', password: 'correct horse' });
  assert.deepEqual([res.status, pathOf(res), cookieOf(res).startsWith('shelf_session=')], [303, '/notes', true]);
});

test('signing out sends an expired cookie and goes to the login page', async () => {
  const res = await submit('/notes', {}, { cookie, form: 1 });
  assert.deepEqual([res.status, pathOf(res)], [303, '/login']);
  assert.ok(res.headers.getSetCookie()[0].startsWith('shelf_session=;'));
  assert.ok(res.headers.getSetCookie()[0].includes('1970'));
});

What the tests printed (Next 16.3.1, node:test + next start), when the build ran them

node http-tests.mjs

ok   notes without a cookie redirect to the login page
ok   signing up sets the session cookie and goes to the notes page
ok   signing up again with the same email is refused
ok   the notes page says who is signed in
ok   a note is added and listed
ok   an empty note is refused with a message
ok   the route handler lists the notes for the session and refuses without one
ok   a cookie whose payload was edited to another user is refused
ok   a wrong password goes back to the login form with a message
ok   the right password signs in again
ok   signing out sends an expired cookie and goes to the login page

The tests ran under node against next start -H 127.0.0.1 -p 0 of project proj, right after its next build; BASE_URL is the address Next printed. The reporter is the course's own, tools/test-reporter.mjs: one line per test, with no durations. Run node --test yourself to see Node's usual report.

These tests are the safety net for changes: change the store to talk to a database, rewrite the session module, move a page, and run them again. What they do not cover is anything that needs a browser, such as the forms with JavaScript on, the pending states, and how the page looks. Those need the end-to-end tools lesson 28 listed.

What this app is not

Shelf is a lesson, not a service. Its data is one JSON file that two servers could corrupt, its sessions cannot be revoked, there is no password reset and no limit on login attempts, and the forms were only exercised with JavaScript off. All of that is outside what this course can run, and none of it is hidden: the code you saw is the whole app. What it does show is the shape that stays when the pieces are replaced: a thin layer of Next.js (routes, actions, the proxy) around plain functions that you can test, over a store you can swap.

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.