10 · Project — A REST API with Auth¶
Time to assemble Level 2 into one service you could actually deploy: a multi-user tasks API. Users register and log in; each user can create, list (with filtering and cursor pagination), update, and delete their own tasks. Every piece comes from a lesson in this level, and the whole thing is covered by an integration test suite that runs against a real Postgres engine.
Requirements¶
POST /auth/register,POST /auth/login→ JWT access token (15 minutes).GET /tasks?done=&limit=&cursor=,POST /tasks,GET/PATCH/DELETE /tasks/:id, all requiringAuthorization: Bearer <token>.- Users can never read or modify another user's tasks (404, not 403).
- Validation errors → 400 with per-field
details; malformed JSON → 400; duplicates → 409; unknown routes → 404 JSON; bugs → 500 without internals. - Structured request logs with a propagated
x-request-id. - Configuration validated at startup; graceful shutdown on
SIGTERM.
Layout¶
tasks-api/
package.json
.env.example
src/
config.js # Zod-validated env (lesson 04)
db.js # Kysely + pg pool, schema bootstrap (lesson 06)
errors.js # HttpError + error middleware (lesson 05)
validate.js # validation middleware (lesson 04)
auth/password.js # scrypt hashing (lesson 07)
auth/tokens.js # JWT sign + requireAuth (lesson 07)
routes/auth.js
routes/tasks.js # REST + cursor pagination (lesson 03)
app.js # createApp factory (lessons 01-02, 09)
server.js # entry point: config, db, listen, shutdown
test/
api.test.js # Supertest integration tests (lesson 08)
pglite-pool.js # test-only in-process Postgres adapter
package.json¶
{
"name": "tasks-api",
"version": "1.0.0",
"private": true,
"type": "module",
"engines": { "node": ">=22" },
"scripts": {
"start": "node src/server.js",
"dev": "node --watch --env-file=.env src/server.js",
"test": "node --test 'test/*.test.js'"
},
"dependencies": {
"express": "^5.1.0",
"jsonwebtoken": "^9.0.2",
"kysely": "^0.29.0",
"pg": "^8.16.0",
"pino": "^10.0.0",
"pino-http": "^11.0.0",
"zod": "^4.0.0"
},
"devDependencies": {
"@electric-sql/pglite": "^0.5.0",
"supertest": "^7.1.0"
}
}
PORT=3000
DATABASE_URL=postgres://tasks:devpass@localhost:5432/tasks
JWT_SECRET=replace-with-at-least-32-random-characters
LOG_LEVEL=debug
Configuration and database¶
import { z } from 'zod';
const schema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.coerce.number().int().positive().default(3000),
DATABASE_URL: z.url(),
JWT_SECRET: z.string().min(32, 'JWT_SECRET must be at least 32 characters'),
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace', 'silent']).default('info'),
});
export function loadConfig(env = process.env) {
const parsed = schema.safeParse(env);
if (!parsed.success) {
const problems = parsed.error.issues.map(i => ` ${i.path.join('.')}: ${i.message}`).join('\n');
throw new Error(`Invalid configuration:\n${problems}`);
}
return Object.freeze(parsed.data);
}
import { Kysely, PostgresDialect, sql } from 'kysely';
import pg from 'pg';
export function createDb({ connectionString, pool } = {}) {
return new Kysely({
dialect: new PostgresDialect({ pool: pool ?? new pg.Pool({ connectionString, max: 10 }) }),
});
}
export async function migrate(db) {
await db.schema.createTable('users').ifNotExists()
.addColumn('id', 'serial', c => c.primaryKey())
.addColumn('email', 'text', c => c.notNull().unique())
.addColumn('password_hash', 'text', c => c.notNull())
.addColumn('created_at', 'timestamptz', c => c.notNull().defaultTo(sql`now()`))
.execute();
await db.schema.createTable('tasks').ifNotExists()
.addColumn('id', 'serial', c => c.primaryKey())
.addColumn('user_id', 'integer', c => c.notNull().references('users.id').onDelete('cascade'))
.addColumn('title', 'text', c => c.notNull())
.addColumn('done', 'boolean', c => c.notNull().defaultTo(false))
.addColumn('created_at', 'timestamptz', c => c.notNull().defaultTo(sql`now()`))
.execute();
await db.schema.createIndex('tasks_user_id_idx').ifNotExists().on('tasks').column('user_id').execute();
}
migrate() here is a simple idempotent bootstrap (ifNotExists) to keep the project
compact. Once the schema starts evolving, switch to versioned migrations with Kysely's
Migrator as shown in lesson 06.
Errors and validation¶
export class HttpError extends Error {
constructor(status, message, details) {
super(message);
this.name = 'HttpError';
this.status = status;
this.details = details;
}
}
export const badRequest = (msg, details) => new HttpError(400, msg, details);
export const unauthorized = (msg = 'authentication required') => new HttpError(401, msg);
export const notFound = (msg = 'not found') => new HttpError(404, msg);
export const conflict = (msg) => new HttpError(409, msg);
// Express 5: registered last, with four parameters
export function errorHandler(err, req, res, next) {
if (res.headersSent) return next(err);
// body-parser errors (malformed JSON, too large) carry a status and `expose`
if (err.type === 'entity.parse.failed') err = badRequest('malformed JSON body');
const status = err.status ?? 500;
if (status >= 500) req.log.error({ err }, 'unhandled error');
else req.log.warn({ status, msg: err.message }, 'request failed');
res.status(status).json({
error: status >= 500 ? 'internal server error' : err.message,
...(err.details && { details: err.details }),
});
}
import { badRequest } from './errors.js';
// validate({ body: schema, query: schema, params: schema })
export function validate(schemas) {
return (req, res, next) => {
for (const [part, schema] of Object.entries(schemas)) {
const result = schema.safeParse(req[part]);
if (!result.success) {
const details = result.error.issues.map(i => ({ path: [part, ...i.path].join('.'), message: i.message }));
return next(badRequest('validation failed', details));
}
// Express 5 makes req.query a getter, so store parsed values separately
req.valid ??= {};
req.valid[part] = result.data;
}
next();
};
}
Authentication¶
import { scrypt, randomBytes, timingSafeEqual } from 'node:crypto';
import { promisify } from 'node:util';
const scryptAsync = promisify(scrypt);
const KEYLEN = 64;
const PARAMS = { N: 16384, r: 8, p: 1 }; // cost parameters, stored with each hash
export async function hashPassword(password) {
const salt = randomBytes(16);
const key = await scryptAsync(password, salt, KEYLEN, PARAMS);
return `scrypt$${PARAMS.N}$${PARAMS.r}$${PARAMS.p}$${salt.toString('base64')}$${key.toString('base64')}`;
}
export async function verifyPassword(password, stored) {
const [algo, N, r, p, saltB64, keyB64] = stored.split('$');
if (algo !== 'scrypt') return false;
const expected = Buffer.from(keyB64, 'base64');
const actual = await scryptAsync(password, Buffer.from(saltB64, 'base64'), expected.length,
{ N: Number(N), r: Number(r), p: Number(p) });
return timingSafeEqual(actual, expected);
}
import jwt from 'jsonwebtoken';
import { unauthorized } from '../errors.js';
export function signToken(user, secret) {
return jwt.sign({ sub: String(user.id), email: user.email }, secret, {
algorithm: 'HS256',
expiresIn: '15m',
});
}
export function requireAuth(secret) {
return (req, res, next) => {
const header = req.get('authorization') ?? '';
const [scheme, token] = header.split(' ');
if (scheme !== 'Bearer' || !token) return next(unauthorized());
try {
const payload = jwt.verify(token, secret, { algorithms: ['HS256'] });
req.user = { id: Number(payload.sub), email: payload.email };
next();
} catch {
next(unauthorized('invalid or expired token'));
}
};
}
import { Router } from 'express';
import { z } from 'zod';
import { validate } from '../validate.js';
import { conflict, unauthorized } from '../errors.js';
import { hashPassword, verifyPassword } from '../auth/password.js';
import { signToken } from '../auth/tokens.js';
const credentials = z.object({
email: z.email().transform(e => e.toLowerCase()),
password: z.string().min(12, 'password must be at least 12 characters').max(200),
});
export function authRouter({ db, config }) {
const router = Router();
router.post('/register', validate({ body: credentials }), async (req, res) => {
const { email, password } = req.valid.body;
const password_hash = await hashPassword(password);
try {
const user = await db.insertInto('users').values({ email, password_hash })
.returning(['id', 'email']).executeTakeFirstOrThrow();
res.status(201).json({ user, token: signToken(user, config.JWT_SECRET) });
} catch (err) {
if (err.code === '23505') throw conflict('email already registered');
throw err;
}
});
router.post('/login', validate({ body: credentials }), async (req, res) => {
const { email, password } = req.valid.body;
const user = await db.selectFrom('users').select(['id', 'email', 'password_hash'])
.where('email', '=', email).executeTakeFirst();
// Same error for unknown email and wrong password: don't reveal which accounts exist
if (!user || !(await verifyPassword(password, user.password_hash))) {
throw unauthorized('invalid email or password');
}
res.json({ token: signToken(user, config.JWT_SECRET) });
});
return router;
}
Tasks¶
import { Router } from 'express';
import { z } from 'zod';
import { validate } from '../validate.js';
import { notFound } from '../errors.js';
const idParams = z.object({ id: z.coerce.number().int().positive() });
const createBody = z.object({ title: z.string().trim().min(1).max(200) });
const updateBody = z.object({
title: z.string().trim().min(1).max(200).optional(),
done: z.boolean().optional(),
}).refine(b => Object.keys(b).length > 0, 'provide at least one field');
const listQuery = z.object({
done: z.enum(['true', 'false']).transform(v => v === 'true').optional(),
limit: z.coerce.number().int().min(1).max(100).default(20),
cursor: z.coerce.number().int().positive().optional(),
});
const columns = ['id', 'title', 'done', 'created_at'];
export function tasksRouter({ db }) {
const router = Router();
router.get('/', validate({ query: listQuery }), async (req, res) => {
const { done, limit, cursor } = req.valid.query;
let q = db.selectFrom('tasks').select(columns)
.where('user_id', '=', req.user.id)
.orderBy('id').limit(limit + 1);
if (done !== undefined) q = q.where('done', '=', done);
if (cursor) q = q.where('id', '>', cursor);
const rows = await q.execute();
const hasMore = rows.length > limit;
const data = rows.slice(0, limit);
res.json({ data, nextCursor: hasMore ? data.at(-1).id : null });
});
router.post('/', validate({ body: createBody }), async (req, res) => {
const task = await db.insertInto('tasks')
.values({ user_id: req.user.id, title: req.valid.body.title })
.returning(columns).executeTakeFirstOrThrow();
res.status(201).location(`${req.baseUrl}/${task.id}`).json(task);
});
router.get('/:id', validate({ params: idParams }), async (req, res) => {
const task = await db.selectFrom('tasks').select(columns)
.where('id', '=', req.valid.params.id).where('user_id', '=', req.user.id)
.executeTakeFirst();
if (!task) throw notFound('task not found');
res.json(task);
});
router.patch('/:id', validate({ params: idParams, body: updateBody }), async (req, res) => {
const task = await db.updateTable('tasks').set(req.valid.body)
.where('id', '=', req.valid.params.id).where('user_id', '=', req.user.id)
.returning(columns).executeTakeFirst();
if (!task) throw notFound('task not found');
res.json(task);
});
router.delete('/:id', validate({ params: idParams }), async (req, res) => {
const result = await db.deleteFrom('tasks')
.where('id', '=', req.valid.params.id).where('user_id', '=', req.user.id)
.executeTakeFirst();
if (result.numDeletedRows === 0n) throw notFound('task not found');
res.status(204).end();
});
return router;
}
Every query includes where('user_id', '=', req.user.id). Authorization is enforced in
the data access itself, so there is no code path that loads a task and forgets to check
its owner.
Wiring¶
import express from 'express';
import { pinoHttp } from 'pino-http';
import { randomUUID } from 'node:crypto';
import { authRouter } from './routes/auth.js';
import { tasksRouter } from './routes/tasks.js';
import { requireAuth } from './auth/tokens.js';
import { errorHandler, notFound } from './errors.js';
export function createApp({ db, config, logger }) {
const app = express();
app.disable('x-powered-by');
app.use(pinoHttp({
logger,
genReqId: (req, res) => {
const id = req.get('x-request-id') ?? randomUUID();
res.setHeader('x-request-id', id);
return id;
},
}));
app.use(express.json({ limit: '100kb' }));
app.get('/health', (req, res) => res.json({ status: 'ok' }));
app.use('/auth', authRouter({ db, config }));
app.use('/tasks', requireAuth(config.JWT_SECRET), tasksRouter({ db }));
app.use((req, res, next) => next(notFound(`no route for ${req.method} ${req.path}`)));
app.use(errorHandler);
return app;
}
import pino from 'pino';
import { loadConfig } from './config.js';
import { createDb, migrate } from './db.js';
import { createApp } from './app.js';
const config = loadConfig();
const logger = pino({ level: config.LOG_LEVEL });
const db = createDb({ connectionString: config.DATABASE_URL });
await migrate(db);
const server = createApp({ db, config, logger }).listen(config.PORT, () => {
logger.info({ port: config.PORT }, 'server listening');
});
async function shutdown(signal) {
logger.info({ signal }, 'shutting down');
server.close(async () => {
await db.destroy();
process.exit(0);
});
setTimeout(() => process.exit(1), 10_000).unref();
}
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
Tests¶
import { describe, test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import request from 'supertest';
import pino from 'pino';
import { createDb, migrate } from '../src/db.js';
import { createApp } from '../src/app.js';
import { loadConfig } from '../src/config.js';
import { pglitePool } from './pglite-pool.js';
const config = loadConfig({
NODE_ENV: 'test',
DATABASE_URL: 'postgres://unused/test',
JWT_SECRET: 'test-secret-that-is-at-least-32-characters',
});
let db, app;
before(async () => {
db = createDb({ pool: pglitePool() }); // in-process Postgres for tests
await migrate(db);
app = createApp({ db, config, logger: pino({ level: 'silent' }) });
});
after(() => db.destroy());
async function registerAndLogin(email) {
const res = await request(app).post('/auth/register')
.send({ email, password: 'correct horse battery' }).expect(201);
return res.body.token;
}
describe('auth', () => {
test('register, then login with the same credentials', async () => {
await registerAndLogin('Ada@Example.com');
const res = await request(app).post('/auth/login')
.send({ email: 'ada@example.com', password: 'correct horse battery' }).expect(200);
assert.match(res.body.token, /^[\w-]+\.[\w-]+\.[\w-]+$/);
});
test('duplicate email is 409', async () => {
await request(app).post('/auth/register')
.send({ email: 'ada@example.com', password: 'correct horse battery' }).expect(409);
});
test('wrong password is 401 with a generic message', async () => {
const res = await request(app).post('/auth/login')
.send({ email: 'ada@example.com', password: 'wrong password!!' }).expect(401);
assert.equal(res.body.error, 'invalid email or password');
});
test('short password is rejected with field details', async () => {
const res = await request(app).post('/auth/register')
.send({ email: 'bob@example.com', password: 'short' }).expect(400);
assert.equal(res.body.details[0].path, 'body.password');
});
});
describe('tasks', () => {
let token;
before(async () => { token = await registerAndLogin('tasks@example.com'); });
const auth = () => ({ Authorization: `Bearer ${token}` });
test('requires a token', async () => {
await request(app).get('/tasks').expect(401);
});
test('create, read, update, delete', async () => {
const created = await request(app).post('/tasks').set(auth())
.send({ title: ' write tests ' }).expect(201);
assert.equal(created.body.title, 'write tests');
assert.equal(created.headers.location, `/tasks/${created.body.id}`);
const patched = await request(app).patch(`/tasks/${created.body.id}`).set(auth())
.send({ done: true }).expect(200);
assert.equal(patched.body.done, true);
await request(app).delete(`/tasks/${created.body.id}`).set(auth()).expect(204);
await request(app).get(`/tasks/${created.body.id}`).set(auth()).expect(404);
});
test('cursor pagination', async () => {
for (const title of ['a', 'b', 'c']) {
await request(app).post('/tasks').set(auth()).send({ title }).expect(201);
}
const page1 = await request(app).get('/tasks?limit=2').set(auth()).expect(200);
assert.deepEqual(page1.body.data.map(t => t.title), ['a', 'b']);
const page2 = await request(app).get(`/tasks?limit=2&cursor=${page1.body.nextCursor}`).set(auth()).expect(200);
assert.deepEqual(page2.body.data.map(t => t.title), ['c']);
assert.equal(page2.body.nextCursor, null);
});
test("users cannot see each other's tasks", async () => {
const mine = await request(app).post('/tasks').set(auth()).send({ title: 'private' }).expect(201);
const other = await registerAndLogin('mallory@example.com');
await request(app).get(`/tasks/${mine.body.id}`)
.set({ Authorization: `Bearer ${other}` }).expect(404);
});
test('malformed JSON is a 400, not a 500', async () => {
await request(app).post('/tasks').set(auth())
.set('content-type', 'application/json').send('{"title":').expect(400);
});
});
$ npm test
▶ auth
✔ register, then login with the same credentials
✔ duplicate email is 409
✔ wrong password is 401 with a generic message
✔ short password is rejected with field details
▶ tasks
✔ requires a token
✔ create, read, update, delete
✔ cursor pagination
✔ users cannot see each other's tasks
✔ malformed JSON is a 400, not a 500
ℹ tests 9
ℹ pass 9
ℹ fail 0
Trying it by hand¶
With a local Postgres running and .env filled in, npm run dev starts the server.
(For the session below, the same createApp was started on port 3100 with the
in-process PGlite pool instead of a Postgres server.)
$ curl -X POST localhost:3100/auth/register -H 'content-type: application/json' \
-d '{"email":"ada@example.com","password":"correct horse battery"}'
{"user":{"id":1,"email":"ada@example.com"},"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...."}
$ curl -i -X POST localhost:3100/tasks -H "authorization: Bearer $TOKEN" \
-H 'content-type: application/json' -d '{"title":"ship level 2"}'
HTTP/1.1 201 Created
x-request-id: fbd5e9c7-bfc5-4f94-8a6f-bffda9222838
Location: /tasks/1
{"id":1,"title":"ship level 2","done":false,"created_at":"2026-09-26T15:08:16.921Z"}
$ curl "localhost:3100/tasks?done=false" -H "authorization: Bearer $TOKEN"
{"data":[{"id":1,"title":"ship level 2","done":false,"created_at":"2026-09-26T15:08:16.921Z"}],"nextCursor":null}
$ curl -X PATCH localhost:3100/tasks/1 -H "authorization: Bearer $TOKEN" \
-H 'content-type: application/json' -d '{}'
{"error":"validation failed","details":[{"path":"body","message":"provide at least one field"}]}
$ curl localhost:3100/tasks
{"error":"authentication required"}
How It Actually Works¶
Follow one request — PATCH /tasks/1 with a valid token — through the stack:
- node:http parses the request line and headers (llhttp) and calls the Express app.
- pino-http assigns the request id, attaches
req.log, and registers a'finish'listener for the completion log line. - express.json streams the body, enforces the 100 KB limit, and parses JSON into
req.body(or raisesentity.parse.failed). /healthand/authdon't match, so the router reachesapp.use('/tasks', ...): requireAuth verifies the HMAC signature and expiry of the JWT and setsreq.user.- The tasks router strips
/tasksfrom the path, matchesPATCH /:id, and runs validate, which coercesidto a number and parses the body. - The handler awaits Kysely, which compiles an
UPDATE ... WHERE id = $1 AND user_id = $2 RETURNING ..., borrows a pooled connection, and sends it using the extended query protocol. The event loop serves other requests meanwhile. - The row comes back;
res.jsonserializes it; the'finish'event fires and pino-http writes the completion line with status and duration.
If anything throws in steps 4–6, Express 5 routes the error to errorHandler, which maps
it to a status and logs with the same request id.
Common mistakes in projects like this¶
- Checking ownership in some handlers and forgetting it in others. Put it in the query.
- Returning
password_hashby selecting*fromusers. Select explicit columns. - A test suite that shares one user across all tests, so order matters.
- A
JWT_SECRETdefault in code "for development" that reaches production. - No limit on list endpoints.
Exercise¶
Extend the project; each item should come with tests.
- Projects: add a
projectstable; tasks optionally belong to a project the user owns. Creating a task in someone else's project must fail with 404. - Refresh tokens: implement the refresh flow from lesson 07, including logout that revokes the refresh token.
- Rate limiting: limit
/auth/loginto 5 attempts per minute per IP and email. - Search:
GET /tasks?q=milkusing PostgresILIKE, with the search term passed as a parameter, and a test proving%and_in the search term are matched literally. - Real Postgres in CI: run the same tests against a Postgres service container by choosing the pool from an environment variable.