TUTORIAL T-003 · TELEGRAM MINI APP · FULL-STACK

Bikin Telegram Mini App dari Nol

Studi kasus real: 9router miniapp: dashboard mobile-first yang nge-proxy ke API 9router live (localhost:20128) dan jalan di dalam Telegram. React 19 + Vite + TypeScript di frontend, Express reverse-proxy di backend, auth pakai password gate + Telegram initData HMAC. Dari npm create sampai live di BotFather.

STACK: React 19 + Express STEPS: 11 LEVEL: Intermediate DEPLOY: CF Tunnel
— PREREQUISITES

Yang lo butuhin: (1) Node.js 20+ dan npm di mesin lo (node --version confirm dulu), (2) Telegram Bot token dari @BotFather (/newbot), (3) cloudflared binary untuk expose local server ke HTTPS (gratis, covered di Step 08), (4) basic React/TypeScript familiarity, (5) kalau mau named tunnel + permanent URL: domain yang di-manage Cloudflare DNS. Quick tunnel gak butuh domain.

— YANG LO DAPET DI AKHIR

Setelah selesai: (1) Telegram Mini App live yang bisa dibuka dari dalam Telegram via Menu Button, (2) React 19 + Vite + TypeScript frontend dengan Telegram WebApp SDK terintegrasi, (3) Express backend yang serve static files + reverse-proxy ke API 9router, (4) auth HMAC-SHA256 via initData (cuma user Telegram terdaftar yang bisa masuk), (5) CF Tunnel aktif (quick atau named), (6) systemd service buat auto-start.

— TL;DR

Scaffold Vite React-TS → build UI mobile-first pakai Telegram WebApp SDK (window.Telegram.WebApp) → bikin Express backend yang serve static + expose API → amankan dengan verifikasi initData HMAC-SHA256 (cek bot_token) → build frontend (npm run build) → expose via cloudflared tunnel → daftarin URL HTTPS ke @BotFather (Menu Button). Mini App = web app biasa yang jalan di webview Telegram dengan SDK + auth khusus.

STEP 01

Apa Itu Mini App & Scaffold Project

Telegram Mini App itu web app biasa (HTML/JS) yang dirender di dalam webview Telegram. Bedanya cuma dua: ada Telegram WebApp SDK (window.Telegram.WebApp) buat akses tema, viewport, & data user, plus mekanisme auth lewat initData yang ditandatangani bot token. Selebihnya React/Vite biasa.

Scaffold pakai Vite template React + TypeScript:

# Scaffold Vite React-TS $ npm create vite@latest 9router-miniapp -- --template react-ts $ cd 9router-miniapp $ npm install $ npm install @simplewebauthn/browser # biometric/passkey support # Jalanin dev server $ npm run dev # http://localhost:5173

Tambah SDK Telegram ke index.html. Ini yang bikin window.Telegram.WebApp tersedia:

<!-- index.html, di dalam <head> --> <script src="https://telegram.org/js/telegram-web-app.js"></script>
— KENAPA FULL-STACK

Mini App murni frontend cuma cukup buat UI statis. Begitu lo butuh akses data sensitif (API key, stats, OAuth token) ATAU verifikasi user beneran login dari Telegram, lo WAJIB punya backend. Di case 9router: backend jadi reverse-proxy ke API 9router live, mint JWT server-side, dan gak pernah ekspos token mentah ke browser.

— GAK MAU BUILD DARI NOL?

Tutorial ini ngajarin bikin miniapp dari scratch. Kalau lo cuma mau install versi jadi tanpa belajar build-nya, pakai salah satu cara ini:

  1. npm create 9router-miniapp@latest my-dashboard — otomatis scaffold + install + generate password
  2. npx degit lukmanc405/9router-miniapp my-dashboard — clone tanpa git history
  3. git clone https://github.com/lukmanc405/9router-miniapp.git — full history

Setelah install: npm run build && cd server && node index.js. Buat deploy production + hardening baca SKILL.md di repo.

STEP 02

Struktur Project (Frontend + Backend)

Pisahin frontend (React/Vite) dan backend (Express) dalam satu repo. Backend nanti yang serve hasil build frontend + expose API.

9router-miniapp/ ├── src/ # Frontend: React 19 + Vite + TS │ ├── config.ts # APP_NAME, API_BASE │ ├── types.ts # Shared TS types (mirror server) │ ├── App.tsx # 5-tab nav + sub-nav pills + auth gate │ ├── lib/api.ts # Fetch wrapper (+ initData header) │ ├── components/ # Login, Toast, BiometricManager │ └── pages/ # Dashboard, Routing, Logs, Infra, Tools sub-pages ├── server/ # Backend: Express │ ├── index.js # API + static file serving │ └── lib/ │ ├── telegram-auth.js # initData HMAC verify + middleware │ ├── gate.js # Password gate + session token (HMAC cookie) │ ├── auth.js # Mint JWT buat upstream 9router │ ├── webauthn.js # WebAuthn/passkey register + login │ └── stats.js # System stats (CPU, RAM, swap, disk, network) ├── public/ # favicon, icons ├── .env.example └── vite.config.ts

Init backend sebagai package terpisah:

$ mkdir server && cd server $ npm init -y $ npm install express cors dotenv jose http-proxy-middleware @simplewebauthn/server
STEP 03

Frontend: Tab Nav + Telegram SDK Init

Inti UI: panggil WebApp.ready() & WebApp.expand() di mount, cek auth status, lalu render tab navigation. Arsitektur nav: 5 top-level tabs + pill sub-nav di dalamnya. Ini mencegah horizontal scroll yang bikin UX jelek di mobile.

