import crypto from 'node:crypto'; import { spawnSync } from 'node:child_process'; import fs from 'node:fs'; import http from 'node:http'; import https from 'node:https'; import net from 'node:net'; import path from 'node:path'; import tls from 'node:tls'; const PORT = Number.parseInt(process.env.PORT || '8081', 10); if (!Number.isInteger(PORT) || PORT <= 0) throw new Error(`Invalid PORT: ${process.env.PORT}`); const APP_VERSION = String(process.env.APP_VERSION || 'v1').trim() || 'v1'; const BUILD_TIMESTAMP = String(process.env.BUILD_TIMESTAMP || '').trim() || undefined; const STARTED_AT = new Date().toISOString(); const STATIC_DIR = process.env.STATIC_DIR || '/srv'; const BASIC_AUTH_FILE = process.env.BASIC_AUTH_FILE || '/tokens/frontend.json'; const API_READ_TOKEN_FILE = process.env.API_READ_TOKEN_FILE || '/tokens/read.json'; const API_UPSTREAM = process.env.API_UPSTREAM || process.env.API_URL || 'http://api:8787'; const HASURA_UPSTREAM = process.env.HASURA_UPSTREAM || 'http://hasura:8080'; const HASURA_GRAPHQL_PATH = process.env.HASURA_GRAPHQL_PATH || '/v1/graphql'; const GRAPHQL_CORS_ORIGIN = process.env.GRAPHQL_CORS_ORIGIN || process.env.CORS_ORIGIN || '*'; const BASIC_AUTH_MODE = String(process.env.BASIC_AUTH_MODE || 'on') .trim() .toLowerCase(); const BASIC_AUTH_ENABLED = !['off', 'false', '0', 'disabled', 'none'].includes(BASIC_AUTH_MODE); const AUTH_USER_HEADER = String(process.env.AUTH_USER_HEADER || 'x-trade-user') .trim() .toLowerCase(); const AUTH_MODE = String(process.env.AUTH_MODE || 'session') .trim() .toLowerCase(); const HTPASSWD_FILE = String(process.env.HTPASSWD_FILE || '/auth/users').trim(); const AUTH_SESSION_SECRET_FILE = String(process.env.AUTH_SESSION_SECRET_FILE || '').trim() || null; const AUTH_SESSION_COOKIE = String(process.env.AUTH_SESSION_COOKIE || 'trade_session') .trim() .toLowerCase(); const AUTH_SESSION_TTL_SECONDS = Number.parseInt(process.env.AUTH_SESSION_TTL_SECONDS || '43200', 10); // 12h const DLOB_SOURCE_COOKIE = String(process.env.DLOB_SOURCE_COOKIE || 'trade_dlob_source').trim() || 'trade_dlob_source'; const DLOB_SOURCE_DEFAULT = String(process.env.DLOB_SOURCE_DEFAULT || 'mevnode').trim() || 'mevnode'; function readJson(filePath) { const raw = fs.readFileSync(filePath, 'utf8'); return JSON.parse(raw); } function readText(filePath) { return fs.readFileSync(filePath, 'utf8'); } function timingSafeEqualStr(a, b) { const aa = Buffer.from(String(a), 'utf8'); const bb = Buffer.from(String(b), 'utf8'); if (aa.length !== bb.length) return false; return crypto.timingSafeEqual(aa, bb); } function timingSafeEqualBuf(a, b) { if (!(a instanceof Uint8Array) || !(b instanceof Uint8Array)) return false; if (a.length !== b.length) return false; return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b)); } function loadBasicAuth() { const j = readJson(BASIC_AUTH_FILE); const username = (j?.username || '').toString(); const password = (j?.password || '').toString(); if (!username || !password) throw new Error(`Invalid BASIC_AUTH_FILE: ${BASIC_AUTH_FILE}`); return { username, password }; } function loadApiReadToken() { const j = readJson(API_READ_TOKEN_FILE); const token = (j?.token || '').toString(); if (!token) throw new Error(`Invalid API_READ_TOKEN_FILE: ${API_READ_TOKEN_FILE}`); return token; } function send(res, status, headers, body) { res.statusCode = status; for (const [k, v] of Object.entries(headers || {})) res.setHeader(k, v); if (body == null) return void res.end(); res.end(body); } function sendJson(res, status, body) { send(res, status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }, JSON.stringify(body)); } function basicAuthRequired(res) { res.setHeader('www-authenticate', 'Basic realm="trade"'); send(res, 401, { 'content-type': 'text/plain; charset=utf-8' }, 'unauthorized'); } function unauthorized(res) { sendJson(res, 401, { ok: false, error: 'unauthorized' }); } function isAuthorized(req, creds) { const auth = req.headers.authorization || ''; const m = String(auth).match(/^Basic\s+(.+)$/i); if (!m?.[1]) return false; let decoded; try { decoded = Buffer.from(m[1], 'base64').toString('utf8'); } catch { return false; } const idx = decoded.indexOf(':'); if (idx < 0) return false; const u = decoded.slice(0, idx); const p = decoded.slice(idx + 1); return timingSafeEqualStr(u, creds.username) && timingSafeEqualStr(p, creds.password); } const MIME = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'application/javascript; charset=utf-8', '.mjs': 'application/javascript; charset=utf-8', '.json': 'application/json; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.ico': 'image/x-icon', '.txt': 'text/plain; charset=utf-8', '.map': 'application/json; charset=utf-8', }; function contentTypeFor(filePath) { return MIME[path.extname(filePath).toLowerCase()] || 'application/octet-stream'; } function safePathFromUrlPath(urlPath) { const decoded = decodeURIComponent(urlPath); const cleaned = decoded.replace(/\0/g, ''); // strip leading slash so join() doesn't ignore STATIC_DIR const rel = cleaned.replace(/^\/+/, ''); const normalized = path.normalize(rel); // prevent traversal if (normalized.startsWith('..') || path.isAbsolute(normalized)) return null; return normalized; } function injectIndexHtml(html, { dlobSource, redirectPath }) { const src = normalizeDlobSource(dlobSource) || 'mevnode'; const redirect = safeRedirectPath(redirectPath); const hrefBase = `/prefs/dlob-source?redirect=${encodeURIComponent(redirect)}&set=`; const styleActive = 'font-weight:700;text-decoration:underline;'; const styleInactive = 'font-weight:400;text-decoration:none;'; const snippet = `
DLOB mevnode | drift
`; const bodyClose = /<\/body>/i; if (bodyClose.test(html)) return html.replace(bodyClose, `${snippet}`); return `${html}\n${snippet}\n`; } function serveStatic(req, res) { if (req.method !== 'GET' && req.method !== 'HEAD') { send(res, 405, { 'content-type': 'text/plain; charset=utf-8' }, 'method_not_allowed'); return; } const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`); const rel = safePathFromUrlPath(url.pathname); if (rel == null) { send(res, 400, { 'content-type': 'text/plain; charset=utf-8' }, 'bad_request'); return; } const root = path.resolve(STATIC_DIR); const fileCandidate = path.resolve(root, rel); if (!fileCandidate.startsWith(root)) { send(res, 400, { 'content-type': 'text/plain; charset=utf-8' }, 'bad_request'); return; } const trySend = (filePath) => { try { const st = fs.statSync(filePath); if (st.isDirectory()) return trySend(path.join(filePath, 'index.html')); res.statusCode = 200; res.setHeader('content-type', contentTypeFor(filePath)); res.setHeader('cache-control', filePath.endsWith('index.html') ? 'no-cache' : 'public, max-age=31536000'); if (req.method === 'HEAD') return void res.end(); if (filePath.endsWith('index.html')) { const html = fs.readFileSync(filePath, 'utf8'); const injected = injectIndexHtml(html, { dlobSource: resolveDlobSource(req), redirectPath: url.pathname + url.search, }); res.end(injected); return true; } fs.createReadStream(filePath).pipe(res); return true; } catch { return false; } }; // exact file, otherwise SPA fallback if (trySend(fileCandidate)) return; const indexPath = path.join(root, 'index.html'); if (trySend(indexPath)) return; send(res, 404, { 'content-type': 'text/plain; charset=utf-8' }, 'not_found'); } function stripHopByHopHeaders(headers) { const hop = new Set([ 'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailer', 'transfer-encoding', 'upgrade', ]); const out = {}; for (const [k, v] of Object.entries(headers || {})) { if (hop.has(k.toLowerCase())) continue; out[k] = v; } return out; } function readHeader(req, name) { const v = req.headers[String(name).toLowerCase()]; return Array.isArray(v) ? v[0] : v; } function readCookie(req, name) { const raw = typeof req.headers.cookie === 'string' ? req.headers.cookie : ''; if (!raw) return null; const needle = `${name}=`; for (const part of raw.split(';')) { const t = part.trim(); if (!t.startsWith(needle)) continue; return t.slice(needle.length) || null; } return null; } function normalizeDlobSource(value) { const v = String(value ?? '') .trim() .toLowerCase(); if (v === 'mevnode') return 'mevnode'; if (v === 'drift') return 'drift'; return null; } function resolveDlobSource(req) { const fromCookie = normalizeDlobSource(readCookie(req, DLOB_SOURCE_COOKIE)); if (fromCookie) return fromCookie; return normalizeDlobSource(DLOB_SOURCE_DEFAULT) || 'mevnode'; } function safeRedirectPath(value) { const s = String(value ?? '').trim(); if (!s.startsWith('/')) return '/'; if (s.startsWith('//')) return '/'; return s.replace(/\r|\n/g, ''); } function setDlobSourceCookie(res, { secure, dlobSource }) { const src = normalizeDlobSource(dlobSource); if (!src) return false; const parts = [ `${DLOB_SOURCE_COOKIE}=${src}`, 'Path=/', 'SameSite=Lax', 'HttpOnly', 'Max-Age=31536000', ]; if (secure) parts.push('Secure'); res.setHeader('set-cookie', parts.join('; ')); return true; } function resolveAuthUser(req) { const user = readHeader(req, AUTH_USER_HEADER) || readHeader(req, 'x-webauth-user'); const value = typeof user === 'string' ? user.trim() : ''; return value || null; } function isHttpsRequest(req) { const xf = readHeader(req, 'x-forwarded-proto'); if (typeof xf === 'string' && xf.toLowerCase() === 'https') return true; return Boolean(req.socket && req.socket.encrypted); } function base64urlEncode(buf) { return Buffer.from(buf) .toString('base64') .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=+$/g, ''); } function base64urlDecode(str) { const cleaned = String(str).replace(/-/g, '+').replace(/_/g, '/'); const pad = cleaned.length % 4 === 0 ? '' : '='.repeat(4 - (cleaned.length % 4)); return Buffer.from(cleaned + pad, 'base64'); } function loadSessionSecret() { if (process.env.AUTH_SESSION_SECRET && String(process.env.AUTH_SESSION_SECRET).trim()) { return Buffer.from(String(process.env.AUTH_SESSION_SECRET).trim(), 'utf8'); } if (AUTH_SESSION_SECRET_FILE) { try { const txt = readText(AUTH_SESSION_SECRET_FILE).trim(); if (txt) return Buffer.from(txt, 'utf8'); } catch { // ignore } } return crypto.randomBytes(32); } const SESSION_SECRET = loadSessionSecret(); function signSessionPayload(payloadB64) { return crypto.createHmac('sha256', SESSION_SECRET).update(payloadB64).digest(); } function makeSessionCookieValue(username) { const now = Math.floor(Date.now() / 1000); const exp = now + (Number.isFinite(AUTH_SESSION_TTL_SECONDS) && AUTH_SESSION_TTL_SECONDS > 0 ? AUTH_SESSION_TTL_SECONDS : 43200); const payload = JSON.stringify({ u: String(username), exp }); const payloadB64 = base64urlEncode(Buffer.from(payload, 'utf8')); const sigB64 = base64urlEncode(signSessionPayload(payloadB64)); return `${payloadB64}.${sigB64}`; } function getSessionUser(req) { const raw = readCookie(req, AUTH_SESSION_COOKIE); if (!raw) return null; const parts = raw.split('.'); if (parts.length !== 2) return null; const [payloadB64, sigB64] = parts; if (!payloadB64 || !sigB64) return null; let payload; try { payload = JSON.parse(base64urlDecode(payloadB64).toString('utf8')); } catch { return null; } const u = typeof payload?.u === 'string' ? payload.u.trim() : ''; const exp = Number(payload?.exp); if (!u || !Number.isFinite(exp)) return null; const now = Math.floor(Date.now() / 1000); if (now >= exp) return null; const expected = signSessionPayload(payloadB64); let got; try { got = base64urlDecode(sigB64); } catch { return null; } if (!timingSafeEqualBuf(expected, got)) return null; return u; } function resolveAuthenticatedUser(req) { const sessionUser = getSessionUser(req); if (sessionUser) return sessionUser; const headerUser = resolveAuthUser(req); if (headerUser) return headerUser; if (AUTH_MODE === 'off' || AUTH_MODE === 'none' || AUTH_MODE === 'disabled') return 'anonymous'; return null; } function clearSessionCookie(res, secure) { const parts = [`${AUTH_SESSION_COOKIE}=`, 'Path=/', 'Max-Age=0', 'HttpOnly', 'SameSite=Lax']; if (secure) parts.push('Secure'); res.setHeader('set-cookie', parts.join('; ')); } function setSessionCookie(res, secure, username) { const value = makeSessionCookieValue(username); const parts = [ `${AUTH_SESSION_COOKIE}=${value}`, 'Path=/', `Max-Age=${Number.isFinite(AUTH_SESSION_TTL_SECONDS) ? AUTH_SESSION_TTL_SECONDS : 43200}`, 'HttpOnly', 'SameSite=Lax', ]; if (secure) parts.push('Secure'); res.setHeader('set-cookie', parts.join('; ')); } function verifyWithHtpasswd(username, password) { try { const r = spawnSync('htpasswd', ['-vb', HTPASSWD_FILE, String(username), String(password)], { stdio: 'ignore', timeout: 3000, }); return r.status === 0; } catch { return false; } } function readBody(req, limitBytes = 1024 * 16) { return new Promise((resolve, reject) => { let total = 0; const chunks = []; req.on('data', (chunk) => { total += chunk.length; if (total > limitBytes) { reject(new Error('payload_too_large')); req.destroy(); return; } chunks.push(chunk); }); req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); req.on('error', reject); }); } function proxyApi(req, res, apiReadToken) { const upstreamBase = new URL(API_UPSTREAM); const inUrl = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`); const prefix = '/api'; const strippedPath = inUrl.pathname === prefix ? '/' : inUrl.pathname.startsWith(prefix + '/') ? inUrl.pathname.slice(prefix.length) : null; if (strippedPath == null) { send(res, 404, { 'content-type': 'text/plain; charset=utf-8' }, 'not_found'); return; } const target = new URL(upstreamBase.toString()); target.pathname = strippedPath || '/'; target.search = inUrl.search; const isHttps = target.protocol === 'https:'; const lib = isHttps ? https : http; const headers = stripHopByHopHeaders(req.headers); delete headers.authorization; // basic auth from client must not leak upstream headers.host = target.host; headers.authorization = `Bearer ${apiReadToken}`; const upstreamReq = lib.request( { protocol: target.protocol, hostname: target.hostname, port: target.port || (isHttps ? 443 : 80), method: req.method, path: target.pathname + target.search, headers, }, (upstreamRes) => { const outHeaders = stripHopByHopHeaders(upstreamRes.headers); res.writeHead(upstreamRes.statusCode || 502, outHeaders); upstreamRes.pipe(res); } ); upstreamReq.on('error', (err) => { if (!res.headersSent) { send(res, 502, { 'content-type': 'text/plain; charset=utf-8' }, `bad_gateway: ${err?.message || err}`); } else { res.destroy(); } }); req.pipe(upstreamReq); } function withCors(res) { res.setHeader('access-control-allow-origin', GRAPHQL_CORS_ORIGIN); res.setHeader('access-control-allow-methods', 'GET,POST,OPTIONS'); res.setHeader( 'access-control-allow-headers', 'content-type, authorization, x-hasura-admin-secret, x-hasura-role, x-hasura-user-id, x-hasura-dlob-source' ); } function proxyGraphqlHttp(req, res) { const upstreamBase = new URL(HASURA_UPSTREAM); const inUrl = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`); const target = new URL(upstreamBase.toString()); target.pathname = HASURA_GRAPHQL_PATH; target.search = inUrl.search; const isHttps = target.protocol === 'https:'; const lib = isHttps ? https : http; const headers = stripHopByHopHeaders(req.headers); headers.host = target.host; delete headers['x-hasura-dlob-source']; headers['x-hasura-dlob-source'] = resolveDlobSource(req); const upstreamReq = lib.request( { protocol: target.protocol, hostname: target.hostname, port: target.port || (isHttps ? 443 : 80), method: req.method, path: target.pathname + target.search, headers, }, (upstreamRes) => { const outHeaders = stripHopByHopHeaders(upstreamRes.headers); withCors(res); res.writeHead(upstreamRes.statusCode || 502, outHeaders); upstreamRes.pipe(res); } ); upstreamReq.on('error', (err) => { if (!res.headersSent) { withCors(res); send(res, 502, { 'content-type': 'text/plain; charset=utf-8' }, `bad_gateway: ${err?.message || err}`); } else { res.destroy(); } }); req.pipe(upstreamReq); } function isGraphqlPath(pathname) { return pathname === '/graphql' || pathname === '/graphql-ws'; } function proxyGraphqlWs(req, socket, head) { const upstreamBase = new URL(HASURA_UPSTREAM); const inUrl = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`); const target = new URL(upstreamBase.toString()); target.pathname = HASURA_GRAPHQL_PATH; target.search = inUrl.search; const port = Number(target.port || (target.protocol === 'https:' ? 443 : 80)); const host = target.hostname; const connect = target.protocol === 'https:' ? () => tls.connect({ host, port, servername: host }) : () => net.connect({ host, port }); const upstream = connect(); upstream.setNoDelay(true); socket.setNoDelay(true); // For WebSocket upgrades we must forward `connection`/`upgrade` and related headers. const headers = { ...req.headers }; delete headers['content-length']; delete headers['content-type']; headers.host = target.host; delete headers['x-hasura-dlob-source']; headers['x-hasura-dlob-source'] = resolveDlobSource(req); const lines = []; lines.push(`GET ${target.pathname + target.search} HTTP/1.1`); for (const [k, v] of Object.entries(headers)) { if (v == null) continue; if (Array.isArray(v)) { for (const vv of v) lines.push(`${k}: ${vv}`); } else { lines.push(`${k}: ${v}`); } } lines.push('', ''); upstream.write(lines.join('\r\n')); if (head?.length) upstream.write(head); upstream.on('error', () => { try { socket.destroy(); } catch { // ignore } }); socket.on('error', () => { try { upstream.destroy(); } catch { // ignore } }); upstream.pipe(socket); socket.pipe(upstream); } async function handler(req, res) { if (req.method === 'GET' && (req.url === '/healthz' || req.url?.startsWith('/healthz?'))) { send( res, 200, { 'content-type': 'application/json; charset=utf-8' }, JSON.stringify({ ok: true, version: APP_VERSION, buildTimestamp: BUILD_TIMESTAMP, startedAt: STARTED_AT }) ); return; } const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`); if (isGraphqlPath(url.pathname)) { if (req.method === 'OPTIONS') { withCors(res); res.statusCode = 204; res.end(); return; } if (AUTH_MODE !== 'off' && AUTH_MODE !== 'none' && AUTH_MODE !== 'disabled') { const user = resolveAuthenticatedUser(req); if (!user) { withCors(res); unauthorized(res); return; } } withCors(res); proxyGraphqlHttp(req, res); return; } if (req.method === 'GET' && url.pathname === '/whoami') { sendJson(res, 200, { ok: true, user: resolveAuthenticatedUser(req), mode: AUTH_MODE }); return; } if (req.method === 'GET' && url.pathname === '/prefs/dlob-source') { const set = url.searchParams.get('set'); if (!set) { sendJson(res, 200, { ok: true, dlobSource: resolveDlobSource(req) }); return; } const ok = setDlobSourceCookie(res, { secure: isHttpsRequest(req), dlobSource: set }); if (!ok) { sendJson(res, 400, { ok: false, error: 'invalid_dlob_source' }); return; } res.statusCode = 302; res.setHeader('location', safeRedirectPath(url.searchParams.get('redirect') || '/')); res.end(); return; } if (req.method === 'POST' && url.pathname === '/auth/login') { if (AUTH_MODE === 'off' || AUTH_MODE === 'none' || AUTH_MODE === 'disabled') { sendJson(res, 400, { ok: false, error: 'auth_disabled' }); return; } const raw = await readBody(req); const ct = String(req.headers['content-type'] || '').toLowerCase(); let username = ''; let password = ''; if (ct.includes('application/json')) { let json; try { json = JSON.parse(raw); } catch { sendJson(res, 400, { ok: false, error: 'bad_json' }); return; } username = typeof json?.username === 'string' ? json.username.trim() : ''; password = typeof json?.password === 'string' ? json.password : ''; } else { const params = new URLSearchParams(raw); username = String(params.get('username') || '').trim(); password = String(params.get('password') || ''); } if (!username || !password || username.length > 64 || password.length > 200) { sendJson(res, 400, { ok: false, error: 'invalid_input' }); return; } const ok = verifyWithHtpasswd(username, password); if (!ok) { unauthorized(res); return; } const secure = isHttpsRequest(req); setSessionCookie(res, secure, username); sendJson(res, 200, { ok: true, user: username }); return; } if ((req.method === 'POST' || req.method === 'GET') && (url.pathname === '/auth/logout' || url.pathname === '/logout')) { clearSessionCookie(res, isHttpsRequest(req)); if (req.method === 'GET') { res.statusCode = 302; res.setHeader('location', '/'); res.end(); return; } sendJson(res, 200, { ok: true }); return; } if (BASIC_AUTH_ENABLED) { let creds; try { creds = loadBasicAuth(); } catch (e) { send(res, 500, { 'content-type': 'text/plain; charset=utf-8' }, String(e?.message || e)); return; } if (!isAuthorized(req, creds)) { basicAuthRequired(res); return; } } if (req.url?.startsWith('/api') && (req.url === '/api' || req.url.startsWith('/api/'))) { if (AUTH_MODE !== 'off' && AUTH_MODE !== 'none' && AUTH_MODE !== 'disabled') { const user = resolveAuthenticatedUser(req); if (!user) { unauthorized(res); return; } } let token; try { token = loadApiReadToken(); } catch (e) { send(res, 500, { 'content-type': 'text/plain; charset=utf-8' }, String(e?.message || e)); return; } proxyApi(req, res, token); return; } serveStatic(req, res); } const server = http.createServer((req, res) => { handler(req, res).catch((e) => { if (res.headersSent) { res.destroy(); return; } send(res, 500, { 'content-type': 'text/plain; charset=utf-8' }, String(e?.message || e)); }); }); server.on('upgrade', (req, socket, head) => { try { const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`); if (!isGraphqlPath(url.pathname)) { socket.destroy(); return; } if (AUTH_MODE !== 'off' && AUTH_MODE !== 'none' && AUTH_MODE !== 'disabled') { const user = resolveAuthenticatedUser(req); if (!user) { try { socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); } catch { // ignore } socket.destroy(); return; } } proxyGraphqlWs(req, socket, head); } catch { socket.destroy(); } }); server.listen(PORT, () => { console.log( JSON.stringify( { service: 'trade-frontend', port: PORT, staticDir: STATIC_DIR, apiUpstream: API_UPSTREAM, hasuraUpstream: HASURA_UPSTREAM, basicAuthFile: BASIC_AUTH_FILE, basicAuthMode: BASIC_AUTH_MODE, apiReadTokenFile: API_READ_TOKEN_FILE, authUserHeader: AUTH_USER_HEADER, authMode: AUTH_MODE, htpasswdFile: HTPASSWD_FILE, }, null, 2 ) ); });