Appearance
User model
A "user model" here isn't a class hierarchy - it's a table + helpers: create user, find by email, verify password. Never store plaintext passwords. Ever.
Schema
sql
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
email_verified_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);We'll add token tables for verify / reset in later chapters. Start simple.
Hashing with bcrypt
bash
npm install bcryptjs
npm install -D @types/bcryptjs(bcryptjs is pure JS - fewer native build headaches than bcrypt while learning.)
ts
import bcrypt from 'bcryptjs'
const ROUNDS = 10
export async function hashPassword(plain: string) {
return bcrypt.hash(plain, ROUNDS)
}
export async function verifyPassword(plain: string, passwordHash: string) {
return bcrypt.compare(plain, passwordHash)
}Comments for the code above:
- Hash is one-way - you can't "decrypt" a password
compareis timing-safe enough for this purpose- Cost factor
10is a reasonable default for tutorials; production may tune higher
Helpers
ts
export type User = {
id: number
email: string
password_hash: string
email_verified_at: string | null
created_at: string
}
export async function createUser(email: string, password: string) {
const password_hash = await hashPassword(password)
try {
const info = db
.prepare('INSERT INTO users (email, password_hash) VALUES (?, ?)')
.run(email.toLowerCase(), password_hash)
return findUserById(Number(info.lastInsertRowid))
} catch (err: any) {
if (String(err?.message).includes('UNIQUE')) {
throw new HttpError(409, 'email already registered')
}
throw err
}
}
export function findUserByEmail(email: string) {
return db
.prepare('SELECT * FROM users WHERE email = ?')
.get(email.toLowerCase()) as User | undefined
}
export function findUserById(id: number) {
return db.prepare('SELECT * FROM users WHERE id = ?').get(id) as User | undefined
}
export function publicUser(user: User) {
return {
id: user.id,
email: user.email,
emailVerified: Boolean(user.email_verified_at),
}
}Never send password_hash to the client. publicUser is the API shape.
FE analogy
ts
// what you might keep in a Pinia/React store after login
{ id: 1, email: 'ada@example.com', emailVerified: false }The hash stays in the DB. The SPA only sees the public profile (+ a JWT separately).
Password rules (minimal)
Even for a tutorial, reject empty / tiny passwords at the edge:
ts
if (password.length < 8) throw new HttpError(400, 'password too short')Zod can own this later in Security basics.
What we haven't done yet
- Login endpoint
- JWT issuance
- Email verification columns in use
- Reset tokens
That's the next chapter - register & login against this table.
The companion app mirrors these helpers in examples/notes-api/src/db.ts.
Best,
-- ł.