// src/App.tsx — 5-tab architecture with sub-navigation import { useState, useEffect } from 'react'; import Home from './pages/Home'; import Providers from './pages/Providers'; import Models from './pages/Models'; import Combos from './pages/Combos'; import Logs from './pages/Logs'; import Login from './components/Login'; // Top-level: 5 tabs saja — muat tanpa scroll di mobile const TABS = [ { id: 'dashboard', label: 'Dashboard' }, { id: 'routing', label: 'Routing' }, { id: 'logs', label: 'Logs' }, { id: 'infra', label: 'Infra' }, { id: 'tools', label: 'Tools' }, ]; // Sub-nav pill component — renders inside each merged tab function SubNav({ items, active, onChange }) { return ( <div className="sub-nav"> {items.map(item => ( <button key={item.id} className={`sub-nav-pill ${active === item.id ? 'active' : ''}`} onClick={() => onChange(item.id)} >{item.label}</button> ))} </div> ); } // Example: RoutingTab groups Providers + Models + Combos function RoutingTab() { const [sub, setSub] = useState('providers'); return ( <div> <SubNav items={[ { id: 'providers', label: 'Providers' }, { id: 'models', label: 'Models' }, { id: 'combos', label: 'Combos' }, ]} active={sub} onChange={setSub} /> {sub === 'providers' && <Providers />} {sub === 'models' && <Models />} {sub === 'combos' && <Combos />} </div> ); } function App() { const [tab, setTab] = useState('dashboard'); const [authState, setAuthState] = useState<'checking' | 'login' | 'ok'>('checking'); useEffect(() => { // Wajib: kasih tau Telegram app siap, lalu fullscreen window.Telegram?.WebApp?.ready(); window.Telegram?.WebApp?.expand(); checkAuth(); }, []); const checkAuth = async () => { try { const r = await fetch('/api/auth-status', { credentials: 'include' }); const d = await r.json(); setAuthState(!d.required || d.authed ? 'ok' : 'login'); } catch { setAuthState('login'); } }; if (authState === 'login') return <Login onSuccess={() => setAuthState('ok')} />; return ( <div> <nav className="tabs"> {TABS.map(t => ( <button className={`tab ${tab === t.id ? 'active' : ''}`} onClick={() => setTab(t.id)}>{t.label}</button> ))} </nav> {tab === 'dashboard' && <DashboardTab />} {tab === 'routing' && <RoutingTab />} {tab === 'logs' && <Logs />} {tab === 'infra' && <InfraTab />} {tab === 'tools' && <ToolsTab />} </div> ); }

Grouping logic: Dashboard = Overview + Charts + Quota. Routing = Providers + Models + Combos. Logs = standalone (sering diakses). Infra = Keys + Proxies + Tunnel + OAuth + Settings. Tools = CLI + Translator + Media + Stats + System.

CSS buat pill sub-nav (di App.css). Pakai flex-wrap biar pills turun ke baris bawah kalau kepenuhan, bukan bikin scroll:

/* App.css — sub-navigation pills */ .sub-nav { display: flex; gap: 6px; flex-wrap: wrap; /* wrap, jangan scroll */ margin-bottom: 16px; } .sub-nav-pill { padding: 6px 12px; background: var(--bg-elev); border: 1px solid var(--border); border-radius: 3px; color: var(--text-muted); font-family: var(--font-mono); font-size: 11px; cursor: pointer; transition: all 0.15s; } .sub-nav-pill.active { color: var(--accent); border-color: var(--accent); background: rgba(255, 106, 26, 0.06); /* tint accent tipis */ }
— KENAPA 5 TABS, BUKAN 17

Awalnya tiap fitur dapet tab sendiri: 17 tab total. Di mobile webview Telegram (lebar ~360px), itu jadi horizontal scroll panjang yang gak kebaca: user kudu swipe-swipe nyari tab. Solusi: kelompokin fitur yang berhubungan ke dalam 5 top-level tab, terus pakai pill sub-nav di dalamnya. Top-level muat tanpa scroll, sub-nav cuma muncul pas tab dibuka. State sub-nav di-manage lokal per tab pakai useState sendiri-sendiri, jadi posisi sub-nav kesimpen pas pindah-pindah tab.

— PITFALL: TypeScript & window.Telegram

TS bakal error Property 'Telegram' does not exist on Window. Solusi cepat: // @ts-ignore di atas tiap akses, atau (lebih rapi) deklarasi global di types.ts: declare global { interface Window { Telegram?: any } }.

STEP 04

Backend: Express Serve Static + API

Backend punya 3 tugas: (1) serve hasil build frontend (folder dist/), (2) handle auth (login/session), (3) reverse-proxy request ke API 9router live sambil nyuntik JWT. Satu server, satu port. Gampang di-deploy.

// server/index.js import express from 'express'; import cors from 'cors'; import { createProxyMiddleware } from 'http-proxy-middleware'; import dotenv from 'dotenv'; import { getAuthToken } from './lib/auth.js'; import { telegramAuthMiddleware } from './lib/telegram-auth.js'; import { passwordGate, makeSessionToken } from './lib/gate.js'; import path from 'path'; import { fileURLToPath } from 'url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); dotenv.config({ path: path.resolve(__dirname, '../.env') }); const app = express(); const PORT = parseInt(process.env.PORT || '9122'); const NROUTER_URL = process.env.NROUTER_URL || 'http://localhost:20128'; app.use(cors({ origin: process.env.CORS_ORIGIN || '*', credentials: true })); app.use('/api', express.json()); // JSON parsing HANYA buat /api (jangan /proxy) // Health + login + auth-status (no auth). lihat Step 06 detail gate-nya app.get('/api/health', (req, res) => res.json({ status: 'ok', upstream: NROUTER_URL })); // --- Proxy chain: password gate -> telegram gate -> mint JWT -> forward --- app.use('/proxy', passwordGate); app.use('/proxy', telegramAuthMiddleware); app.use('/proxy', async (req, res, next) => { try { req._nrouterToken = await getAuthToken(); next(); } catch (e) { res.status(500).json({ error: 'Auth token generation failed' }); } }); app.use('/proxy', createProxyMiddleware({ target: NROUTER_URL, changeOrigin: true, pathRewrite: { '^/proxy': '' }, on: { proxyReq: (proxyReq, req) => { if (req._nrouterToken) proxyReq.setHeader('Cookie', `auth_token=${req._nrouterToken}`); proxyReq.removeHeader('authorization'); }, }, })); // --- Serve frontend build (dist/) untuk semua route lain --- const dist = path.resolve(__dirname, '../dist'); app.use(express.static(dist)); app.get('*', (req, res) => res.sendFile(path.join(dist, 'index.html'))); app.listen(PORT, '0.0.0.0', () => console.log(`Mini App di :${PORT} -> ${NROUTER_URL}`));
— KENAPA REVERSE-PROXY, BUKAN BACA DB

9router udah punya REST API lengkap di localhost:20128. Daripada baca SQLite-nya langsung (rapuh, schema bisa berubah antar versi), miniapp cukup proxy ke API itu. Backend nyuntik JWT (Step berikutnya) jadi browser gak pernah pegang kredensial upstream. Frontend tinggal hit /proxy/api/... seolah ngomong langsung ke 9router.

— PITFALL: JSON parser nelan body proxy

Pasang express.json() CUMA di /api, JANGAN global. Kalau global, dia bakal consume request body sebelum sampai ke proxy middleware → POST/PUT ke upstream jadi kosong. Dan SPA fallback app.get('*') harus PALING BAWAH.

STEP 05

JWT Mint + Password Gate (Server-Side Secrets)

Ini jantung keamanan miniapp. 9router butuh auth cookie (auth_token) buat tiap request ke API-nya. Daripada nyimpen kredensial itu di browser, backend mint JWT sendiri pakai secret 9router yang ada di server, lalu nyuntiknya pas proxy. Browser gak pernah liat secret apa pun.

