diff --git a/.codex b/.codex new file mode 100644 index 0000000..e69de29 diff --git a/.env.example b/.env.example index 4babd36..2c2c79d 100644 --- a/.env.example +++ b/.env.example @@ -27,6 +27,9 @@ CAMPAIGN_RETRY_JOB_RATE_LIMIT_WINDOW_MS="60000" WHATSAPP_WEBHOOK_RATE_LIMIT_GET="60" WHATSAPP_WEBHOOK_RATE_LIMIT_POST="120" WHATSAPP_WEBHOOK_RATE_LIMIT_WINDOW_MS="60000" +SESSION_TTL_SECONDS="86400" +SESSION_COOKIE_DOMAIN="" +COOKIE_SECURE="true" AUTH_TOKEN_CONSUMED_RETENTION_HOURS="24" CAMPAIGN_RETRY_STALE_LOCK_MINUTES="120" WEBHOOK_EVENT_RETENTION_DAYS="30" diff --git a/app/auth/login/route.ts b/app/auth/login/route.ts index 9b952c6..5930c5b 100644 --- a/app/auth/login/route.ts +++ b/app/auth/login/route.ts @@ -2,6 +2,9 @@ import { NextRequest, NextResponse } from "next/server"; import { SESSION_COOKIE, + SESSION_COOKIE_SECURE_ENV, + getSessionCookieDomain, + getSessionTtlSeconds, UserRole, canAccessPath, authenticateUser, @@ -58,7 +61,7 @@ function maskEmail(email: string) { } function shouldUseSecureCookies(request: NextRequest) { - const explicit = process.env.COOKIE_SECURE?.toLowerCase() ?? ""; + const explicit = SESSION_COOKIE_SECURE_ENV; if (explicit === "true" || explicit === "1") { return true; } @@ -193,6 +196,7 @@ export async function POST(request: NextRequest) { sameSite: "lax", secure: shouldUseSecureCookies(request), path: "/", + domain: getSessionCookieDomain(), maxAge: sessionMaxAgeSeconds }); if (AUTH_DEBUG) { @@ -200,6 +204,7 @@ export async function POST(request: NextRequest) { userId: session.userId, role: session.role, sessionExpiresAt: session.expiresAt, + sessionMaxAgeFromEnv: getSessionTtlSeconds(), maxAge: sessionMaxAgeSeconds, host: request.headers.get("host") || "unknown", protocol: request.nextUrl.protocol, diff --git a/app/auth/logout/route.ts b/app/auth/logout/route.ts index a403263..c3dce31 100644 --- a/app/auth/logout/route.ts +++ b/app/auth/logout/route.ts @@ -4,7 +4,7 @@ import { getRequestAuditContext, writeAuditTrail } from "@/lib/audit"; import { getSession, SESSION_COOKIE } from "@/lib/auth"; import { getRequestBaseUrl } from "@/lib/request-url"; -export async function GET(request: NextRequest) { +export async function POST(request: NextRequest) { const session = await getSession(); const { ipAddress, userAgent } = await getRequestAuditContext(); @@ -25,3 +25,9 @@ export async function GET(request: NextRequest) { response.cookies.delete(SESSION_COOKIE); return response; } + +export async function GET(request: NextRequest) { + const baseUrl = getRequestBaseUrl(request); + const response = NextResponse.redirect(new URL("/login", baseUrl)); + return response; +} diff --git a/components/app-shell.tsx b/components/app-shell.tsx index cb358eb..841d624 100644 --- a/components/app-shell.tsx +++ b/components/app-shell.tsx @@ -125,13 +125,15 @@ export async function AppShell({ })}
- - logout - {t("nav", "logout")} - +
+ +
diff --git a/docs/code-documentation.md b/docs/code-documentation.md new file mode 100644 index 0000000..4b275aa --- /dev/null +++ b/docs/code-documentation.md @@ -0,0 +1,189 @@ + + +# WhatsApp Inbox Platform – Dokumentasi Kode + +Dokumen ini menjelaskan arsitektur dan alur utama aplikasi supaya mudah dipahami saat onboarding, debug, atau audit. + +## 1. Gambaran Sistem + +Stack yang dipakai: + +- Frontend + backend: Next.js 15 (App Router). +- Database: PostgreSQL + Prisma ORM. +- Autentikasi custom: cookie session berbasis payload ter-tanda-tangan. +- Job worker: campaign retry worker berbasis script Node. +- Proses produksi: PM2 + Nginx sebagai reverse proxy. +- Logging dan health check: endpoint internal + script operasi. + +## 2. Struktur Folder Penting + +`app/` +Himpunan route Next.js, termasuk endpoint autentikasi dan halaman admin. + +`lib/` +Modul inti: + +- auth/session. +- prisma client. +- util i18n. +- request url utility. +- audit & rate limit. + +`prisma/` +Skema dan migrasi database. + +`scripts/` +Operasional dan maintenance: + +- ops scripts. +- job scripts. + +`public/` +Asset statis. + +## 3. Alur Autentikasi + +### 3.1 Login + +1. POST ke `/auth/login`. +2. Validasi rate limit, credential, dan status user. +3. Jika valid, session dibuat dan disimpan di cookie `wa_inbox_session`. +4. Cookie diset dengan: + - `httpOnly`. + - `sameSite: "lax"`. + - `secure` sesuai env/protocol. + - `domain` dari `SESSION_COOKIE_DOMAIN` bila di-set. + - `maxAge` dari nilai session TTL. +5. Response diarahkan ke route default sesuai role. + +File: +- `app/auth/login/route.ts` + +### 3.2 Middleware Guard + +Setiap request (kecuali API) dilewati middleware untuk validasi: + +- Mengecek session cookie. +- Redirect ke `/login?next=...` jika tidak ada session. +- Mengarahkan user ke default path role jika sudah login dan masuk halaman publik tertentu. +- Redirect unauthorized jika role tidak punya akses. +- Menangani locale cookie fallback. + +File: +- `middleware.ts` + +### 3.3 Parse Session + +Session token dibuat dari payload user lalu ditandatangani dengan HMAC. + +Modul inti: +- `lib/auth.ts` + +Berisi: +- tipe session. +- validasi role. +- serialisasi/deserialisasi cookie. +- helper konfigurasi session (`getSessionTtlSeconds`, `getSessionCookieDomain`). + +## 4. Konfigurasi Session dan Cookie + +### 4.1 TTL Session + +Nilai diambil dari env: + +- `SESSION_TTL_SECONDS` (prioritas utama). +- `SESSION_TTL_HOURS` (fallback legacy). + +Default fallback tetap saat env tidak ada. + +### 4.2 Cookie Hardening + +Untuk environment HTTPS/prod: + +- `COOKIE_SECURE=true`. +- `SESSION_COOKIE_DOMAIN=web.zappcare.id` (jika pakai subdomain). +- `SESSION_COOKIE_DOMAIN` akan diabaikan untuk localhost/127.0.0.1. + +## 5. Modul dan Script Operasional + +### 5.1 Script Deploy Aman + +`scripts/ops-safe-restart.sh`: + +- cek `.env`. +- install dependency (`npm ci`). +- build. +- deploy migration Prisma (`npm run db:deploy`). +- restart/start PM2. +- optional healthcheck. +- save PM2 state. + +NPM script: +- `npm run ops:safe-restart` + +### 5.2 Script Verifikasi Session + +`scripts/ops-session-check.mjs`: + +- login otomatis via akun health check. +- verifikasi cookie sesi terbit. +- validasi TTL. +- cek akses page protected (`/super-admin`) dengan cookie sesi. + +NPM script: +- `npm run ops:session-check` + +### 5.3 Dokumentasi Operasional Terkait + +- `ops-runbook.md` +- `production-readiness-checklist.md` + +## 6. Catatan Debug Umum + +### Gejala: Kembali ke halaman login setelah klik menu + +Penyebab paling umum: +- sesi tidak tersimpan karena cookie `secure/domain` tidak cocok. +- domain/protocol HTTPS mismatch. +- proses logout tidak sengaja terpicu. +- path/role mismatch pada middleware. + +Pengecekan: +- lihat response headers: + - `X-Auth-Session` + - `X-Auth-Session-Has-Cookie` + - `X-Auth-Base-Url` +- cek cookie di browser devtools (nama `wa_inbox_session`, secure/domain/path/maxAge). +- cek log aplikasi (`pm2 logs ...` + `grep AUTH`). + +### Gejala: Loop redirect login (bahasa Inggris / error invalid credential) + +Pastikan: +- `AUTH_SECRET` ada di env prod. +- `APP_URL` / `OPS_BASE_URL` sesuai domain HTTPS. +- `COOKIE_SECURE` sesuai mode. +- Nginx tidak strip header `Cookie`. + +## 7. Catatan Implementasi Khusus + +- Logout flow sudah difokuskan ke request non-prefetch agar tidak terpicu by mistake pada prefetch link. +- Script yang diubah untuk mencegah kegagalan sesi karena navigasi prefetch. +- Penambahan debugging header di middleware/login untuk mempercepat root cause analysis. + +## 8. Dependency Environment Wajib (Ringkas) + +- `DATABASE_URL` +- `AUTH_SECRET` +- `APP_URL` +- `SESSION_TTL_SECONDS` +- `COOKIE_SECURE` +- `SESSION_COOKIE_DOMAIN` (jika dibutuhkan) +- `WHATSAPP_*`, `CAMPAIGN_*`, dan token internal sesuai fitur aktif. + +Lihat juga: +- `ops-runbook.md` +- `production-readiness-checklist.md` +- `.env.example` diff --git a/docs/setup-guide.md b/docs/setup-guide.md new file mode 100644 index 0000000..b55e4d4 --- /dev/null +++ b/docs/setup-guide.md @@ -0,0 +1,320 @@ + + +# Guide Setup & Deployment (from zero to production) + +Dokumen ini berisi urutan setup dari awal, termasuk instalasi dependency, konfigurasi environment, deploy, dan pengecekan. + +## A. Prasyarat Dasar + +- Linux server Linux Mint/Ubuntu. +- User deploy dengan sudo tanpa password (sudah disiapkan di server Anda). +- Akses SSH ke server. +- Domain yang mengarah ke server, contoh `web.zappcare.id`. +- PostgreSQL sudah ada untuk production. + +## B. Setup Lokal (Developer) + +### B1. Clone dan install + +```bash +git clone https://git.iptek.co/wirabasalamah/whatsapp-inbox-platform.git +cd whatsapp-inbox-platform +npm install +``` + +### B2. Siapkan `.env` + +```bash +cp .env.example .env +``` + +Edit `.env` minimal: + +- `DATABASE_URL` +- `AUTH_SECRET` +- `APP_URL=http://localhost:3002` +- `SESSION_TTL_SECONDS=86400` +- `COOKIE_SECURE=false` untuk lokal non-HTTPS + +### B3. Jalankan DB lokal + +```bash +npx prisma generate +npm run db:migrate +npm run db:seed +``` + +### B4. Jalankan aplikasi + +```bash +npm run dev +``` + +Untuk build check: + +```bash +npm run build +``` + +## C. Setup Server (Dari Kosong) + +## C1. Install Node.js + Git + Nginx + PostgreSQL + PM2 + +```bash +sudo apt update +sudo apt install -y curl git nginx postgresql +curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - +sudo apt install -y nodejs +sudo npm install -g pm2 +``` + +Catatan: + +- Versi Node.js disesuaikan dengan kebutuhan proyek. +- Gunakan `pnpm` jika Anda standardize npm/pnpm di server, tapi repo ini diuji dengan npm. + +## C2. Setup user deploy + +Contoh: + +```bash +sudo adduser --system --group whatsapp-inbox +sudo mkdir -p /var/www/whatsapp-inbox-platform +sudo chown -R whatsapp-inbox:whatsapp-inbox /var/www/whatsapp-inbox-platform +``` + +## C3. Clone repo dan set permission + +```bash +sudo -u whatsapp-inbox -H git clone https://git.iptek.co/wirabasalamah/whatsapp-inbox-platform.git /var/www/whatsapp-inbox-platform +sudo chown -R whatsapp-inbox:whatsapp-inbox /var/www/whatsapp-inbox-platform +sudo chmod -R g+rwX /var/www/whatsapp-inbox-platform +``` + +## C4. Konfigurasi Database PostgreSQL + +```sql +-- psql as postgres +CREATE DATABASE whatsapp_inbox; +CREATE USER whatsapp_inbox WITH ENCRYPTED PASSWORD 'isi_password_kuat'; +GRANT ALL PRIVILEGES ON DATABASE whatsapp_inbox TO whatsapp_inbox; +``` + +Pastikan `DATABASE_URL` mengarah ke: + +`postgresql://whatsapp_inbox:password@127.0.0.1:5432/whatsapp_inbox?schema=public` + +## C5. Set `.env` production + +Buat `.env` di `/var/www/whatsapp-inbox-platform/.env` berdasarkan `.env.example`. + +Wajib isi: + +- `NODE_ENV=production` +- `PORT=3002` +- `DATABASE_URL=...` +- `AUTH_SECRET=...` (random panjang) +- `APP_URL=https://web.zappcare.id` +- `SESSION_TTL_SECONDS=86400` +- `COOKIE_SECURE=true` +- `SESSION_COOKIE_DOMAIN=web.zappcare.id` +- `CAMPAIGN_RETRY_JOB_TOKEN=...` +- `WHATSAPP_WEBHOOK_VERIFY_TOKEN=...` +- `WHATSAPP_WEBHOOK_SECRET=...` + +Opsional: + +- `OPS_SESSION_CHECK_EMAIL`, `OPS_SESSION_CHECK_PASSWORD` untuk verifikasi otomatis. + +## C6. Build dan Deploy awal + +```bash +cd /var/www/whatsapp-inbox-platform +npm ci +npm run build +npm run db:deploy +npm run ops:safe-restart +``` + +`ops:safe-restart` juga melakukan install+build+migrate jika dipanggil dari state normal. + +## C7. Nginx + SSL + +Konfigurasi Nginx (contoh sederhana): + +```nginx +server { + listen 80; + server_name web.zappcare.id; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl http2; + server_name web.zappcare.id; + + ssl_certificate /etc/letsencrypt/live/web.zappcare.id/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/web.zappcare.id/privkey.pem; + + location / { + proxy_pass http://127.0.0.1:3002; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` + +Setup Let’s Encrypt: + +```bash +sudo apt install -y certbot python3-certbot-nginx +sudo certbot --nginx -d web.zappcare.id +``` + +Reload nginx: + +```bash +sudo nginx -t && sudo systemctl reload nginx +``` + +## D. Deployment Routine Setelah Update + +### D0. Alur source code ke Git + +Kerjakan perubahan di folder lokal: + +```bash +cd /home/wira/work/whatsapp-inbox-platform +``` + +Pastikan file sensitif seperti `.env` tidak ikut di-commit. Repo ini sudah meng-ignore `.env`. + +Command Git manual dari lokal: + +```bash +cd /home/wira/work/whatsapp-inbox-platform +git status +git add . +git commit -m "fix: deskripsi perubahan" +git push origin main +``` + +Jika ingin cek commit terakhir yang sudah siap diambil server: + +```bash +cd /home/wira/work/whatsapp-inbox-platform +git log --oneline -n 5 +``` + +### D1. Pull dan jalankan restart aman + +```bash +cd /var/www/whatsapp-inbox-platform +git pull origin main +npm run ops:safe-restart +``` + +Jika app di server dijalankan dengan user deploy `whatsapp-inbox`, gunakan: + +```bash +sudo -u whatsapp-inbox -H bash -lc 'cd /var/www/whatsapp-inbox-platform && git pull origin main && npm run ops:safe-restart' +``` + +Command di atas adalah jalur patch utama untuk update source di server. + +### D1.1. Patch manual di server langkah demi langkah + +Gunakan ini jika ingin melihat proses satu per satu: + +```bash +sudo -u whatsapp-inbox -H bash -lc 'cd /var/www/whatsapp-inbox-platform && git status' +sudo -u whatsapp-inbox -H bash -lc 'cd /var/www/whatsapp-inbox-platform && git pull origin main' +sudo -u whatsapp-inbox -H bash -lc 'cd /var/www/whatsapp-inbox-platform && npm ci' +sudo -u whatsapp-inbox -H bash -lc 'cd /var/www/whatsapp-inbox-platform && npm run build' +sudo -u whatsapp-inbox -H bash -lc 'cd /var/www/whatsapp-inbox-platform && npm run db:deploy' +sudo -u whatsapp-inbox -H bash -lc 'cd /var/www/whatsapp-inbox-platform && npm run ops:safe-restart' +``` + +Gunakan jalur manual ini jika: + +- ada perubahan dependency baru +- ada migration baru +- ingin memastikan step build berhasil sebelum restart + +### D1.2. Patch cepat tanpa perubahan dependency + +Kalau hanya ubah source biasa dan `package-lock.json` tidak berubah: + +```bash +sudo -u whatsapp-inbox -H bash -lc 'cd /var/www/whatsapp-inbox-platform && git pull origin main && npm run build && npm run ops:safe-restart' +``` + +### D2. Verifikasi pasca deploy + +```bash +cd /var/www/whatsapp-inbox-platform +npm run ops:readiness +npm run ops:session-check +npm run ops:healthcheck +``` + +### D3. Verifikasi proses berjalan + +```bash +pm2 list +sudo ss -ltnp | grep 3002 +``` + +## E. Checklist Pasca Login Issue + +Jika masih kena loop ke login: + +- cek cookie `wa_inbox_session` di browser. +- cek domain/cookie secure. +- cek header: + - `X-Auth-Session` + - `X-Auth-Session-Has-Cookie` + - `X-Auth-Base-Url` +- cek logs: + +```bash +pm2 logs whatsapp-inbox-platform --lines 200 +``` + +- cek ENV: + - `COOKIE_SECURE=true` untuk HTTPS. + - `APP_URL` harus `https://web.zappcare.id`. + - `SESSION_COOKIE_DOMAIN` kalau lintas subdomain. + +## F. Jalur Emergency/Recovery + +Jika app tidak start: + +- `npm run ops:readiness` +- cek DB connect. +- jalankan ulang migration jika schema mismatch. +- run `npm run ops:safe-restart`. +- jika perlu rollback, checkout commit sebelumnya lalu restart ulang. + +```bash +git log --oneline -n 10 +git checkout +npm run ops:safe-restart +``` + +## G. Reference Command Cepat + +- Health app: `npm run ops:healthcheck` +- Session check: `npm run ops:session-check` +- Ready check: `npm run ops:readiness` +- Restart aman: `npm run ops:safe-restart` +- View process: `pm2 list` +- Tail logs: `pm2 logs` +- Folder kerja lokal: `/home/wira/work/whatsapp-inbox-platform` +- Folder deploy server: `/var/www/whatsapp-inbox-platform` diff --git a/lib/auth.ts b/lib/auth.ts index 3ebba19..52bc132 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -19,7 +19,46 @@ export type AuthSession = { }; export const SESSION_COOKIE = "wa_inbox_session"; -const SESSION_TTL_SECONDS = 60 * 60 * 24 * 7; +const DEFAULT_SESSION_TTL_SECONDS = 60 * 60 * 24 * 7; +const SESSION_TTL_SECONDS = getConfiguredSessionTtlSeconds(process.env.SESSION_TTL_SECONDS); +export const SESSION_COOKIE_DOMAIN = process.env.SESSION_COOKIE_DOMAIN?.trim() || ""; +export const SESSION_COOKIE_SECURE_ENV = process.env.COOKIE_SECURE?.trim().toLowerCase() || ""; + +function getConfiguredSessionTtlSeconds(raw: string | undefined) { + if (typeof raw === "string" && raw.trim().length > 0) { + const parsed = Number(raw.trim()); + if (Number.isFinite(parsed) && parsed > 0) { + return Math.floor(parsed); + } + } + + const legacyHours = Number(process.env.SESSION_TTL_HOURS); + if (Number.isFinite(legacyHours) && legacyHours > 0) { + return Math.floor(legacyHours * 60 * 60); + } + + return DEFAULT_SESSION_TTL_SECONDS; +} + +export function getSessionTtlSeconds() { + return SESSION_TTL_SECONDS; +} + +function parseCookieDomain() { + if (!SESSION_COOKIE_DOMAIN) { + return undefined; + } + + if (SESSION_COOKIE_DOMAIN === "localhost" || SESSION_COOKIE_DOMAIN === "127.0.0.1") { + return undefined; + } + + return SESSION_COOKIE_DOMAIN; +} + +export function getSessionCookieDomain() { + return parseCookieDomain(); +} const AUTH_SECRET = process.env.AUTH_SECRET; const SESSION_ITERATIONS = 120000; @@ -238,8 +277,8 @@ export async function parseSessionCookie(raw: string) { return { userId, - role: role as UserRole, - tenantId, + role: role as UserRole, + tenantId, tenantName: "", fullName: "", email: "", diff --git a/lib/inbox-ops.ts b/lib/inbox-ops.ts index 2440a44..3ad0242 100644 --- a/lib/inbox-ops.ts +++ b/lib/inbox-ops.ts @@ -330,15 +330,17 @@ export async function getInboxWorkspace({ scope, conversationId, filter = "all" return { id: conversation.id, tenantId: conversation.tenantId, - name: conversation.contact.fullName, - phone: conversation.contact.phoneNumber, + name: conversation.contact?.fullName ?? "Unknown Contact", + phone: conversation.contact?.phoneNumber ?? "-", snippet: latestMessage?.contentText ?? conversation.subject ?? "No content", time: formatRelativeDate(conversation.lastMessageAt), status: mapConversationStatus(conversation.status), assignee: conversation.assignedUser?.fullName ?? "Unassigned", assigneeId: conversation.assignedUser?.id ?? null, - channel: conversation.channel.channelName, - tags: conversation.conversationTags.map((item) => item.tag.name), + channel: conversation.channel?.channelName ?? "Unknown Channel", + tags: conversation.conversationTags + .map((item) => item.tag?.name) + .filter((tag): tag is string => Boolean(tag)), priority: mapConversationPriority(conversation.priority), contactId: conversation.contactId } satisfies ConversationSummary; @@ -394,17 +396,19 @@ export async function getInboxWorkspace({ scope, conversationId, filter = "all" ? { id: fullConversation.id, tenantId: fullConversation.tenantId, - name: fullConversation.contact.fullName, - phone: fullConversation.contact.phoneNumber, + name: fullConversation.contact?.fullName ?? "Unknown Contact", + phone: fullConversation.contact?.phoneNumber ?? "-", snippet: fullConversation.subject ?? fullConversation.messages[0]?.contentText ?? "No content", time: formatRelativeDate(fullConversation.lastMessageAt), status: mapConversationStatus(fullConversation.status), assignee: fullConversation.assignedUser?.fullName ?? "Unassigned", assigneeId: fullConversation.assignedUser?.id ?? null, - channel: fullConversation.channel.channelName, - tags: fullConversation.conversationTags.map((item) => item.tag.name), + channel: fullConversation.channel?.channelName ?? "Unknown Channel", + tags: fullConversation.conversationTags + .map((item) => item.tag?.name) + .filter((tag): tag is string => Boolean(tag)), priority: mapConversationPriority(fullConversation.priority), - contactId: fullConversation.contact.id, + contactId: fullConversation.contact?.id ?? fullConversation.contactId, tagJson: JSON.stringify(fullConversation.conversationTags.map((item) => item.tag.name)), messages: fullConversation.messages .slice() diff --git a/middleware.ts b/middleware.ts index cdbc71e..ee9a585 100644 --- a/middleware.ts +++ b/middleware.ts @@ -3,6 +3,7 @@ import { NextResponse, type NextRequest } from "next/server"; import { canAccessPath, getDefaultPathForRole, parseSessionCookie, SESSION_COOKIE, type UserRole } from "@/lib/auth"; import { DEFAULT_LOCALE, isLocale, LOCALE_COOKIE } from "@/lib/i18n"; import { getRequestBaseUrl } from "@/lib/request-url"; +import { getSessionCookieDomain } from "@/lib/auth"; const AUTH_DEBUG = process.env.AUTH_DEBUG === "true" || process.env.AUTH_DEBUG === "1"; @@ -73,6 +74,7 @@ export async function middleware(request: NextRequest) { const detected = acceptLanguage.includes("id") ? "id" : acceptLanguage.includes("en") ? "en" : DEFAULT_LOCALE; response.cookies.set(LOCALE_COOKIE, detected, { path: "/", + domain: getSessionCookieDomain(), maxAge: 365 * 24 * 60 * 60, secure: shouldUseSecureCookies(request), sameSite: "lax" @@ -80,6 +82,7 @@ export async function middleware(request: NextRequest) { } else if (!isLocale(localeCookie)) { response.cookies.set(LOCALE_COOKIE, DEFAULT_LOCALE, { path: "/", + domain: getSessionCookieDomain(), maxAge: 365 * 24 * 60 * 60, secure: shouldUseSecureCookies(request), sameSite: "lax" diff --git a/ops-runbook.md b/ops-runbook.md index 15c28bc..01fb642 100644 --- a/ops-runbook.md +++ b/ops-runbook.md @@ -2,13 +2,23 @@ ## 1) Normal Deployment -- Deploy code. +- Deploy code: +- optional pre-step on server: + - `git pull` + - `npm ci` + - `npm run build` - Run migration: - `npm run db:deploy` +- Optional schema safety (if DB changes): + - `npm run ops:readiness` - Update environment variables and secrets. -- Start app service. +- Start or restart app service: + - `npm run ops:safe-restart` - Start retry worker (`daemon` or cron). - Start maintenance cleanup (`npm run ops:maintenance`) on a periodic schedule, e.g. daily. +- Verify auth session baseline (24h check target default can be tuned via `SESSION_TTL_SECONDS`): + - set `OPS_SESSION_CHECK_EMAIL` dan `OPS_SESSION_CHECK_PASSWORD` di `.env` + - `npm run ops:session-check` - Run readiness: - `npm run ops:readiness` - Monitor: diff --git a/package.json b/package.json index fe7bada..e4de40a 100644 --- a/package.json +++ b/package.json @@ -20,8 +20,10 @@ "ops:healthcheck": "node scripts/ops-healthcheck.mjs", "ops:readiness": "node scripts/ops-readiness.mjs", "ops:incident": "node scripts/ops-incident.mjs", + "ops:session-check": "node scripts/ops-session-check.mjs", "ops:smoke": "node scripts/ops-smoke.mjs", "ops:maintenance": "node scripts/ops-maintenance.mjs", + "ops:safe-restart": "bash scripts/ops-safe-restart.sh", "ci:verify": "npm run typecheck && npm run lint:ci && npm run build" }, "prisma": { diff --git a/production-readiness-checklist.md b/production-readiness-checklist.md index a4bf10b..d05024c 100644 --- a/production-readiness-checklist.md +++ b/production-readiness-checklist.md @@ -10,6 +10,8 @@ - `APP_URL` (atau `OPS_BASE_URL`) - `WHATSAPP_WEBHOOK_VERIFY_TOKEN` + `WHATSAPP_WEBHOOK_SECRET` - `CAMPAIGN_RETRY_JOB_TOKEN` + - `SESSION_TTL_SECONDS` (opsional, contoh `86400` untuk 24 jam) + - `SESSION_COOKIE_DOMAIN` (opsional untuk cookie shared subdomain) - optional production hardening: - `HEALTHCHECK_TOKEN` - `WEBHOOK_FAILURE_RATE_THRESHOLD_PER_HOUR` @@ -24,6 +26,8 @@ - [ ] Run `npm run typecheck`. - [ ] Run `npm run build`. - [ ] Run `npm run ops:smoke` sebelum deploy dan setelah restart service. +- [ ] Jika aplikasi pakai PM2, verifikasi restart aman: + - `npm run ops:safe-restart` (hanya di server, setelah cek git/env terbaru). - [ ] Review `.env` tidak mengandung placeholder (contoh `change-me`, `your-*`). ## Runtime Readiness (Post-Deploy) @@ -35,6 +39,9 @@ - run `npm run ops:maintenance` on a broader schedule (daily/weekly) for cleanup. - [ ] Run ops readiness: `npm run ops:readiness`. - [ ] Check endpoint health: `GET /api/health`. +- [ ] Verify sesi login valid dan cookie lifetime: + - set `OPS_SESSION_CHECK_EMAIL` dan `OPS_SESSION_CHECK_PASSWORD` + - `npm run ops:session-check` - [ ] Verify webhook endpoint reachable: `POST /api/webhooks/whatsapp`. - [ ] Verify campaign retry endpoint state: - `GET /api/jobs/campaign-retry?token=` diff --git a/scripts/ops-safe-restart.sh b/scripts/ops-safe-restart.sh new file mode 100755 index 0000000..dd5bcee --- /dev/null +++ b/scripts/ops-safe-restart.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +set -euo pipefail + +APP_DIR=${APP_DIR:-$(pwd)} +PM2_NAME=${PM2_NAME:-whatsapp-inbox-platform} +PORT=${PORT:-3002} +SKIP_DB=${SKIP_DB:-0} +SKIP_HEALTHCHECK=${SKIP_HEALTHCHECK:-0} + +if [ ! -f "$APP_DIR/.env" ]; then + echo "[ops-safe-restart] .env file not found in $APP_DIR" + exit 1 +fi + +cd "$APP_DIR" + +echo "[ops-safe-restart] install dependencies" +npm ci --no-audit --no-fund + +echo "[ops-safe-restart] run build" +npm run build + +if [ "$SKIP_DB" != "1" ]; then + echo "[ops-safe-restart] apply prisma migration" + npm run db:deploy +fi + +if pm2 describe "$PM2_NAME" >/dev/null 2>&1; then + echo "[ops-safe-restart] restart existing pm2 process: $PM2_NAME" + pm2 restart "$PM2_NAME" --update-env +else + echo "[ops-safe-restart] start new pm2 process: $PM2_NAME" + pm2 start npm --name "$PM2_NAME" -- start -- --port "$PORT" +fi + +if [ "$SKIP_HEALTHCHECK" != "1" ]; then + echo "[ops-safe-restart] run ops healthcheck" + npm run ops:healthcheck +fi + +echo "[ops-safe-restart] saving pm2 process list" +pm2 save + +echo "[ops-safe-restart] done" diff --git a/scripts/ops-session-check.mjs b/scripts/ops-session-check.mjs new file mode 100644 index 0000000..727879d --- /dev/null +++ b/scripts/ops-session-check.mjs @@ -0,0 +1,189 @@ +#!/usr/bin/env node + +const BASE_URL = [ + process.env.OPS_BASE_URL, + process.env.APP_URL, + process.env.NEXT_PUBLIC_APP_URL +].map((value) => value?.trim()).find(Boolean); + +const TEST_EMAIL = process.env.OPS_SESSION_CHECK_EMAIL?.trim(); +const TEST_PASSWORD = process.env.OPS_SESSION_CHECK_PASSWORD?.trim(); +const EXPECTED_TTL_SECONDS = resolveSessionTtl(process.env.SESSION_TTL_SECONDS); +const SESSION_COOKIE_NAME = "wa_inbox_session"; + +if (!BASE_URL) { + console.error("[ops-session-check] Missing OPS_BASE_URL / APP_URL / NEXT_PUBLIC_APP_URL"); + process.exit(1); +} + +if (!TEST_EMAIL || !TEST_PASSWORD) { + console.error("[ops-session-check] Missing OPS_SESSION_CHECK_EMAIL / OPS_SESSION_CHECK_PASSWORD. Test skipped."); + process.exit(1); +} + +function resolveSessionTtl(value) { + if (!value) { + return 60 * 60 * 24 * 7; + } + const parsed = Number(value.trim()); + if (!Number.isFinite(parsed) || parsed <= 0) { + return 60 * 60 * 24 * 7; + } + return Math.floor(parsed); +} + +function readCookieLines(headers) { + const direct = []; + if (typeof headers.getSetCookie === "function") { + const lines = headers.getSetCookie(); + if (Array.isArray(lines)) { + return lines; + } + } + + headers.forEach((value, key) => { + if (key.toLowerCase() === "set-cookie") { + direct.push(value); + } + }); + + return direct; +} + +function getCookieValue(cookieHeader, name) { + const prefix = `${name}=`; + const found = cookieHeader.find((entry) => entry.startsWith(prefix)); + if (!found) { + return null; + } + + const [value] = found.split(";")[0].split("=", 2).slice(1); + if (typeof value !== "string") { + return null; + } + return `${found.split(";")[0].slice(prefix.length)}`; +} + +function getCookieTtlSeconds(cookieHeader, name) { + const prefix = `${name}=`; + const found = cookieHeader.find((entry) => entry.startsWith(prefix)); + if (!found) { + return null; + } + + const parts = found.split(";").map((part) => part.trim()); + const attrs = new Map(); + for (const part of parts.slice(1)) { + const [rawKey, rawValue] = part.split("="); + attrs.set(rawKey.toLowerCase(), rawValue ?? ""); + } + + const maxAgeRaw = attrs.get("max-age"); + if (maxAgeRaw) { + const parsed = Number(maxAgeRaw); + if (Number.isFinite(parsed)) { + return Math.floor(parsed); + } + } + + const expiresRaw = attrs.get("expires"); + if (expiresRaw) { + const parsedDate = new Date(expiresRaw); + if (!Number.isNaN(parsedDate.getTime())) { + return Math.floor((parsedDate.getTime() - Date.now()) / 1000); + } + } + + return null; +} + +async function requestJson(url, options = {}) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 12_000); + + try { + const response = await fetch(url, { + ...options, + signal: controller.signal + }); + clearTimeout(timeout); + return response; + } catch (error) { + clearTimeout(timeout); + throw error; + } +} + +async function main() { + const loginHeaders = { + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": "ops-session-check" + }; + const loginBody = new URLSearchParams({ + email: TEST_EMAIL, + password: TEST_PASSWORD, + next: "/super-admin" + }).toString(); + + const loginResponse = await requestJson(`${BASE_URL}/auth/login`, { + method: "POST", + redirect: "manual", + headers: loginHeaders, + body: loginBody + }); + + if (loginResponse.status !== 307 && loginResponse.status !== 302) { + console.error(`[ops-session-check] login status unexpected: ${loginResponse.status}`); + process.exit(1); + } + + const setCookieLines = readCookieLines(loginResponse.headers); + const sessionCookieValue = getCookieValue(setCookieLines, SESSION_COOKIE_NAME); + if (!sessionCookieValue) { + console.error("[ops-session-check] session cookie not issued by /auth/login"); + process.exit(1); + } + + const ttl = getCookieTtlSeconds(setCookieLines, SESSION_COOKIE_NAME); + if (ttl === null) { + console.error("[ops-session-check] session cookie ttl missing"); + process.exit(1); + } + + if (Math.abs(ttl - EXPECTED_TTL_SECONDS) > 600) { + console.warn( + `[ops-session-check] session ttl unexpected: ${ttl}s (expected ${EXPECTED_TTL_SECONDS}s ±600s)` + ); + } else { + console.log(`[ops-session-check] session ttl check OK: ${ttl}s`); + } + + const protected = await requestJson(`${BASE_URL}/super-admin`, { + method: "GET", + redirect: "manual", + headers: { + Cookie: `${SESSION_COOKIE_NAME}=${sessionCookieValue}` + } + }); + + if (protected.status === 200) { + console.log("[ops-session-check] protected path access OK (HTTP 200)."); + process.exit(0); + } + + if (protected.status >= 300 && protected.status < 400) { + const location = protected.headers.get("location") || ""; + if (location.includes("/login")) { + console.error("[ops-session-check] protected path redirected to login; session not accepted."); + process.exit(1); + } + } + + console.error(`[ops-session-check] protected path check failed with status ${protected.status}`); + process.exit(1); +} + +main().catch((error) => { + console.error("[ops-session-check] failed:", error instanceof Error ? error.message : String(error)); + process.exit(1); +});