kodingup.camp

Beranda/Node.js Backend: Database, Auth, Keamanan & Deploy

Proyek Akhir Fase 5

Modul 10 dari 106 menit bacaREST API Toko Online

Isi modul

    Tujuan

    Di proyek ini kamu merangkai semua yang sudah dipelajari di Fase 5 menjadi satu backend toko online yang siap dipakai frontend React dari Fase 3. Pembeli bisa mendaftar, login, melihat katalog, dan checkout. Admin bisa mengelola produk dan mengunggah foto produk. Semuanya dijaga validasi, aturan akses, dan lapisan keamanan, diuji otomatis, dan siap di-deploy.

    Proyek ini sengaja dibuat tanpa fitur yang mengaburkan inti pelajaran (pembayaran sungguhan, ongkir, email). Fokusnya: struktur proyek yang rapi, data yang benar (transaksi, harga dari server), dan keamanan yang tidak bolong. Kerangka di bawah ini sudah dijalankan sungguhan: npm test lolos 11 tes di Node 26.0.0 maupun Node 24.22.1 (LTS), dan servernya diuji dengan curl serta lewat proxy dev server Vite 8.3.5.

    Peta proyek:

    toko-api/
    ├── drizzle/                 # file migrasi hasil drizzle-kit (di-commit)
    ├── scripts/smoke.js         # cek cepat setelah deploy
    ├── src/
    │   ├── config.js            # baca + validasi environment variable
    │   ├── errors.js            # HttpError
    │   ├── upload.js            # multer + cek magic bytes
    │   ├── app.js               # merakit Express (tanpa listen)
    │   ├── server.js            # listen + graceful shutdown
    │   ├── seed-admin.js        # buat akun admin pertama
    │   ├── db/schema.js         # tabel + relasi Drizzle
    │   ├── db/index.js          # koneksi + migrasi
    │   ├── middleware/auth.js   # JWT cookie, requireAuth, requireRole
    │   ├── middleware/validate.js
    │   └── routes/auth.js | products.js | orders.js
    ├── test/api.test.js         # Vitest + Supertest
    ├── uploads/                 # gambar produk (tidak di-commit)
    ├── .env.example  .gitignore  .dockerignore  Dockerfile  ecosystem.config.cjs
    ├── drizzle.config.js
    └── package.json
    

    Daftar endpoint:

    Method Path Akses Keterangan
    GET /health publik Cek server + database
    POST /api/auth/register publik (rate limit) Daftar, langsung login
    POST /api/auth/login publik (rate limit) Login, pasang cookie httpOnly
    POST /api/auth/logout publik Hapus cookie
    GET /api/auth/me login Data user yang sedang login
    GET /api/products?q=&page=&limit= publik Katalog + pencarian + pagination
    GET /api/products/:id publik Detail produk
    POST /api/products admin Buat produk
    PATCH /api/products/:id admin Ubah sebagian field
    DELETE /api/products/:id admin Hapus produk
    POST /api/products/:id/image admin Upload gambar (field image, maks 2 MB)
    POST /api/orders login Checkout (transaksi, kurangi stok)
    GET /api/orders login Pesanan milik sendiri (admin: semua)
    GET /api/orders/:id login Detail pesanan (pemilik atau admin)

    Fitur wajib

    1. Database SQLite lewat Drizzle ORM dengan migrasi (drizzle-kit generate) yang dijalankan otomatis saat start. Tabel users, products, orders, order_items dengan foreign key dan foreign_keys = ON.
    2. Autentikasi: register dan login dengan hash argon2id, JWT (jose, HS256, 2 jam) di cookie httpOnly + sameSite: 'lax' + secure di produksi. Pesan error login yang sama untuk email dan password salah.
    3. Otorisasi: requireAuth (401), requireRole('admin') (403), dan cek kepemilikan pesanan (404 untuk pesanan orang lain). role tidak pernah diambil dari input.
    4. Produk: katalog publik dengan pencarian dan pagination tervalidasi, CRUD khusus admin.
    5. Upload gambar produk: multer diskStorage, batas 2 MB, filter mimetype, cek magic bytes, nama file UUID buatan server, disajikan lewat /uploads.
    6. Checkout dalam transaksi: harga dan total dihitung dari database, stok dicek dan dikurangi dalam satu UPDATE, rollback kalau ada item gagal.
    7. Validasi Zod untuk body, params, query, dan environment variable.
    8. Keamanan: helmet, CORS dengan daftar origin dari env, rate limit di login/register, batas body 100 KB, error handler tanpa stack trace.
    9. Logging: pino + pino-http dengan request id dan redaksi cookie.
    10. Tes Vitest + Supertest dengan database in-memory per tes, mencakup jalur sukses dan gagal.
    11. Siap deploy: script start, graceful shutdown, /health (503 kalau database mati), Dockerfile, konfigurasi PM2.
    12. Tersambung ke frontend React lewat proxy Vite di development dan CORS + cookie di produksi.

    Langkah bertahap

    1. Siapkan proyek (Node 24 LTS atau lebih baru):
    mkdir toko-api && cd toko-api
    npm init -y && npm pkg set type=module
    npm install express@5.3.0 drizzle-orm@0.45.4 better-sqlite3@13.0.3 zod@4.6.7 jose@6.2.13 \
      argon2@0.45.1 helmet@8.3.0 cors@2.8.6 express-rate-limit@8.7.1 multer@2.5.0 \
      pino@10.4.0 pino-http@11.0.0 cookie-parser@1.4.7
    npm install -D drizzle-kit@0.31.11 vitest@5.0.5 supertest@7.3.1
    mkdir -p src/db src/middleware src/routes test uploads scripts && touch uploads/.gitkeep
    
    1. Konfigurasi: buat src/config.js, .env.example, .gitignore, lalu salin .env.example ke .env dan isi JWT_SECRET dengan string acak.
    2. Database: tulis src/db/schema.js, drizzle.config.js, jalankan npx drizzle-kit generate --name=init, baca SQL-nya, lalu tulis src/db/index.js.
    3. Auth: src/middleware/auth.js, src/middleware/validate.js, src/routes/auth.js. Uji register/login dengan curl -c dan curl -b.
    4. Produk + upload: src/upload.js, src/routes/products.js. Buat admin dengan npm run seed:admin -- admin@toko.id admin12345, lalu uji CRUD dan upload.
    5. Pesanan: src/errors.js, src/routes/orders.js. Uji checkout, stok kurang, dan pesanan milik orang lain.
    6. Rakit: src/app.js dan src/server.js. Jalankan npm run dev.
    7. Tes: test/api.test.js, jalankan npm test sampai hijau semua. Setiap menemukan bug lewat curl, tambahkan tesnya dulu, baru perbaiki.
    8. Sambungkan frontend React (lihat bagian Kode kerangka paling bawah).
    9. Deploy: pilih satu jalur dari Modul 9 (PaaS dengan volume, Docker, atau VPS + PM2/systemd + Nginx + certbot), set environment variable produksi, jalankan scripts/smoke.js ke URL publikmu.

    Kode kerangka

    package.json

    {
      "name": "toko-api",
      "version": "1.0.0",
      "scripts": {
        "dev": "node --watch --env-file-if-exists=.env src/server.js",
        "start": "node src/server.js",
        "db:generate": "drizzle-kit generate",
        "seed:admin": "node --env-file-if-exists=.env src/seed-admin.js",
        "test": "vitest run"
      },
      "type": "module",
      "dependencies": {
        "argon2": "^0.45.1",
        "better-sqlite3": "^13.0.3",
        "cookie-parser": "^1.4.7",
        "cors": "^2.8.6",
        "drizzle-orm": "^0.45.4",
        "express": "^5.3.0",
        "express-rate-limit": "^8.7.1",
        "helmet": "^8.3.0",
        "jose": "^6.2.13",
        "multer": "^2.5.0",
        "pino": "^10.4.0",
        "pino-http": "^11.0.0",
        "zod": "^4.6.7"
      },
      "devDependencies": {
        "drizzle-kit": "^0.31.11",
        "supertest": "^7.3.1",
        "vitest": "^5.0.5"
      },
      "engines": {
        "node": ">=24"
      },
      "private": true
    }
    

    .env.example dan .gitignore

    # .env.example
    NODE_ENV=development
    PORT=3000
    DATABASE_FILE=toko.db
    # buat dengan: node -e "console.log(require('node:crypto').randomBytes(48).toString('base64url'))"
    JWT_SECRET=ganti-dengan-string-acak-minimal-32-karakter
    CORS_ORIGIN=http://localhost:5173
    UPLOAD_DIR=uploads
    LOG_LEVEL=info
    AUTH_RATE_LIMIT=10
    
    # .gitignore
    node_modules
    .env
    *.db
    *.db-*
    uploads/*
    !uploads/.gitkeep
    

    drizzle.config.js

    import { defineConfig } from 'drizzle-kit';
    
    export default defineConfig({
      dialect: 'sqlite',
      schema: './src/db/schema.js',
      out: './drizzle',
      dbCredentials: { url: process.env.DATABASE_FILE ?? 'toko.db' },
    });
    

    src/config.js

    // src/config.js, baca & validasi environment variable SEKALI saat start
    import { z } from 'zod';
    
    const EnvSchema = z.object({
      NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
      PORT: z.coerce.number().int().default(3000),
      DATABASE_FILE: z.string().default('toko.db'),
      JWT_SECRET: z.string().min(32, 'JWT_SECRET minimal 32 karakter'),
      CORS_ORIGIN: z.string().default('http://localhost:5173'),
      UPLOAD_DIR: z.string().default('uploads'),
      LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace', 'silent']).default('info'),
      AUTH_RATE_LIMIT: z.coerce.number().int().default(10),
    });
    
    export function loadConfig(env = process.env) {
      const hasil = EnvSchema.safeParse(env);
      if (!hasil.success) {
        console.error('Konfigurasi tidak valid:', z.flattenError(hasil.error).fieldErrors);
        process.exit(1); // gagal cepat: lebih baik mati saat start daripada error aneh di tengah jalan
      }
      const c = hasil.data;
      return { ...c, isProd: c.NODE_ENV === 'production', corsOrigins: c.CORS_ORIGIN.split(',') };
    }
    

    src/db/schema.js

    // src/db/schema.js, struktur tabel toko online
    import { sqliteTable, integer, text } from 'drizzle-orm/sqlite-core';
    import { relations, sql } from 'drizzle-orm';
    
    export const users = sqliteTable('users', {
      id: integer('id').primaryKey({ autoIncrement: true }),
      email: text('email').notNull().unique(),
      name: text('name').notNull(),
      passwordHash: text('password_hash').notNull(),
      role: text('role', { enum: ['user', 'admin'] }).notNull().default('user'),
      createdAt: text('created_at').notNull().default(sql`(CURRENT_TIMESTAMP)`),
    });
    
    export const products = sqliteTable('products', {
      id: integer('id').primaryKey({ autoIncrement: true }),
      name: text('name').notNull(),
      description: text('description').notNull().default(''),
      price: integer('price').notNull(), // dalam rupiah, integer (hindari float untuk uang)
      stock: integer('stock').notNull().default(0),
      imageUrl: text('image_url'),
    });
    
    export const orders = sqliteTable('orders', {
      id: integer('id').primaryKey({ autoIncrement: true }),
      userId: integer('user_id').notNull().references(() => users.id),
      total: integer('total').notNull(),
      status: text('status', { enum: ['pending', 'paid', 'shipped'] }).notNull().default('pending'),
      createdAt: text('created_at').notNull().default(sql`(CURRENT_TIMESTAMP)`),
    });
    
    export const orderItems = sqliteTable('order_items', {
      id: integer('id').primaryKey({ autoIncrement: true }),
      orderId: integer('order_id').notNull().references(() => orders.id, { onDelete: 'cascade' }),
      productId: integer('product_id').notNull().references(() => products.id),
      quantity: integer('quantity').notNull(),
      price: integer('price').notNull(), // harga SAAT dibeli, bukan harga sekarang
    });
    
    export const usersRelations = relations(users, ({ many }) => ({ orders: many(orders) }));
    export const ordersRelations = relations(orders, ({ one, many }) => ({
      user: one(users, { fields: [orders.userId], references: [users.id] }),
      items: many(orderItems),
    }));
    export const orderItemsRelations = relations(orderItems, ({ one }) => ({
      order: one(orders, { fields: [orderItems.orderId], references: [orders.id] }),
      product: one(products, { fields: [orderItems.productId], references: [products.id] }),
    }));
    

    src/db/index.js

    // src/db/index.js, buka koneksi + jalankan migrasi
    import Database from 'better-sqlite3';
    import { drizzle } from 'drizzle-orm/better-sqlite3';
    import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
    import * as schema from './schema.js';
    
    export function createDb(file) {
      const sqlite = new Database(file);           // ':memory:' untuk tes
      sqlite.pragma('journal_mode = WAL');         // baca & tulis bisa jalan bersamaan
      sqlite.pragma('foreign_keys = ON');          // SQLite TIDAK menegakkan foreign key kalau ini tidak dinyalakan
      const db = drizzle({ client: sqlite, schema });
      migrate(db, { migrationsFolder: 'drizzle' });
      return db;
    }
    

    src/errors.js dan src/middleware/validate.js

    // src/errors.js, error dengan status HTTP
    export class HttpError extends Error {
      constructor(status, message) { super(message); this.status = status; }
    }
    
    // src/middleware/validate.js, validasi body dengan Zod
    import { z } from 'zod';
    
    export const validate = (schema) => (req, res, next) => {
      const hasil = schema.safeParse(req.body);
      if (!hasil.success) {
        return res.status(400).json({ error: 'Input tidak valid', detail: z.flattenError(hasil.error).fieldErrors });
      }
      req.body = hasil.data; // pakai data yang sudah dibersihkan (field asing dibuang)
      next();
    };
    

    src/middleware/auth.js

    // src/middleware/auth.js, token JWT di cookie httpOnly
    import { SignJWT, jwtVerify } from 'jose';
    
    export const COOKIE_NAME = 'token';
    
    export function makeAuth(config) {
      const key = new TextEncoder().encode(config.JWT_SECRET);
    
      async function signToken(user) {
        return new SignJWT({ role: user.role })
          .setProtectedHeader({ alg: 'HS256' })
          .setSubject(String(user.id))   // "sub" = id user
          .setIssuedAt()
          .setExpirationTime('2h')
          .sign(key);
      }
    
      function setAuthCookie(res, token) {
        res.cookie(COOKIE_NAME, token, {
          httpOnly: true,               // JavaScript di browser tidak bisa membaca
          secure: config.isProd,        // hanya lewat HTTPS di produksi
          sameSite: 'lax',              // mengurangi risiko CSRF
          maxAge: 2 * 60 * 60 * 1000,   // 2 jam, samakan dengan exp token
          path: '/',
        });
      }
    
      // 401: "kamu siapa?", belum login / token rusak / kedaluwarsa
      async function requireAuth(req, res, next) {
        const token = req.cookies?.[COOKIE_NAME];
        if (!token) return res.status(401).json({ error: 'Belum login' });
        try {
          const { payload } = await jwtVerify(token, key, { algorithms: ['HS256'] });
          req.user = { id: Number(payload.sub), role: payload.role };
          next();
        } catch {
          return res.status(401).json({ error: 'Sesi tidak valid atau kedaluwarsa' });
        }
      }
    
      // 403: "aku tahu kamu siapa, tapi kamu tidak boleh"
      function requireRole(...roles) {
        return (req, res, next) => {
          if (!roles.includes(req.user?.role)) {
            return res.status(403).json({ error: 'Akses ditolak' });
          }
          next();
        };
      }
    
      return { signToken, setAuthCookie, requireAuth, requireRole };
    }
    

    src/upload.js

    // src/upload.js, konfigurasi multer untuk gambar produk
    import multer from 'multer';
    import crypto from 'node:crypto';
    import fs from 'node:fs/promises';
    
    const EKSTENSI = { 'image/jpeg': '.jpg', 'image/png': '.png', 'image/webp': '.webp' };
    
    export function makeUpload(uploadDir) {
      const storage = multer.diskStorage({
        destination: uploadDir,
        // nama file BUATAN SERVER, bukan nama dari user (hindari ../../ dan bentrok nama)
        filename: (req, file, cb) => cb(null, crypto.randomUUID() + EKSTENSI[file.mimetype]),
      });
      return multer({
        storage,
        limits: { fileSize: 2 * 1024 * 1024, files: 1 }, // maks 2 MB, 1 file
        fileFilter: (req, file, cb) => {
          if (EKSTENSI[file.mimetype]) return cb(null, true);
          const err = new multer.MulterError('LIMIT_UNEXPECTED_FILE', file.fieldname);
          err.message = 'Hanya JPG, PNG, atau WEBP';
          cb(err);
        },
      });
    }
    
    // mimetype dikirim browser = bisa dipalsukan. Cek "magic bytes" isi file yang asli.
    export async function isRealImage(path) {
      const fh = await fs.open(path);
      const { buffer } = await fh.read(Buffer.alloc(12), 0, 12, 0);
      await fh.close();
      const hex = buffer.toString('hex');
      return hex.startsWith('ffd8ff')                       // JPEG
        || hex.startsWith('89504e47')                       // PNG
        || (hex.startsWith('52494646') && hex.slice(16, 24) === '57454250'); // RIFF....WEBP
    }
    

    src/routes/auth.js

    // src/routes/auth.js, register, login, logout, me
    import { Router } from 'express';
    import argon2 from 'argon2';
    import { z } from 'zod';
    import { eq } from 'drizzle-orm';
    import { users } from '../db/schema.js';
    import { validate } from '../middleware/validate.js';
    import { COOKIE_NAME } from '../middleware/auth.js';
    
    const RegisterSchema = z.object({
      email: z.email().transform((e) => e.toLowerCase()),
      name: z.string().trim().min(2).max(60),
      password: z.string().min(8).max(128),
    });
    const LoginSchema = z.object({
      email: z.email().transform((e) => e.toLowerCase()),
      password: z.string().min(1).max(128),
    });
    
    // hash palsu untuk menyamakan waktu respons saat email tidak terdaftar
    const DUMMY_HASH = await argon2.hash('password-palsu-untuk-timing');
    
    const publicUser = (u) => ({ id: u.id, email: u.email, name: u.name, role: u.role });
    
    export function authRouter({ db, auth, authLimiter }) {
      const r = Router();
    
      r.post('/register', authLimiter, validate(RegisterSchema), async (req, res) => {
        const { email, name, password } = req.body;
        const ada = db.select({ id: users.id }).from(users).where(eq(users.email, email)).get();
        if (ada) return res.status(409).json({ error: 'Email sudah terdaftar' });
    
        const passwordHash = await argon2.hash(password); // argon2id, salt otomatis
        // role TIDAK diambil dari body: user tidak boleh mendaftarkan diri jadi admin
        const user = db.insert(users).values({ email, name, passwordHash }).returning().get();
        auth.setAuthCookie(res, await auth.signToken(user));
        res.status(201).json(publicUser(user));
      });
    
      r.post('/login', authLimiter, validate(LoginSchema), async (req, res) => {
        const { email, password } = req.body;
        const user = db.select().from(users).where(eq(users.email, email)).get();
        const cocok = await argon2.verify(user?.passwordHash ?? DUMMY_HASH, password);
        if (!user || !cocok) {
          // pesan SAMA untuk email salah & password salah
          return res.status(401).json({ error: 'Email atau password salah' });
        }
        auth.setAuthCookie(res, await auth.signToken(user));
        res.json(publicUser(user));
      });
    
      r.post('/logout', (req, res) => {
        res.clearCookie(COOKIE_NAME, { path: '/' });
        res.status(204).end();
      });
    
      r.get('/me', auth.requireAuth, (req, res) => {
        const user = db.select().from(users).where(eq(users.id, req.user.id)).get();
        if (!user) return res.status(401).json({ error: 'User tidak ada' });
        res.json(publicUser(user));
      });
    
      return r;
    }
    

    src/routes/products.js

    // src/routes/products.js, katalog publik + CRUD khusus admin
    import { Router } from 'express';
    import { z } from 'zod';
    import fs from 'node:fs/promises';
    import { eq, like, asc } from 'drizzle-orm';
    import { products } from '../db/schema.js';
    import { validate } from '../middleware/validate.js';
    import { isRealImage } from '../upload.js';
    
    const ProductSchema = z.object({
      name: z.string().trim().min(2).max(100),
      description: z.string().max(2000).default(''),
      price: z.number().int().positive(),
      stock: z.number().int().min(0).default(0),
    });
    const QuerySchema = z.object({
      q: z.string().max(50).optional(),
      page: z.coerce.number().int().min(1).default(1),
      limit: z.coerce.number().int().min(1).max(50).default(20),
    });
    const IdSchema = z.coerce.number().int().positive();
    
    export function productsRouter({ db, auth, upload }) {
      const r = Router();
      const adminOnly = [auth.requireAuth, auth.requireRole('admin')];
    
      r.get('/', (req, res) => {
        const parsed = QuerySchema.safeParse(req.query);
        if (!parsed.success) return res.status(400).json({ error: 'Query tidak valid' });
        const { q, page, limit } = parsed.data;
        const rows = db.select().from(products)
          .where(q ? like(products.name, `%${q}%`) : undefined) // tetap parameterized, aman
          .orderBy(asc(products.id))
          .limit(limit).offset((page - 1) * limit)
          .all();
        res.json({ data: rows, page, limit });
      });
    
      r.get('/:id', (req, res) => {
        const id = IdSchema.safeParse(req.params.id);
        if (!id.success) return res.status(400).json({ error: 'ID tidak valid' });
        const p = db.select().from(products).where(eq(products.id, id.data)).get();
        if (!p) return res.status(404).json({ error: 'Produk tidak ditemukan' });
        res.json(p);
      });
    
      r.post('/', ...adminOnly, validate(ProductSchema), (req, res) => {
        const p = db.insert(products).values(req.body).returning().get();
        res.status(201).json(p);
      });
    
      r.patch('/:id', ...adminOnly, validate(ProductSchema.partial()), (req, res) => {
        const id = IdSchema.safeParse(req.params.id);
        if (!id.success) return res.status(400).json({ error: 'ID tidak valid' });
        const p = db.update(products).set(req.body).where(eq(products.id, id.data)).returning().get();
        if (!p) return res.status(404).json({ error: 'Produk tidak ditemukan' });
        res.json(p);
      });
    
      r.delete('/:id', ...adminOnly, (req, res) => {
        const id = IdSchema.safeParse(req.params.id);
        if (!id.success) return res.status(400).json({ error: 'ID tidak valid' });
        const hasil = db.delete(products).where(eq(products.id, id.data)).run();
        if (hasil.changes === 0) return res.status(404).json({ error: 'Produk tidak ditemukan' });
        res.status(204).end();
      });
    
      r.post('/:id/image', ...adminOnly, upload.single('image'), async (req, res) => {
        if (!req.file) return res.status(400).json({ error: 'File gambar wajib (field "image")' });
        const id = IdSchema.safeParse(req.params.id);
        if (!id.success || !(await isRealImage(req.file.path))) {
          await fs.unlink(req.file.path); // buang file yang ditolak
          return res.status(400).json({ error: id.success ? 'Isi file bukan gambar' : 'ID tidak valid' });
        }
        const imageUrl = `/uploads/${req.file.filename}`;
        const p = db.update(products).set({ imageUrl }).where(eq(products.id, id.data)).returning().get();
        if (!p) {
          await fs.unlink(req.file.path);
          return res.status(404).json({ error: 'Produk tidak ditemukan' });
        }
        res.json(p);
      });
    
      return r;
    }
    

    src/routes/orders.js

    // src/routes/orders.js, checkout (transaksi) + riwayat pesanan
    import { Router } from 'express';
    import { z } from 'zod';
    import { eq, and, gte, sql, inArray, desc } from 'drizzle-orm';
    import { products, orders, orderItems } from '../db/schema.js';
    import { validate } from '../middleware/validate.js';
    import { HttpError } from '../errors.js';
    
    const OrderSchema = z.object({
      items: z.array(z.object({
        productId: z.number().int().positive(),
        quantity: z.number().int().min(1).max(100),
      })).min(1).max(50),
    });
    
    export function ordersRouter({ db, auth }) {
      const r = Router();
      r.use(auth.requireAuth); // semua endpoint pesanan wajib login
    
      r.post('/', validate(OrderSchema), (req, res) => {
        // better-sqlite3 = sinkron, jadi callback transaksi juga sinkron (tanpa async/await)
        const order = db.transaction((tx) => {
          const ids = req.body.items.map((i) => i.productId);
          const daftar = tx.select().from(products).where(inArray(products.id, ids)).all();
          const byId = new Map(daftar.map((p) => [p.id, p]));
    
          let total = 0;
          for (const item of req.body.items) {
            const p = byId.get(item.productId);
            if (!p) throw new HttpError(400, `Produk ${item.productId} tidak ada`);
            // kurangi stok HANYA kalau stoknya cukup (cek & kurangi dalam satu UPDATE)
            const upd = tx.update(products)
              .set({ stock: sql`${products.stock} - ${item.quantity}` })
              .where(and(eq(products.id, p.id), gte(products.stock, item.quantity)))
              .run();
            if (upd.changes === 0) throw new HttpError(409, `Stok ${p.name} tidak cukup`);
            total += p.price * item.quantity; // harga dari DB, BUKAN dari body request
          }
    
          const o = tx.insert(orders).values({ userId: req.user.id, total }).returning().get();
          tx.insert(orderItems).values(req.body.items.map((i) => ({
            orderId: o.id, productId: i.productId, quantity: i.quantity, price: byId.get(i.productId).price,
          }))).run();
          return o;
        }); // kalau ada throw di dalam → ROLLBACK otomatis, stok kembali seperti semula
        res.status(201).json(order);
      });
    
      r.get('/', (req, res) => {
        // admin melihat semua, user hanya miliknya sendiri
        const where = req.user.role === 'admin' ? undefined : eq(orders.userId, req.user.id);
        res.json(db.select().from(orders).where(where).orderBy(desc(orders.id)).all());
      });
    
      r.get('/:id', (req, res) => {
        const id = Number(req.params.id);
        const order = db.query.orders.findFirst({
          where: eq(orders.id, id),
          with: { items: { with: { product: { columns: { name: true } } } } },
        }).sync();
        // bukan milikmu → 404 (bukan 403) supaya tidak membocorkan bahwa pesanan itu ada
        if (!order || (order.userId !== req.user.id && req.user.role !== 'admin')) {
          return res.status(404).json({ error: 'Pesanan tidak ditemukan' });
        }
        res.json(order);
      });
    
      return r;
    }
    

    src/app.js

    // src/app.js, merakit aplikasi (TANPA listen, supaya bisa dites)
    import express from 'express';
    import helmet from 'helmet';
    import cors from 'cors';
    import cookieParser from 'cookie-parser';
    import { rateLimit } from 'express-rate-limit';
    import { pinoHttp } from 'pino-http';
    import pino from 'pino';
    import crypto from 'node:crypto';
    import multer from 'multer';
    import { makeAuth } from './middleware/auth.js';
    import { makeUpload } from './upload.js';
    import { authRouter } from './routes/auth.js';
    import { productsRouter } from './routes/products.js';
    import { ordersRouter } from './routes/orders.js';
    
    export function createApp({ db, config }) {
      const app = express();
      const logger = pino({ level: config.LOG_LEVEL, redact: ['req.headers.cookie', 'req.headers.authorization'] });
      const auth = makeAuth(config);
      const upload = makeUpload(config.UPLOAD_DIR);
      const authLimiter = rateLimit({
        windowMs: 15 * 60 * 1000, limit: config.AUTH_RATE_LIMIT,
        standardHeaders: 'draft-8', legacyHeaders: false,
        message: { error: 'Terlalu banyak percobaan, coba lagi nanti' },
      });
    
      app.disable('x-powered-by');
      if (config.isProd) app.set('trust proxy', 1); // di belakang Nginx: IP asli dari X-Forwarded-For
      app.use(pinoHttp({
        logger,
        genReqId: (req, res) => {
          const id = req.headers['x-request-id'] ?? crypto.randomUUID();
          res.setHeader('X-Request-Id', id);
          return id;
        },
      }));
      app.use(helmet({ crossOriginResourcePolicy: { policy: 'cross-origin' } })); // gambar boleh dipakai frontend di origin lain
      app.use(cors({ origin: config.corsOrigins, credentials: true }));
      app.use(express.json({ limit: '100kb' }));
      app.use(cookieParser());
    
      app.get('/health', (req, res) => {
        try {
          db.$client.prepare('SELECT 1').get(); // pastikan database masih hidup
          res.json({ status: 'ok', uptime: Math.round(process.uptime()) });
        } catch (err) {
          req.log.error({ err }, 'health check gagal');
          res.status(503).json({ status: 'error' }); // 503 = jangan kirimi trafik dulu
        }
      });
      app.use('/uploads', express.static(config.UPLOAD_DIR, { maxAge: '7d', index: false }));
      app.use('/api/auth', authRouter({ db, auth, authLimiter }));
      app.use('/api/products', productsRouter({ db, auth, upload }));
      app.use('/api/orders', ordersRouter({ db, auth }));
    
      app.use((req, res) => res.status(404).json({ error: 'Endpoint tidak ditemukan' }));
    
      // error handler: 4 parameter. Express 5 otomatis meneruskan error dari async handler ke sini.
      app.use((err, req, res, next) => {
        if (err instanceof multer.MulterError) {
          const status = err.code === 'LIMIT_FILE_SIZE' ? 413 : 400;
          return res.status(status).json({ error: err.code === 'LIMIT_FILE_SIZE' ? 'File maksimal 2 MB' : err.message });
        }
        if (err.type === 'entity.parse.failed') return res.status(400).json({ error: 'JSON rusak' });
        if (err.type === 'entity.too.large') return res.status(413).json({ error: 'Body terlalu besar' });
        const status = err.status ?? 500;
        if (status >= 500) req.log.error({ err }, 'error tak terduga');
        // jangan kirim stack trace / pesan internal ke klien
        res.status(status).json({ error: status >= 500 ? 'Terjadi kesalahan di server' : err.message });
      });
    
      return app;
    }
    

    src/server.js dan src/seed-admin.js

    // src/server.js, titik masuk: load config, buka DB, listen, graceful shutdown
    import { loadConfig } from './config.js';
    import { createDb } from './db/index.js';
    import { createApp } from './app.js';
    
    const config = loadConfig();
    const db = createDb(config.DATABASE_FILE);
    const app = createApp({ db, config });
    
    const server = app.listen(config.PORT, () => {
      console.log(`API jalan di http://localhost:${config.PORT} (${config.NODE_ENV})`);
    });
    
    function shutdown(signal) {
      console.log(`${signal} diterima, menutup server...`);
      server.close(() => {          // berhenti terima koneksi baru, tunggu request yang sedang jalan
        db.$client.close();         // tutup database dengan rapi
        console.log('Selesai. Sampai jumpa.');
        process.exit(0);
      });
      setTimeout(() => process.exit(1), 10_000).unref(); // paksa keluar kalau macet > 10 detik
    }
    process.on('SIGTERM', shutdown); // dikirim Docker/systemd/PM2 saat stop
    process.on('SIGINT', shutdown);  // Ctrl+C
    
    // src/seed-admin.js, buat akun admin pertama: node --env-file=.env src/seed-admin.js email password
    import argon2 from 'argon2';
    import { loadConfig } from './config.js';
    import { createDb } from './db/index.js';
    import { users } from './db/schema.js';
    
    const [email, password] = process.argv.slice(2);
    if (!email || !password || password.length < 8) {
      console.error('Pakai: node src/seed-admin.js <email> <password-min-8>');
      process.exit(1);
    }
    const db = createDb(loadConfig().DATABASE_FILE);
    const passwordHash = await argon2.hash(password);
    const u = db.insert(users).values({ email: email.toLowerCase(), name: 'Admin', passwordHash, role: 'admin' })
      .onConflictDoUpdate({ target: users.email, set: { role: 'admin', passwordHash } })
      .returning().get();
    console.log(`Admin siap: ${u.email} (id ${u.id})`);
    db.$client.close();
    

    test/api.test.js

    // test/api.test.js, tes endpoint dengan database in-memory yang bersih
    import { describe, it, expect, beforeEach, afterAll } from 'vitest';
    import request from 'supertest';
    import argon2 from 'argon2';
    import fs from 'node:fs';
    import os from 'node:os';
    import path from 'node:path';
    import { createDb } from '../src/db/index.js';
    import { createApp } from '../src/app.js';
    import { users } from '../src/db/schema.js';
    
    const uploadDir = fs.mkdtempSync(path.join(os.tmpdir(), 'toko-upload-'));
    const config = {
      NODE_ENV: 'test', isProd: false, LOG_LEVEL: 'silent', AUTH_RATE_LIMIT: 1000,
      JWT_SECRET: 'rahasia-khusus-tes-yang-panjangnya-lebih-dari-32',
      corsOrigins: ['http://localhost:5173'], UPLOAD_DIR: uploadDir,
    };
    // PNG 1x1 piksel asli (magic bytes 89 50 4E 47)
    const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGD4DwABBAEAwS2OUAAAAABJRU5ErkJggg==', 'base64');
    
    let app, db;
    beforeEach(async () => {
      db = createDb(':memory:'); // DB baru tiap tes → tes tidak saling mengganggu
      app = createApp({ db, config });
      db.insert(users).values({ email: 'admin@toko.id', name: 'Admin', role: 'admin', passwordHash: await argon2.hash('admin12345') }).run();
    });
    afterAll(() => fs.rmSync(uploadDir, { recursive: true, force: true }));
    
    // agent = "browser" yang menyimpan cookie antar request
    async function loginAs(email, password) {
      const agent = request.agent(app);
      await agent.post('/api/auth/login').send({ email, password }).expect(200);
      return agent;
    }
    async function registerUser(email = 'budi@mail.com') {
      const agent = request.agent(app);
      await agent.post('/api/auth/register').send({ email, name: 'Budi', password: 'rahasia123' }).expect(201);
      return agent;
    }
    async function buatProduk(admin, data = {}) {
      const res = await admin.post('/api/products').send({ name: 'Kaos Polos', price: 75000, stock: 5, ...data }).expect(201);
      return res.body;
    }
    
    describe('auth', () => {
      it('register memasang cookie httpOnly dan tidak mengembalikan hash', async () => {
        const res = await request(app).post('/api/auth/register')
          .send({ email: 'Budi@Mail.com', name: 'Budi', password: 'rahasia123', role: 'admin' });
        expect(res.status).toBe(201);
        expect(res.body).toEqual({ id: 2, email: 'budi@mail.com', name: 'Budi', role: 'user' }); // role dari body diabaikan
        expect(res.headers['set-cookie'][0]).toMatch(/HttpOnly/);
      });
    
      it('email dobel → 409, input jelek → 400', async () => {
        await registerUser();
        await request(app).post('/api/auth/register').send({ email: 'budi@mail.com', name: 'Budi', password: 'rahasia123' }).expect(409);
        const res = await request(app).post('/api/auth/register').send({ email: 'bukan-email', name: 'B', password: '123' });
        expect(res.status).toBe(400);
        expect(Object.keys(res.body.detail).sort()).toEqual(['email', 'name', 'password']);
      });
    
      it('password salah → 401, /me tanpa cookie → 401', async () => {
        await request(app).post('/api/auth/login').send({ email: 'admin@toko.id', password: 'salah-salah' }).expect(401);
        await request(app).get('/api/auth/me').expect(401);
        const admin = await loginAs('admin@toko.id', 'admin12345');
        const me = await admin.get('/api/auth/me').expect(200);
        expect(me.body.role).toBe('admin');
      });
    });
    
    describe('produk & role', () => {
      it('user biasa tidak boleh membuat produk (403), tamu 401', async () => {
        const user = await registerUser();
        await user.post('/api/products').send({ name: 'X', price: 1 }).expect(403);
        await request(app).post('/api/products').send({ name: 'X', price: 1 }).expect(401);
      });
    
      it('admin CRUD produk, publik bisa membaca & mencari', async () => {
        const admin = await loginAs('admin@toko.id', 'admin12345');
        const p = await buatProduk(admin);
        await buatProduk(admin, { name: 'Topi Rimba', price: 50000 });
        await admin.patch(`/api/products/${p.id}`).send({ price: 80000 }).expect(200);
        const list = await request(app).get('/api/products?q=kaos').expect(200);
        expect(list.body.data).toHaveLength(1);
        expect(list.body.data[0].price).toBe(80000);
        await admin.delete(`/api/products/${p.id}`).expect(204);
        await request(app).get(`/api/products/${p.id}`).expect(404);
      });
    
      it('upload gambar: PNG asli diterima, teks menyamar jadi PNG ditolak', async () => {
        const admin = await loginAs('admin@toko.id', 'admin12345');
        const p = await buatProduk(admin);
        const ok = await admin.post(`/api/products/${p.id}/image`).attach('image', PNG, { filename: 'kaos.png', contentType: 'image/png' });
        expect(ok.status).toBe(200);
        expect(ok.body.imageUrl).toMatch(/^\/uploads\/[0-9a-f-]+\.png$/);
        await request(app).get(ok.body.imageUrl).expect(200).expect('content-type', 'image/png');
    
        const palsu = await admin.post(`/api/products/${p.id}/image`).attach('image', Buffer.from('<?php echo 1; ?>'), { filename: 'x.png', contentType: 'image/png' });
        expect(palsu.status).toBe(400);
        const pdf = await admin.post(`/api/products/${p.id}/image`).attach('image', Buffer.from('%PDF'), { filename: 'x.pdf', contentType: 'application/pdf' });
        expect(pdf.status).toBe(400);
      });
    });
    
    describe('pesanan', () => {
      it('checkout mengurangi stok, total dihitung server', async () => {
        const admin = await loginAs('admin@toko.id', 'admin12345');
        const p = await buatProduk(admin);
        const user = await registerUser();
        const res = await user.post('/api/orders').send({ items: [{ productId: p.id, quantity: 2 }] }).expect(201);
        expect(res.body.total).toBe(150000);
        const sesudah = await request(app).get(`/api/products/${p.id}`);
        expect(sesudah.body.stock).toBe(3);
      });
    
      it('stok kurang → 409 dan TIDAK ada perubahan (rollback)', async () => {
        const admin = await loginAs('admin@toko.id', 'admin12345');
        const a = await buatProduk(admin, { name: 'Produk A', stock: 10 });
        const b = await buatProduk(admin, { name: 'Produk B', stock: 1 });
        const user = await registerUser();
        await user.post('/api/orders').send({ items: [{ productId: a.id, quantity: 3 }, { productId: b.id, quantity: 5 }] }).expect(409);
        expect((await request(app).get(`/api/products/${a.id}`)).body.stock).toBe(10); // stok A kembali utuh
        expect((await user.get('/api/orders')).body).toHaveLength(0);
      });
    
      it('user lain tidak bisa melihat pesanan orang (404), admin bisa', async () => {
        const admin = await loginAs('admin@toko.id', 'admin12345');
        const p = await buatProduk(admin);
        const budi = await registerUser('budi@mail.com');
        const siti = await registerUser('siti@mail.com');
        const order = (await budi.post('/api/orders').send({ items: [{ productId: p.id, quantity: 1 }] })).body;
        await siti.get(`/api/orders/${order.id}`).expect(404);
        const detail = await budi.get(`/api/orders/${order.id}`).expect(200);
        expect(detail.body.items[0].product.name).toBe('Kaos Polos');
        await admin.get(`/api/orders/${order.id}`).expect(200);
      });
    });
    
    describe('keamanan dasar', () => {
      it('header helmet terpasang dan x-powered-by hilang', async () => {
        const res = await request(app).get('/health').expect(200);
        expect(res.headers['x-content-type-options']).toBe('nosniff');
        expect(res.headers['x-powered-by']).toBeUndefined();
        expect(res.headers['x-request-id']).toBeDefined();
      });
      it('JSON rusak → 400 rapi, bukan stack trace', async () => {
        const res = await request(app).post('/api/auth/login').set('content-type', 'application/json').send('{"email":');
        expect(res.status).toBe(400);
        expect(res.body).toEqual({ error: 'JSON rusak' });
      });
    });
    
    npm test
    # →  Test Files  1 passed (1)
    # →       Tests  11 passed (11)
    # →    Duration  1.28s
    

    Mencoba alur lengkap dengan curl

    npm run seed:admin -- admin@toko.id admin12345     # → Admin siap: admin@toko.id (id 1)
    npm run dev                                         # → API jalan di http://localhost:3000 (development)
    
    # terminal lain
    B=http://localhost:3000
    curl -s -c admin.txt -X POST $B/api/auth/login -H 'content-type: application/json' \
      -d '{"email":"admin@toko.id","password":"admin12345"}'
    # → {"id":1,"email":"admin@toko.id","name":"Admin","role":"admin"}
    curl -s -b admin.txt -X POST $B/api/products -H 'content-type: application/json' \
      -d '{"name":"Kaos Polos Hitam","price":75000,"stock":10}'
    # → {"id":1,"name":"Kaos Polos Hitam","description":"","price":75000,"stock":10,"imageUrl":null}
    curl -s -b admin.txt -F "image=@kaos.png;type=image/png" $B/api/products/1/image
    # → {"id":1,...,"imageUrl":"/uploads/d1312114-c246-40b6-ba6f-312b5b7b09e0.png"}
    curl -s -c budi.txt -X POST $B/api/auth/register -H 'content-type: application/json' \
      -d '{"email":"budi@mail.com","name":"Budi","password":"rahasia123"}'
    # → {"id":2,"email":"budi@mail.com","name":"Budi","role":"user"}
    curl -s -b budi.txt -X POST $B/api/orders -H 'content-type: application/json' \
      -d '{"items":[{"productId":1,"quantity":2}]}'
    # → {"id":1,"userId":2,"total":150000,"status":"pending","createdAt":"2026-10-23 18:12:55"}
    curl -s -b budi.txt $B/api/orders/1
    # → {"id":1,"userId":2,"total":150000,"status":"pending","createdAt":"2026-10-23 18:12:55",
    #    "items":[{"id":1,"orderId":1,"productId":1,"quantity":2,"price":75000,"product":{"name":"Kaos Polos Hitam"}}]}
    

    Menyambungkan ke frontend React

    Development: pakai proxy dev server Vite, sehingga browser melihat frontend dan API sebagai satu origin (localhost:5173). Cookie dan CORS jadi tidak perlu dipikirkan saat development.

    // vite.config.js di proyek React (Vite 8.3.5)
    import { defineConfig } from 'vite';
    import react from '@vitejs/plugin-react';
    
    export default defineConfig({
      plugins: [react()],
      server: {
        proxy: {
          '/api': 'http://localhost:3000',     // request /api/... diteruskan ke Express
          '/uploads': 'http://localhost:3000', // gambar produk juga
        },
      },
    });
    

    Diuji lewat curl ke port Vite (dengan konfigurasi proxy yang sama): login lewat http://localhost:5173/api/auth/login mengembalikan set-cookie: token=...; HttpOnly; SameSite=Lax, lalu GET http://localhost:5173/api/auth/me dengan cookie itu mengembalikan data admin, dan GET /uploads/<file>.png membalas 200 image/png.

    Helper fetch kecil untuk semua panggilan API:

    // src/lib/api.js di proyek React
    const BASE = import.meta.env.VITE_API_URL ?? ''; // kosong saat dev (lewat proxy)
    
    export async function api(path, { method = 'GET', body, ...rest } = {}) {
      const isForm = body instanceof FormData;
      const res = await fetch(BASE + path, {
        method,
        credentials: 'include', // WAJIB supaya cookie httpOnly ikut terkirim lintas origin
        headers: isForm || !body ? undefined : { 'Content-Type': 'application/json' },
        body: isForm ? body : body && JSON.stringify(body),
        ...rest,
      });
      if (res.status === 204) return null;
      const data = await res.json();
      if (!res.ok) {
        const err = new Error(data.error ?? 'Request gagal');
        err.status = res.status; // 401 → arahkan ke login, 403 → tampilkan "tidak punya akses"
        err.detail = data.detail;
        throw err;
      }
      return data;
    }
    
    // contoh pemakaian
    // await api('/api/auth/login', { method: 'POST', body: { email, password } });
    // const { data } = await api('/api/products?page=1');
    // const fd = new FormData(); fd.append('image', file); await api(`/api/products/${id}/image`, { method: 'POST', body: fd });
    

    Komponen katalog sederhana (React 19):

    // src/components/Katalog.jsx
    import { useEffect, useState } from 'react';
    import { api } from '../lib/api.js';
    
    const rupiah = new Intl.NumberFormat('id-ID', { style: 'currency', currency: 'IDR', maximumFractionDigits: 0 });
    
    export default function Katalog() {
      const [produk, setProduk] = useState([]);
      const [error, setError] = useState(null);
    
      useEffect(() => {
        api('/api/products?limit=20').then((r) => setProduk(r.data)).catch((e) => setError(e.message));
      }, []);
    
      async function beli(id) {
        try {
          await api('/api/orders', { method: 'POST', body: { items: [{ productId: id, quantity: 1 }] } });
          alert('Pesanan dibuat!');
        } catch (e) {
          if (e.status === 401) window.location.href = '/login';
          else alert(e.message); // misalnya "Stok Kaos Polos Hitam tidak cukup"
        }
      }
    
      if (error) return <p>Gagal memuat: {error}</p>;
      return (
        <ul>
          {produk.map((p) => (
            <li key={p.id}>
              {p.imageUrl && <img src={(import.meta.env.VITE_API_URL ?? '') + p.imageUrl} alt={p.name} width={120} />}
              <strong>{p.name}</strong> {rupiah.format(p.price)} (stok {p.stock})
              <button onClick={() => beli(p.id)} disabled={p.stock === 0}>Beli</button>
            </li>
          ))}
        </ul>
      );
    }
    

    Produksi: kalau frontend dan API berada di subdomain berbeda dari domain yang sama (misalnya tokoku.id dan api.tokoku.id), set VITE_API_URL=https://api.tokoku.id saat build frontend, dan CORS_ORIGIN=https://tokoku.id di API. Cookie sameSite: 'lax' tetap terkirim karena keduanya dianggap "same-site" (satu domain utama). Kalau domainnya benar-benar berbeda (misalnya frontend di tokoku.vercel.app), cookie lintas situs butuh sameSite: 'none' + secure, dan sebagian browser memblokir cookie pihak ketiga. Pilihan paling sederhana adalah menaruh keduanya di bawah satu domain, atau menyajikan API di path yang sama lewat reverse proxy (tokoku.id/api).

    Kriteria lulus

    Pengembangan lanjutan

    Video pendamping

    Kalau penjelasan di atas belum masuk, tonton ini. Video dari kanal YouTube pihak lain.

    REST API Toko Online Menggunakan ExpressJs Part 1daridasar · Bahasa IndonesiaBuka di YouTube

    Kuis pemahaman

    Pilih satu jawaban per soal. Penjelasan muncul setelah kamu menjawab.