// server/lib/auth.js. mint JWT buat upstream 9router import { SignJWT } from 'jose'; import fs from 'fs'; let cachedToken = null, tokenExpiry = 0; function getSecret() { const p = process.env.NROUTER_JWT_SECRET_PATH || '/root/.9router/jwt-secret'; return new TextEncoder().encode(fs.readFileSync(p, 'utf8').trim()); } export async function getAuthToken() { const now = Date.now(); // Cache token, refresh 5 menit sebelum expiry if (cachedToken && tokenExpiry - now > 5 * 60 * 1000) return cachedToken; cachedToken = await new SignJWT({ authenticated: true }) .setProtectedHeader({ alg: 'HS256' }) .setIssuedAt() .setExpirationTime('23h') .sign(getSecret()); tokenExpiry = now + 23 * 60 * 60 * 1000; return cachedToken; }

Lapisan kedua: password gate (gate.js). Session token = HMAC(password, server-secret), disimpen di cookie HttpOnly. Attacker gak bisa forge tanpa tau password.

// server/lib/gate.js. session token via HMAC import crypto from 'crypto'; import fs from 'fs'; import path from 'path'; import os from 'os'; // Secret di-persist biar restart gak nge-invalidate session function getSessionSecret() { const file = path.join(os.homedir(), '.9router', 'miniapp-session-secret'); try { return fs.readFileSync(file, 'utf8').trim(); } catch { const s = crypto.randomBytes(32).toString('hex'); fs.writeFileSync(file, s, { mode: 0o600 }); return s; } } const SECRET = getSessionSecret(); export function makeSessionToken(password) { return crypto.createHmac('sha256', SECRET).update(password).digest('hex'); } // Middleware: tolak kalau APP_PASSWORD di-set tapi cookie session gak valid export function passwordGate(req, res, next) { const pw = process.env.APP_PASSWORD; if (!pw) return next(); // password opsional const cookie = (req.headers.cookie || '').match(/mini_session=([^;]+)/); if (cookie && cookie[1] === makeSessionToken(pw)) return next(); res.status(401).json({ error: 'auth_required' }); }
— PRINSIP

Aturan emas: secret hidup di server, gak pernah ke client. Frontend cuma kirim password lewat /api/login (HTTPS) → dapet cookie session. Token JWT buat upstream di-mint & di-cache server-side. Kalau lo taruh secret di browser terus di-hide via JS, itu tetap bocor (buka DevTools).

STEP 06

Auth: Verifikasi Telegram initData (HMAC-SHA256)

Ini yang bikin Mini App beda dari web app biasa. Telegram ngasih string initData ke webview (berisi data user + hash). Server lo verifikasi hash itu pakai bot token. Kalau valid, berarti request beneran dari Telegram dan user-nya asli. Gak bisa dipalsuin tanpa bot token.

Algoritmanya per dokumentasi resmi Telegram:

// server/lib/telegram-auth.js import crypto from 'crypto'; export function verifyTelegramInitData(initData, botToken) { if (!initData || !botToken) return null; const params = new URLSearchParams(initData); const hash = params.get('hash'); if (!hash) return null; // 1. Susun data-check-string: semua param kecuali hash, sorted, join \n const checkArr = []; for (const [key, val] of params.entries()) { if (key !== 'hash') checkArr.push(`${key}=${val}`); } checkArr.sort(); const dataCheckString = checkArr.join('\n'); // 2. secret_key = HMAC_SHA256("WebAppData", bot_token) const secretKey = crypto.createHmac('sha256', 'WebAppData') .update(botToken).digest(); // 3. computed = HMAC_SHA256(secret_key, dataCheckString). bandingin sama hash const computed = crypto.createHmac('sha256', secretKey) .update(dataCheckString).digest('hex'); if (computed !== hash) return null; // PALSU. tolak const user = JSON.parse(params.get('user') || '{}'); return { id: user.id, username: user.username, firstName: user.first_name }; }

Bungkus jadi middleware Express. Header format Authorization: tma <initData>, plus whitelist Telegram ID (opsional, biar cuma lo yang bisa akses):

export function telegramAuthMiddleware(req, res, next) { const botToken = process.env.MINIAPP_BOT_TOKEN; const allowed = (process.env.ALLOWED_TG_IDS || '') .split(',').map(s => parseInt(s.trim())).filter(Boolean); // Dev mode: tanpa bot token, skip auth (CUMA buat lokal) if (!botToken) { console.warn('[auth] no MINIAPP_BOT_TOKEN — DEV MODE tanpa auth'); return next(); } const h = req.headers['authorization'] || ''; const initData = h.startsWith('tma ') ? h.slice(4) : null; const user = verifyTelegramInitData(initData, botToken); if (!user) return res.status(401).json({ error: 'Invalid Telegram initData' }); if (allowed.length && !allowed.includes(user.id)) return res.status(403).json({ error: 'Not whitelisted' }); req.tgUser = user; next(); }
— PITFALL: Dev mode bocor ke prod

Kalau MINIAPP_BOT_TOKEN gak di-set, middleware skip auth total. Enak buat dev lokal, BAHAYA di production. Siapa aja bisa hit API lo. Pastiin .env production selalu punya bot token + ALLOWED_TG_IDS. Jangan pernah deploy tanpa dua itu.

STEP 07

Frontend: Kirim initData ke Tiap Request

Frontend hit backend lewat base /proxy/api (di-rewrite proxy ke API 9router). Tiap request bawa cookie session (dari login) + selipin initData kalau ada. Bungkus dalam satu wrapper biar gak repeat.

// src/config.ts export const API_BASE = '/proxy/api'; // di-rewrite backend -> 9router // src/lib/api.ts import { API_BASE } from '../config'; function getInitData(): string { // @ts-ignore return window.Telegram?.WebApp?.initData || ''; } async function request<T>(ep: string, opts: RequestInit = {}, raw = false): Promise<T> { const initData = getInitData(); const headers: Record<string, string> = { ...opts.headers as any }; if (initData) headers['Authorization'] = `tma ${initData}`; // "tma <initData>" const url = raw ? ep : `${API_BASE}${ep}`; const res = await fetch(url, { ...opts, headers, credentials: 'include' }); if (!res.ok) { if (res.status === 401) window.dispatchEvent(new CustomEvent('auth_required')); // trigger login throw new Error(`HTTP ${res.status}`); } return res.json(); } // API helper object. full HTTP verbs + raw escape hatches export const api = { get: <T>(ep: string) => request<T>(ep), post: <T>(ep: string, body?: any) => request<T>(ep, { method: 'POST', body: body ? JSON.stringify(body) : undefined }), put: <T>(ep: string, body?: any) => request<T>(ep, { method: 'PUT', body: body ? JSON.stringify(body) : undefined }), patch: <T>(ep: string, body?: any) => request<T>(ep, { method: 'PATCH', body: body ? JSON.stringify(body) : undefined }), delete: <T>(ep: string, body?: any) => request<T>(ep, { method: 'DELETE', body: body ? JSON.stringify(body) : undefined }), // raw = ke path non-/proxy (e.g. /api/login, /api/stats/system) rawGet: <T>(p: string) => request<T>(p, {}, true), rawPost: <T>(p: string, body?: any) => request<T>(p, { method: 'POST', body: body ? JSON.stringify(body) : undefined }, true), }; // Login (raw, ke /api/login bukan /proxy): await api.rawPost('/api/login', { password }) // Data: const d = await api.get<Overview>('/overview'); // -> /proxy/api/overview // Toggle: await api.put(`/providers/${id}`, { enabled: true }); // -> /proxy/api/providers/...
— DUA JALUR AUTH

Ada 2 cara akses: (1) password gate: buka di browser biasa, login lewat /api/login dapet cookie session. (2) Telegram initData: buka dari dalam Telegram, initData ke-inject otomatis & di-verify backend. Di luar Telegram initData kosong, jadi password jadi fallback. Event auth_required (dari response 401) yang trigger UI login muncul.

STEP 08

Build & Deploy via Cloudflare Tunnel

Telegram WAJIB butuh URL HTTPS buat Mini App. Cara paling cepet tanpa beli domain/VPS dedicated: cloudflared tunnel, expose server local lo ke URL HTTPS publik instan, gratis, zero config.

Apa itu Cloudflare Tunnel?

Cloudflare Tunnel (dulu namanya Argo Tunnel) adalah reverse tunnel yang bikin server di laptop/PC/VPS lo bisa diakses dari internet tanpa:

Cara kerja:

# Flow diagram [Server local lo:9122] ← cloudflared client → Cloudflare Edge → [Public HTTPS URL] (outbound connection) (reverse proxy)

Cloudflared client di mesin lo buka outbound connection (keluar) ke Cloudflare edge. Gak ada inbound port yang dibuka. Request dari internet masuk ke Cloudflare, terus di-forward lewat tunnel yang udah established. Secure by default, zero-trust networking.

— KENAPA CF TUNNEL vs ALTERNATIVES

ngrok: sama konsepnya, tapi free tier cuma 1 tunnel concurrent + URL random berubah. localtunnel: sering down, gak stabil. Direct VPS + Caddy: butuh bayar VPS + domain, setup lebih lama. CF Tunnel: gratis unlimited, URL stabil (bisa pake domain sendiri), Cloudflare network = fast globally, zero maintenance.

Install cloudflared

Download binary official dari Cloudflare. Pilih sesuai OS lo:

# Linux x86_64 (Ubuntu/Debian) $ wget https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 $ sudo mv cloudflared-linux-amd64 /usr/local/bin/cloudflared $ sudo chmod +x /usr/local/bin/cloudflared $ cloudflared --version # verify install # macOS (via Homebrew) $ brew install cloudflare/cloudflare/cloudflared # Windows (via winget atau download .exe) PS> winget install --id Cloudflare.cloudflared

Docs lengkap install: developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads

Quick Tunnel (URL random, no account needed)

Build frontend dulu, jalanin server, baru tunnel:

# 1. Build frontend → hasil ke dist/ $ npm run build # 2. Set env production (server/.env) $ cat > server/.env <<EOF PORT=9122 NROUTER_URL=http://localhost:20128 APP_PASSWORD=ganti-password-kuat NROUTER_JWT_SECRET_PATH=/root/.9router/jwt-secret MINIAPP_BOT_TOKEN=123456:ABC-your-bot-token ALLOWED_TG_IDS=123456789 EOF # 3. Jalanin server $ cd server && node index.js Mini App di http://localhost:9122 # 4. Tunnel (terminal lain). dapet URL HTTPS instan $ cloudflared tunnel --url http://localhost:9122 https://random-words-1234.trycloudflare.com ← ini URL miniapp lo

Verify tunnel aktif: buka URL di browser (bukan dari dalam Telegram dulu). Kalau muncul UI miniapp lo, tunnel jalan. Kalau "Bad Gateway" = server lo mati atau port salah.

— PITFALL: Quick tunnel URL berubah tiap restart

Quick tunnel (cloudflared tunnel --url) kasih URL random yang berubah tiap restart. OK buat testing cepet, tapi buat production atau share ke user harus pake named tunnel (permanent URL). Jangan daftarin quick tunnel URL ke BotFather, lo harus update terus.

Named Tunnel (permanent URL, production-ready)

Buat production yang URL-nya gak berubah, lo butuh:

Setup named tunnel + route ke subdomain lo:

# 1. Login ke Cloudflare account (browser kebuka, authorize) $ cloudflared tunnel login # 2. Bikin tunnel dengan nama "miniapp" $ cloudflared tunnel create miniapp # Output: Created tunnel miniapp with id abc-123-def-456 # Credentials disimpen di ~/.cloudflared/abc-123-def-456.json # 3. Bikin DNS record subdomain → tunnel $ cloudflared tunnel route dns miniapp app.gitluke.dev # Ganti "app.gitluke.dev" dengan subdomain lo # Domain harus udah di Cloudflare DNS (cek dashboard.cloudflare.com) # 4. Jalanin tunnel $ cloudflared tunnel run --url http://localhost:9122 miniapp # URL miniapp lo: https://app.gitluke.dev (permanent, gak berubah)

Verify named tunnel:

# Cek tunnel terdaftar $ cloudflared tunnel list # Test URL dari laptop lain / HP / browser $ curl -I https://app.gitluke.dev # Expected: HTTP/2 200 (atau redirect, asal bukan 502/503)
— PITFALL: Domain belum di Cloudflare DNS

Command route dns akan fail kalau domain lo belum di-manage Cloudflare. Cara check: login ke dash.cloudflare.com, harus muncul domain lo di list "Websites". Kalau belum, ikutin "Add a Site" wizard (gratis): change nameservers di registrar lo (Namecheap/GoDaddy/dll) ke nameservers Cloudflare. Tunggu propagasi 24 jam, baru bisa run command tunnel route dns.

— ALTERNATIF: Caddy / VPS langsung

Kalau lo udah punya VPS dengan domain dan public IP, pakai Caddy reverse proxy (auto-HTTPS via Let's Encrypt): app.gitluke.dev { reverse_proxy 127.0.0.1:9122 }. Trade-off: butuh VPS bayaran (~€4/mo Hetzner) tapi gak perlu Cloudflare. CF Tunnel: gratis tapi traffic harus lewat Cloudflare. Pilih sesuai requirement lo.

Run cloudflared sebagai systemd service (auto-start)

Biar tunnel jalan terus walaupun lo logout/reboot:

# Install cloudflared sebagai systemd service (otomatis pake config existing) $ sudo cloudflared service install $ sudo systemctl enable cloudflared $ sudo systemctl start cloudflared $ systemctl status cloudflared # → active (running)

Systemd unit file untuk Mini App server (auto-start, auto-restart):

# /etc/systemd/system/9router-miniapp.service [Unit] Description=9router Mini App Dashboard After=network.target [Service] Type=simple WorkingDirectory=/root/workspace/9router-miniapp/server ExecStart=/usr/bin/node /root/workspace/9router-miniapp/server/index.js Restart=on-failure RestartSec=5 Environment=NODE_ENV=production [Install] WantedBy=multi-user.target
# Enable + start $ sudo systemctl daemon-reload $ sudo systemctl enable 9router-miniapp $ sudo systemctl start 9router-miniapp $ systemctl status 9router-miniapp # → active (running)
STEP 09

Daftarin ke BotFather (Go Live)

Langkah terakhir: hubungkan URL HTTPS ke bot Telegram lo. Ada 2 cara nampilin Mini App ke user.

Cara 1 · Menu Button (tombol di pojok kiri input chat, paling umum):

  1. Buka @BotFather
  2. Pilih bot lo → Bot SettingsMenu ButtonConfigure menu button
  3. Paste URL HTTPS (e.g. https://app.gitluke.dev)
  4. Kasih label tombol (e.g. "Dashboard")

Cara 2 · Direct Link / Main Mini App (buka via t.me/botusername/appname):

  1. @BotFather → /newapp → pilih bot
  2. Isi title, description, photo (640×360), URL HTTPS
  3. Set short name → dapet link t.me/yourbot/yourapp
# Test cepet: buka bot lo di Telegram, klik Menu Button. # Mini App lo render full-screen di dalam Telegram. # initData otomatis ke-inject → auth jalan → dashboard tampil.
— PITFALL: Cache webview

Telegram nge-cache webview agresif. Abis update & redeploy, kadang masih nampilin versi lama. Force refresh: tutup-buka Mini App, atau Settings Telegram → clear cache. Di desktop, klik kanan dalam Mini App → Reload.

STEP 10

Bonus: Biometric Login (WebAuthn / Passkey)

Skip password pakai Face ID / Touch ID / Windows Hello / Android fingerprint. Pakai WebAuthn: standar browser buat passkey, gak butuh service eksternal. Backend pakai @simplewebauthn/server, frontend @simplewebauthn/browser. Polanya: challenge-response. Server kasih challenge acak, authenticator (Face ID / fingerprint dll) nandatanganin, server verifikasi signature.

— Cross-platform support: iOS, Android, Windows, Mac

iOS: Face ID, Touch ID (Safari 14+, iOS 14+). Android: Fingerprint sensor, face unlock, screen lock PIN/pattern (Chrome 67+, Android 7+). Windows: Windows Hello (face/fingerprint/PIN), Chrome/Edge. Mac: Touch ID (Safari 14+, Chrome 88+, macOS Big Sur+). WebAuthn standar W3C, semua modern browser support. Yang perlu diperhatiin: behavior di Telegram WebView (covered di pitfall section bawah).

Backend punya 2 flow: registration (daftarin device, butuh sesi password aktif) dan authentication (login pakai passkey). Tiap flow = 2 endpoint: options (kasih challenge) + verify (cek signature). Credential disimpen di file JSON mode 0600.

// server/lib/webauthn.js. setup + helpers import { generateRegistrationOptions, verifyRegistrationResponse, generateAuthenticationOptions, verifyAuthenticationResponse, } from '@simplewebauthn/server'; import fs from 'fs'; import path from 'path'; import os from 'os'; const STORE_PATH = path.join(os.homedir(), '.9router', 'miniapp-webauthn.json'); const CHALLENGE_TTL_MS = 5 * 60 * 1000; // In-memory challenge store. CATATAN: ke-wipe pas restart (lihat pitfall) const challenges = new Map(); function loadStore() { try { return JSON.parse(fs.readFileSync(STORE_PATH, 'utf8')); } catch { return { credentials: [] }; } } function saveStore(store) { fs.mkdirSync(path.dirname(STORE_PATH), { recursive: true }); fs.writeFileSync(STORE_PATH, JSON.stringify(store, null, 2), { mode: 0o600 }); } // rpID = registrable domain (no port/proto). Di balik CF tunnel, baca X-Forwarded-Host function getRpID(req) { return (req.headers['x-forwarded-host'] || req.headers.host || 'localhost').split(':')[0]; } function getOrigin(req) { const proto = req.headers['x-forwarded-proto'] || req.protocol || 'https'; const host = req.headers['x-forwarded-host'] || req.headers.host; return `${proto}://${host}`; } function putChallenge(c) { challenges.set(c, { expires: Date.now() + CHALLENGE_TTL_MS }); for (const [k, v] of challenges.entries()) if (v.expires < Date.now()) challenges.delete(k); }

Registration: issue options (challenge) lalu verify response. Challenge yang di-tanda-tangani authenticator ada di clientDataJSON (base64url). Kita decode, cocokin sama yang kita simpan, baru verify.

// server/lib/webauthn.js. REGISTRATION export async function registrationOptions(req, res) { const store = loadStore(); const options = await generateRegistrationOptions({ rpName: '9router miniapp', rpID: getRpID(req), userID: new TextEncoder().encode('9router-miniapp-user'), userName: 'admin', attestationType: 'none', excludeCredentials: store.credentials.map(c => ({ id: c.credentialID, type: 'public-key', transports: c.transports, })), authenticatorSelection: { residentKey: 'preferred', userVerification: 'preferred', // JANGAN set authenticatorAttachment:'platform'. bikin fail di in-app browser }, }); putChallenge(options.challenge); res.json(options); } export async function registrationVerify(req, res) { const body = req.body; // Decode challenge yang authenticator tanda-tangani dari clientDataJSON let recv = null; try { const cd = JSON.parse(Buffer.from(body.response?.clientDataJSON || '', 'base64url').toString()); recv = cd.challenge; } catch {} const known = Array.from(challenges.keys()); try { const v = await verifyRegistrationResponse({ response: body, // Pass string langsung kalau challenge dikenal. JANGAN async-callback Map lookup expectedChallenge: recv && known.includes(recv) ? recv : async () => false, expectedOrigin: getOrigin(req), expectedRPID: getRpID(req), requireUserVerification: false, }); if (!v.verified || !v.registrationInfo) return res.status(400).json({ error: 'verification_failed' }); if (recv) challenges.delete(recv); const { credential } = v.registrationInfo; const store = loadStore(); store.credentials.push({ credentialID: credential.id, publicKey: Buffer.from(credential.publicKey).toString('base64'), counter: credential.counter, transports: body.response?.transports || [], label: (body.label || 'Device').slice(0, 64), addedAt: new Date().toISOString(), }); saveStore(store); res.json({ ok: true }); } catch (err) { res.status(400).json({ error: err.message }); } }

Authentication (login pakai passkey) polanya sama: generateAuthenticationOptions → verify pakai public key yang tersimpan. Kalau verified, set cookie sesi yang SAMA persis dengan login password, jadi sisa app gak perlu tau bedanya.

// server/lib/webauthn.js. AUTHENTICATION (ringkas) export async function authenticationOptions(req, res) { const store = loadStore(); if (store.credentials.length === 0) return res.status(404).json({ error: 'no_credentials_registered' }); const options = await generateAuthenticationOptions({ rpID: getRpID(req), userVerification: 'preferred', allowCredentials: store.credentials.map(c => ({ id: c.credentialID, type: 'public-key', transports: c.transports, })), }); putChallenge(options.challenge); res.json(options); } export async function authenticationVerify(req, res) { const body = req.body, store = loadStore(); const cred = store.credentials.find(c => c.credentialID === body.id); if (!cred) return res.status(404).json({ error: 'credential_not_found' }); let recv = null; try { const cd = JSON.parse(Buffer.from(body.response?.clientDataJSON || '', 'base64url').toString()); recv = cd.challenge; } catch {} const known = Array.from(challenges.keys()); const v = await verifyAuthenticationResponse({ response: body, expectedChallenge: recv && known.includes(recv) ? recv : async () => false, expectedOrigin: getOrigin(req), expectedRPID: getRpID(req), credential: { id: cred.credentialID, publicKey: Buffer.from(cred.publicKey, 'base64'), counter: cred.counter, transports: cred.transports, }, requireUserVerification: false, }); if (!v.verified) { res.status(400).json({ error: 'verification_failed' }); return null; } if (recv) challenges.delete(recv); cred.counter = v.authenticationInfo.newCounter; cred.lastUsed = new Date().toISOString(); saveStore(store); return { ok: true, label: cred.label }; }

Wire ke Express. Registration butuh sesi password (cuma user yang udah login boleh daftarin passkey). Login-verify yang nge-set cookie sesi:

// server/index.js. webauthn routes import { registrationOptions, registrationVerify, authenticationOptions, authenticationVerify, listCredentials, deleteCredential, hasCredentials, } from './lib/webauthn.js'; // Register + manage: butuh sesi password aktif app.post('/api/webauthn/register-options', requirePasswordSession, registrationOptions); app.post('/api/webauthn/register-verify', requirePasswordSession, registrationVerify); app.get('/api/webauthn/credentials', requirePasswordSession, listCredentials); app.delete('/api/webauthn/credentials/:id', requirePasswordSession, deleteCredential); // Login: OPEN (ini mekanisme login-nya sendiri) app.post('/api/webauthn/login-options', authenticationOptions); app.post('/api/webauthn/login-verify', async (req, res) => { const r = await authenticationVerify(req, res); if (r?.ok) { const token = makeSessionToken(APP_PASSWORD); // cookie SAMA kayak login password res.setHeader('Set-Cookie', `mini_session=${token}; Path=/; Max-Age=2592000; HttpOnly; Secure; SameSite=Lax`); res.json({ ok: true, label: r.label }); } });
— PITFALL #2: challenge verifier pakai async-callback

Versi awal pakai expectedChallenge: async (c) => map.has(c). Kelihatan benar, tapi rapuh: tiap kali server restart, Map in-memory ke-WIPE. Kalau user ambil options SEBELUM restart lalu tap Face ID SESUDAH restart → challenge ilang → "Custom challenge verifier returned false". Fix: decode challenge dari clientDataJSON, cocokin manual, pass sebagai string. Buat production sungguhan, persist challenge ke Redis/file biar survive restart.

— PITFALL #1: rpID di balik reverse-proxy

WebAuthn ngecek rpID (domain) & origin harus match persis sama yang dilihat browser. Di balik Cloudflare Tunnel, req.host bisa jadi localhost:9122: itu BEDA dari 9router.gitluke.dev yang dilihat browser → verify gagal. Solusi: baca X-Forwarded-Host & X-Forwarded-Proto dulu, fallback ke req.host.

— PITFALL #3: prompt() break iOS gesture chain

Ini bug paling halus. Kalau lo panggil prompt('Device name?') atau alert() SEBELUM startRegistration(), iOS Safari langsung throw NotAllowedError TANPA dialog Face ID muncul. Alasan: transient user activation (gesture chain) dari tap tombol putus begitu browser modal muncul. Solusi: pakai inline input field (value udah siap), baru panggil startRegistration() langsung dari onClick.

— PITFALL #4: Telegram WebView behavior (iOS vs Android)

iOS: Telegram buka Mini App di WKWebView yang advertise window.PublicKeyCredential (lolos browserSupportsWebAuthn()) tapi SELALU throw NotAllowedError pas dipanggil. Bahkan "Open in Safari" dari Telegram buka SFSafariViewController (in-app browser) yang juga gak support. User HARUS copy URL & paste di Safari app standalone (bukan dari dalam Telegram).

Android: Telegram buka Mini App di Custom Tab (Chrome-based WebView). Behavior BEDA dari iOS:

  • Chrome Custom Tab (Telegram 10.x+): WebAuthn WORKS karena ini basically Chrome. Fingerprint/face unlock muncul normal. Gak perlu redirect ke external browser.
  • Telegram internal WebView (older versions): sama kayak iOS, PublicKeyCredential advertised tapi throw error. User harus update Telegram ke versi terbaru.
  • Samsung Internet WebView: beberapa device Samsung redirect ke Samsung Pass bukan Android native. Test di device target.

Detection strategy buat handle cross-platform:

// src/lib/biometric-support.ts import { browserSupportsWebAuthn, platformAuthenticatorIsAvailable } from '@simplewebauthn/browser'; export async function canUseBiometric(): Promise<{ supported: boolean; reason?: string; needsExternalBrowser?: boolean; }> { // 1. Browser support check (API exists?) if (!browserSupportsWebAuthn()) { return { supported: false, reason: 'Browser gak support WebAuthn' }; } // 2. Platform authenticator check (ada Face ID/fingerprint/PIN?) const hasPlatform = await platformAuthenticatorIsAvailable(); if (!hasPlatform) { return { supported: false, reason: 'Gak ada biometric di device ini' }; } // 3. Telegram WebView detection const inTelegram = !!(window as any).Telegram?.WebApp?.initData?.length; const ua = navigator.userAgent.toLowerCase(); if (inTelegram) { // Android Chrome Custom Tab: WebAuthn works const isAndroid = ua.includes('android'); const isChromeCustomTab = isAndroid && ua.includes('chrome') && !ua.includes('wv'); if (isAndroid && isChromeCustomTab) { // Android + Chrome Custom Tab = biometric works return { supported: true }; } // iOS WKWebView atau Android old WebView: redirect ke external browser return { supported: false, reason: 'Biometric gak jalan di Telegram WebView', needsExternalBrowser: true, }; } // 4. Standalone browser (Safari, Chrome, Firefox): all good return { supported: true }; }
// src/components/BiometricGate.tsx. UI logic per-platform const [bioStatus, setBioStatus] = useState<Awaited<ReturnType<typeof canUseBiometric>> | null>(null); useEffect(() => { canUseBiometric().then(setBioStatus); }, []); // Render based on status if (!bioStatus) return null; // loading if (bioStatus.supported) { // Show biometric login/register buttons as normal return <BiometricManager />; } if (bioStatus.needsExternalBrowser) { // Show "Open in browser" link (iOS + old Android) const url = window.location.href; return ( <div className="bio-external"> <p>Biometric login perlu browser standalone.</p> <button onClick={() => navigator.clipboard.writeText(url)}> Copy URL (paste di Chrome/Safari) </button> </div> ); } // No biometric available: hide entirely, fallback to password return null;
— Android-specific: userVerification preference

Saat generate options (server), set userVerification: 'preferred' (default). Jangan 'required' kalau target Android old devices: beberapa Android 7-9 punya fingerprint tapi gak advertise isUserVerifyingPlatformAuthenticatorAvailable. Dengan 'preferred', Android fallback ke screen lock PIN kalau fingerprint sensor gak register. Dengan 'required', flow langsung fail di device itu.

Frontend: Register UI pake inline input field (bukan prompt) + biometric login button di Login page.

// src/components/BiometricManager.tsx. inti register (simplified) import { startRegistration, browserSupportsWebAuthn } from '@simplewebauthn/browser'; // Detect Telegram WebView. WAJIB: initData.length > 0, bukan cuma truthy function isTelegramWebView(): boolean { const initData = window.Telegram?.WebApp?.initData; if (typeof initData === 'string' && initData.length > 0) return true; if (/TelegramBot/i.test(navigator.userAgent)) return true; return false; } // Register flow. panggil langsung dari onClick, TANPA prompt() const register = async () => { const optsRes = await fetch('/api/webauthn/register-options', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: '{}', }); const opts = await optsRes.json(); // startRegistration() HARUS dipanggil langsung di onClick. NO modal sebelumnya! const attResp = await startRegistration({ optionsJSON: opts }); await fetch('/api/webauthn/register-verify', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...attResp, label }), // label dari inline <input> }); }; // UI: inline <input> buat device name → button register → list credentials
// src/components/Login.tsx. biometric login button (simplified) import { startAuthentication, browserSupportsWebAuthn } from '@simplewebauthn/browser'; // Cek: browser support + bukan TG WebView + server punya credential // Probe: GET /api/auth-status → { biometric: true/false } const loginBiometric = async () => { const optsRes = await fetch('/api/webauthn/login-options', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: '{}', }); const opts = await optsRes.json(); const credential = await startAuthentication({ optionsJSON: opts }); await fetch('/api/webauthn/login-verify', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(credential), }); onSuccess(); // session cookie udah ke-set oleh server, redirect ke dashboard }; // Button ditampilin CUMA kalau: supportsBio && hasBiometric && !isTelegramWebView()
STEP 11

Bonus: Live Server Stats (CPU / RAM / Disk / Network)

Tab Stats: monitor server real-time. Auto-refresh tiap 3 detik. Backend pakai Node os module + /proc fs (Linux) + shell df/du. Frontend tinggal poll endpoint & render gauge.

Backend stats helper. CPU usage di-compute dari delta os.cpus() antar dua call (single-call doang return cumulative since boot, gak useful):

// server/lib/stats.js. inti import os from 'os'; import fs from 'fs'; import { execSync } from 'child_process'; let lastCpu = null; function readCpuTimes() { let user=0, nice=0, sys=0, idle=0, irq=0; for (const c of os.cpus()) { user+=c.times.user; nice+=c.times.nice; sys+=c.times.sys; idle+=c.times.idle; irq+=c.times.irq; } return { user, nice, sys, idle, irq, total: user+nice+sys+idle+irq }; } function cpuPercent() { const now = readCpuTimes(); if (!lastCpu) { lastCpu = now; return 0; } const totalDiff = now.total - lastCpu.total; const idleDiff = now.idle - lastCpu.idle; lastCpu = now; return totalDiff > 0 ? Math.round((1 - idleDiff/totalDiff) * 1000)/10 : 0; } function diskUsage(p) { const out = execSync(`df -k --output=size,used,avail ${JSON.stringify(p)} | tail -1`, { encoding: 'utf8' }); const [size, used, avail] = out.trim().split(/\s+/).map(Number); return { total: size*1024, used: used*1024, available: avail*1024, percent: Math.round((used/size)*1000)/10 }; } function readNetwork() { // Baca /proc/net/dev. skip lo & docker interfaces const data = fs.readFileSync('/proc/net/dev', 'utf8'); let rxBytes=0, txBytes=0; data.split('\n').slice(2).forEach(line => { const m = line.trim().match(/^(\S+):\s+(\d+)\s+\d+\s+\d+\s+\d+\s+\d+\s+\d+\s+\d+\s+\d+\s+(\d+)/); if (m && !m[1].startsWith('lo') && !m[1].startsWith('docker')) { rxBytes += parseInt(m[2]); txBytes += parseInt(m[3]); } }); return { rxBytes, txBytes }; } export function getStats() { const totalMem = os.totalmem(), freeMem = os.freemem(); return { timestamp: new Date().toISOString(), uptime: os.uptime(), hostname: os.hostname(), platform: os.platform(), arch: os.arch(), cpu: { model: os.cpus()[0]?.model, cores: os.cpus().length, loadAvg: os.loadavg(), usagePercent: cpuPercent() }, memory: { total: totalMem, used: totalMem-freeMem, free: freeMem, percent: Math.round(((totalMem-freeMem)/totalMem)*1000)/10 }, swap: readSwap(), // Opsional: deteksi swap via /proc/meminfo disk: { root: diskUsage('/'), home: diskUsage(os.homedir()) }, network: readNetwork(), node: { version: process.version, pid: process.pid, rssBytes: process.memoryUsage().rss }, }; }

Bonus enhancement: deteksi swap memory via /proc/meminfo. Swap = virtual memory fallback kalau RAM penuh. Gauge cuma muncul kalau swap aktif (total > 0):

// server/lib/stats.js — tambahan readSwap() function readSwap() { try { const data = fs.readFileSync('/proc/meminfo', 'utf8'); const totalMatch = data.match(/SwapTotal:\s+(\d+)\s+kB/); const freeMatch = data.match(/SwapFree:\s+(\d+)\s+kB/); if (!totalMatch || !freeMatch) return null; const total = parseInt(totalMatch[1]) * 1024; const free = parseInt(freeMatch[1]) * 1024; const used = total - free; return { total, used, free, percent: total > 0 ? Math.round((used/total)*1000)/10 : 0 }; } catch { return null; } }

Wire endpoint di Express, di-protect sama password gate biar gak public:

// server/index.js import { getStats } from './lib/stats.js'; app.get('/api/stats/system', requirePasswordSession, (req, res) => { res.json(getStats()); });

Frontend Stats page. Pakai api.rawGet karena endpoint-nya di backend miniapp sendiri (bukan di-proxy ke 9router). Auto-refresh dengan setInterval:

// src/pages/Stats.tsx. inti import { useEffect, useState, useRef } from 'react'; import { api } from '../lib/api'; export default function Stats() { const [s, setS] = useState<any>(null); const timer = useRef<any>(null); const load = async () => { try { setS(await api.rawGet('/api/stats/system')); } catch {} }; useEffect(() => { load(); timer.current = setInterval(load, 3000); // poll 3s return () => clearInterval(timer.current); }, []); if (!s) return <div>Loading</div>; return ( <div> <Gauge label="CPU" pct={s.cpu.usagePercent} detail={`${s.cpu.cores} cores`} /> <Gauge label="Memory" pct={s.memory.percent} detail={`${fmtBytes(s.memory.used)} / ${fmtBytes(s.memory.total)}`} /> {/* Swap gauge — cuma muncul kalau swap aktif (total > 0) */} {s.swap && s.swap.total > 0 && ( <Gauge label="Swap" pct={s.swap.percent} detail={`${fmtBytes(s.swap.used)} / ${fmtBytes(s.swap.total)} · ${fmtBytes(s.swap.free)} free`} /> )} <Gauge label="Disk" pct={s.disk.root.percent} detail={...} /> {/* + Network, Host info, 9router process info */} </div> ); } // Gauge = label + percentage + colored bar (green <70%, orange 70-90%, red ≥90%)
— KENAPA api.rawGet, BUKAN api.get

api.get('/foo') auto-prefix /proxy/api → di-rewrite ke 9router upstream. Tapi /api/stats/system hidup di backend miniapp sendiri, bukan di 9router. Pake rawGet buat skip prefix & hit path apa adanya. Pola sama buat semua endpoint miniapp-only: /api/login, /api/webauthn/*, /api/stats/*.

— PITFALL: execSync nge-block event loop

df & du di stats.js pake execSync, gampang, tapi nge-block Node event loop ratusan ms tiap call. Buat 1 user OK, buat banyak request → bottleneck. Kalau lo perlu scale, ganti ke fs.statfs() (Node 18.15+) atau cache result + invalidate per detik.

— COMMON PITFALLS

Things That Break

Scar dari bikin 9router miniapp beneran. Lo gak perlu ngulang.

HTTPS wajib, HTTP ditolak

Telegram cuma mau URL https://. http://localhost atau IP gak akan jalan di Mini App. Selalu lewat tunnel/reverse-proxy dengan TLS.

initData kosong di luar Telegram

window.Telegram.WebApp.initData cuma keisi kalau dibuka DARI dalam Telegram. Buka URL langsung di Chrome → kosong → auth 401. Itu normal, bukan bug.

Lupa WebApp.ready()

Tanpa WebApp.ready(), Telegram nganggep app belum siap, loading spinner muter terus. Panggil di useEffect paling awal.

Dev-mode auth bocor ke production

Backend skip auth kalau MINIAPP_BOT_TOKEN gak ada. Aman di lokal, fatal di prod. API lo telanjang. Selalu set bot token + ALLOWED_TG_IDS di .env production.

Secret upstream ke-leak ke client

Jangan pernah kirim JWT secret / auth cookie ke browser. Mint JWT di server (auth.js), suntik pas proxy via proxyReq.setHeader('Cookie', ...). Browser cukup pegang cookie session HMAC, bukan kredensial upstream.

express.json() global nelan body proxy

Pasang express.json() CUMA di /api. Kalau global, body request ke-consume sebelum sampai http-proxy-middleware → POST/PUT ke upstream jadi kosong & 9router nolak.

SPA fallback nelen route API

app.get('*') harus didaftar PALING BAWAH, sesudah semua route /api/*. Kalau di atas, semua request API ke-redirect ke index.html.

Webview cache versi lama

Telegram cache agresif. Abis redeploy, force reload Mini App / clear cache, kalau enggak lo bingung kenapa update gak muncul.

API upstream gak support PATCH (405 Method Not Allowed)

Beberapa REST API cuma support GET/POST/PUT/DELETE: kirim PATCH dapet 405. Di 9router, endpoint /providers/:id, /keys/:id, /proxy-pools/:id butuh PUT (full replacement) bukan PATCH (partial). Tes upstream-nya pake curl -X PATCH dulu sebelum nentuin verb di api wrapper. Settings biasanya support PATCH, individual resource sering enggak.

prompt() / alert() bikin Face ID gagal di iOS

Bug paling halus di WebAuthn iOS Safari: panggil prompt('Device name?') sebelum startRegistration() = transient user activation putus = NotAllowedError tanpa dialog Face ID muncul sama sekali. JANGAN pake browser modal sebelum WebAuthn call. Pake inline <input> field, panggil startRegistration() langsung dari onClick.

WebAuthn challenge ke-wipe pas server restart

In-memory Map buat challenge enak buat dev, tapi tiap deploy = challenge ilang. User yang ambil options sebelum restart, tap Face ID sesudah restart → error "Custom challenge verifier returned false". Gak ngeblok demo, tapi kalau lo deploy production, persist ke Redis atau JSON file dengan TTL.

Telegram WebView gak support passkey

iOS Telegram Mini App jalan di WKWebView yang advertise PublicKeyCredential tapi selalu throw NotAllowedError. "Open in browser" dari Telegram = SFSafariViewController = juga gak support. Buat user pake biometric, mereka HARUS copy URL & paste di Safari app standalone. Detect WebView via Telegram.WebApp.initData.length > 0 & sembunyiin tombol biometric.