feat: add internal admin tooling — log viewer and Scalar API docs

- server/logviewer.html: readable log viewer at GET /api/logs/view — level
  filter chips, free-text search, and click-to-trace (clicking any field value
  re-filters to every line sharing it, e.g. an order_id or request id across
  its whole lifecycle). GET /api/logs/files/:filename now redirects browser
  navigation here instead of returning raw JSON (Accept: application/json
  still gets the JSON body).
- server/openapi.json + server/api-docs.html: OpenAPI 3.0 spec and a Scalar
  reference page served at GET /openapi.json and GET /docs. Scalar is loaded
  from a version-pinned CDN URL with a Subresource Integrity hash rather than
  installed as a dependency.
- Generalize requireLogBasicAuth -> requireAdminAuth since the same HTTP Basic
  Auth credentials now gate /api/logs*, /openapi.json, and /docs alike.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Tengku Achmad 2026-08-03 22:49:26 +07:00
parent 441e6261a2
commit c9149eec12
4 changed files with 620 additions and 11 deletions

18
server/api-docs.html Normal file
View File

@ -0,0 +1,18 @@
<!doctype html>
<html lang="id">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Midtrans Middleware — API Docs</title>
<style>
body { margin: 0; background: #0f1115; }
</style>
</head>
<body>
<script id="api-reference" data-url="/openapi.json" data-configuration='{"theme":"purple","darkMode":true}'></script>
<script
src="https://cdn.jsdelivr.net/npm/@scalar/api-reference@1.25.68/dist/browser/standalone.js"
integrity="sha384-v9vXqqwFlfQ80J0rCh/ErF90VG7ymwSmE1r+K7cw8Vroy4kX1N/0PWsdXY/vvcm5"
crossorigin="anonymous"></script>
</body>
</html>

View File

@ -226,6 +226,24 @@ if (!fs.existsSync(LOG_DIR)) {
fs.mkdirSync(LOG_DIR, { recursive: true }) fs.mkdirSync(LOG_DIR, { recursive: true })
} }
// LOG VIEWER PAGE (STATIC, LOADED ONCE — FETCHES /api/logs/* CLIENT-SIDE)
let LOG_VIEWER_HTML = ''
try {
LOG_VIEWER_HTML = fs.readFileSync(path.join(__dirname, 'logviewer.html'), 'utf8')
} catch (e) {
console.warn('[log-viewer] Failed to load logviewer.html:', e.message)
}
// API DOCS (SCALAR) — STATIC, LOADED ONCE
let API_DOCS_HTML = ''
let OPENAPI_JSON = ''
try {
API_DOCS_HTML = fs.readFileSync(path.join(__dirname, 'api-docs.html'), 'utf8')
OPENAPI_JSON = fs.readFileSync(path.join(__dirname, 'openapi.json'), 'utf8')
} catch (e) {
console.warn('[api-docs] Failed to load api-docs.html/openapi.json:', e.message)
}
// LOG RETENTION — DELETE LOGS_*.log FILES OLDER THAN LOG_RETENTION_DAYS // LOG RETENTION — DELETE LOGS_*.log FILES OLDER THAN LOG_RETENTION_DAYS
const LOG_RETENTION_DAYS = parseInt(process.env.LOG_RETENTION_DAYS || '30', 10) const LOG_RETENTION_DAYS = parseInt(process.env.LOG_RETENTION_DAYS || '30', 10)
@ -496,11 +514,11 @@ function timingSafeEqualStr(a, b) {
return crypto.timingSafeEqual(bufA, bufB) return crypto.timingSafeEqual(bufA, bufB)
} }
function requireLogBasicAuth(req, res, next) { function requireAdminAuth(req, res, next) {
if (!LOG_BASIC_AUTH_USER || !LOG_BASIC_AUTH_PASS) { if (!LOG_BASIC_AUTH_USER || !LOG_BASIC_AUTH_PASS) {
if (isDevEnv()) return next() if (isDevEnv()) return next()
logWarn('logs.auth.not_configured', { id: req.id }) logWarn('admin.auth.not_configured', { id: req.id })
return res.status(503).json({ error: 'LOG_AUTH_NOT_CONFIGURED', message: 'Set LOG_BASIC_AUTH_USER and LOG_BASIC_AUTH_PASS to enable log access.' }) return res.status(503).json({ error: 'ADMIN_AUTH_NOT_CONFIGURED', message: 'Set LOG_BASIC_AUTH_USER and LOG_BASIC_AUTH_PASS to enable access.' })
} }
const header = req.headers['authorization'] || '' const header = req.headers['authorization'] || ''
@ -517,9 +535,9 @@ function requireLogBasicAuth(req, res, next) {
const ok = user && pass && timingSafeEqualStr(user, LOG_BASIC_AUTH_USER) && timingSafeEqualStr(pass, LOG_BASIC_AUTH_PASS) const ok = user && pass && timingSafeEqualStr(user, LOG_BASIC_AUTH_USER) && timingSafeEqualStr(pass, LOG_BASIC_AUTH_PASS)
if (!ok) { if (!ok) {
if (scheme === 'Basic') logWarn('logs.auth.failed', { id: req.id, user }) if (scheme === 'Basic') logWarn('admin.auth.failed', { id: req.id, user })
res.set('WWW-Authenticate', 'Basic realm="Midtrans Middleware Logs"') res.set('WWW-Authenticate', 'Basic realm="Midtrans Middleware Admin"')
return res.status(401).json({ error: 'UNAUTHORIZED', message: 'Login required to access logs' }) return res.status(401).json({ error: 'UNAUTHORIZED', message: 'Login required' })
} }
next() next()
} }
@ -528,7 +546,7 @@ function requireLogBasicAuth(req, res, next) {
* Get recent logs from memory (dev/debug only) * Get recent logs from memory (dev/debug only)
* GET /api/logs?limit=100&level=debug|info|warn|error&q=keyword * GET /api/logs?limit=100&level=debug|info|warn|error&q=keyword
*/ */
app.get('/api/logs', requireLogBasicAuth, (req, res) => { app.get('/api/logs', requireAdminAuth, (req, res) => {
if (!LOG_EXPOSE_API) { if (!LOG_EXPOSE_API) {
return res.status(403).json({ error: 'FORBIDDEN', message: 'Log API disabled. Set LOG_EXPOSE_API=true to enable.' }) return res.status(403).json({ error: 'FORBIDDEN', message: 'Log API disabled. Set LOG_EXPOSE_API=true to enable.' })
} }
@ -551,7 +569,7 @@ app.get('/api/logs', requireLogBasicAuth, (req, res) => {
* List all log files in logs directory * List all log files in logs directory
* GET /api/logs/files * GET /api/logs/files
*/ */
app.get('/api/logs/files', requireLogBasicAuth, (req, res) => { app.get('/api/logs/files', requireAdminAuth, (req, res) => {
if (!LOG_EXPOSE_API) { if (!LOG_EXPOSE_API) {
return res.status(403).json({ error: 'FORBIDDEN', message: 'Log API disabled. Set LOG_EXPOSE_API=true to enable.' }) return res.status(403).json({ error: 'FORBIDDEN', message: 'Log API disabled. Set LOG_EXPOSE_API=true to enable.' })
} }
@ -579,18 +597,23 @@ app.get('/api/logs/files', requireLogBasicAuth, (req, res) => {
* Read specific log file * Read specific log file
* GET /api/logs/files/:filename * GET /api/logs/files/:filename
*/ */
app.get('/api/logs/files/:filename', requireLogBasicAuth, (req, res) => { app.get('/api/logs/files/:filename', requireAdminAuth, (req, res) => {
if (!LOG_EXPOSE_API) { if (!LOG_EXPOSE_API) {
return res.status(403).json({ error: 'FORBIDDEN', message: 'Log API disabled. Set LOG_EXPOSE_API=true to enable.' }) return res.status(403).json({ error: 'FORBIDDEN', message: 'Log API disabled. Set LOG_EXPOSE_API=true to enable.' })
} }
try { try {
const { filename } = req.params const { filename } = req.params
// BROWSER NAVIGATION (NOT fetch()/API CLIENTS) GETS THE READABLE VIEWER INSTEAD OF RAW JSON
if (req.accepts(['json', 'html']) === 'html') {
return res.redirect(`/api/logs/view?file=${encodeURIComponent(filename)}`)
}
// SECURITY: VALIDATE FILENAME TO PREVENT DIRECTORY TRAVERSAL // SECURITY: VALIDATE FILENAME TO PREVENT DIRECTORY TRAVERSAL
if (!filename.match(/^LOGS_\d{8}\.log$/)) { if (!filename.match(/^LOGS_\d{8}\.log$/)) {
return res.status(400).json({ error: 'INVALID_FILENAME', message: 'Invalid log filename format' }) return res.status(400).json({ error: 'INVALID_FILENAME', message: 'Invalid log filename format' })
} }
const filePath = path.join(LOG_DIR, filename) const filePath = path.join(LOG_DIR, filename)
if (!fs.existsSync(filePath)) { if (!fs.existsSync(filePath)) {
@ -611,6 +634,43 @@ app.get('/api/logs/files/:filename', requireLogBasicAuth, (req, res) => {
} }
}) })
/**
* Readable log viewer (search, level filter, click-to-trace) fetches
* /api/logs/files and /api/logs/files/:filename client-side.
* GET /api/logs/view?file=LOGS_DDMMYYYY.log
*/
app.get('/api/logs/view', requireAdminAuth, (req, res) => {
if (!LOG_EXPOSE_API) {
return res.status(403).json({ error: 'FORBIDDEN', message: 'Log API disabled. Set LOG_EXPOSE_API=true to enable.' })
}
if (!LOG_VIEWER_HTML) {
return res.status(500).json({ error: 'VIEWER_UNAVAILABLE', message: 'logviewer.html failed to load' })
}
res.type('html').send(LOG_VIEWER_HTML)
})
/**
* OpenAPI spec backing the /docs page.
* GET /openapi.json
*/
app.get('/openapi.json', requireAdminAuth, (req, res) => {
if (!OPENAPI_JSON) {
return res.status(500).json({ error: 'SPEC_UNAVAILABLE', message: 'openapi.json failed to load' })
}
res.type('json').send(OPENAPI_JSON)
})
/**
* Interactive API reference (Scalar), gated by the same credentials as /api/logs*.
* GET /docs
*/
app.get('/docs', requireAdminAuth, (req, res) => {
if (!API_DOCS_HTML) {
return res.status(500).json({ error: 'DOCS_UNAVAILABLE', message: 'api-docs.html failed to load' })
}
res.type('html').send(API_DOCS_HTML)
})
/** /**
* Update payment toggles at runtime (dev only) * Update payment toggles at runtime (dev only)
* POST /api/config * POST /api/config

246
server/logviewer.html Normal file
View File

@ -0,0 +1,246 @@
<!doctype html>
<html lang="id">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Midtrans Middleware — Log Viewer</title>
<style>
:root {
--bg: #0a0b0e; --panel: #1c1f28; --border: #3a4050; --text: #f5f7fa; --dim: #aab2c5;
--accent: #6ab5ff; --debug: #9aa3b8; --debug-fg: #0a0b0e; --info: #3b82f6; --info-fg: #ffffff;
--warn: #f5a524; --warn-fg: #1a1200; --error: #ef4444; --error-fg: #ffffff;
--pill-bg: #2b3040; --pill-border: #454c60; --pill-hover: #394158;
}
@media (prefers-color-scheme: light) {
:root { --bg: #eef0f4; --panel: #ffffff; --border: #c7ccd6; --text: #10131a; --dim: #454b58;
--pill-bg: #e6e9f0; --pill-border: #c2c8d4; --pill-hover: #d7dbe6;
--debug-fg: #ffffff; --info-fg: #ffffff; --warn-fg: #1a1200; --error-fg: #ffffff; }
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--text); font-family: -apple-system, Segoe UI, Roboto, sans-serif; }
header { position: sticky; top: 0; z-index: 5; background: var(--panel); border-bottom: 1px solid var(--border);
padding: 10px 14px; display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
header h1 { font-size: 14px; margin: 0 12px 0 0; color: var(--dim); font-weight: 700; white-space: nowrap; letter-spacing: .04em; }
select, input[type=text], button { background: var(--pill-bg); color: var(--text); border: 1px solid var(--pill-border);
border-radius: 6px; padding: 6px 10px; font-size: 13px; font-family: inherit; }
input[type=text] { min-width: 220px; flex: 1 1 220px; }
input[type=text]::placeholder { color: var(--dim); }
button { cursor: pointer; font-weight: 600; }
button:hover { background: var(--pill-hover); }
.levels { display: flex; gap: 5px; }
.lvl-chip { padding: 5px 10px; border-radius: 6px; border: 1.5px solid var(--border); cursor: pointer; font-size: 12px;
font-weight: 500; opacity: .4; user-select: none; background: transparent; }
.lvl-chip.on { opacity: 1; }
.lvl-chip[data-lvl=DEBUG].on { color: var(--debug-fg); background: var(--debug); border-color: var(--debug); }
.lvl-chip[data-lvl=INFO].on { color: var(--info-fg); background: var(--info); border-color: var(--info); }
.lvl-chip[data-lvl=WARN].on { color: var(--warn-fg); background: var(--warn); border-color: var(--warn); }
.lvl-chip[data-lvl=ERROR].on { color: var(--error-fg); background: var(--error); border-color: var(--error); }
.lvl-chip[data-lvl=DEBUG]:not(.on) { color: var(--debug); }
.lvl-chip[data-lvl=INFO]:not(.on) { color: var(--info); }
.lvl-chip[data-lvl=WARN]:not(.on) { color: var(--warn); }
.lvl-chip[data-lvl=ERROR]:not(.on) { color: var(--error); }
#stats { color: var(--dim); font-size: 12px; white-space: nowrap; font-weight: 600; }
#trace-bar { display: none; align-items: center; gap: 8px; padding: 8px 14px; background: var(--pill-bg);
border-bottom: 1px solid var(--border); font-size: 12px; }
#trace-bar.on { display: flex; }
#trace-bar b { color: var(--accent); }
main { padding: 8px 14px 40px; max-width: 100%; }
.row { display: flex; gap: 10px; padding: 6px 8px; border-radius: 6px; font-size: 12.5px; font-family: ui-monospace, SFMono-Regular, Consolas, Menlo, monospace;
align-items: flex-start; border-left: 4px solid transparent; }
.row:hover { background: var(--panel); }
.row.ERROR { border-left-color: var(--error); background: color-mix(in srgb, var(--error) 10%, transparent); }
.row.WARN { border-left-color: var(--warn); background: color-mix(in srgb, var(--warn) 8%, transparent); }
.row.INFO { border-left-color: transparent; }
.row.DEBUG { opacity: .7; }
.ts { color: var(--dim); white-space: nowrap; flex: 0 0 auto; font-weight: 600; }
.badge { flex: 0 0 auto; font-weight: 600; padding: 2px 7px; border-radius: 4px; font-size: 11px; letter-spacing: .03em; }
.badge.DEBUG { color: var(--debug-fg); background: var(--debug); }
.badge.INFO { color: var(--info-fg); background: var(--info); }
.badge.WARN { color: var(--warn-fg); background: var(--warn); }
.badge.ERROR { color: var(--error-fg); background: var(--error); }
.msg { flex: 0 0 auto; font-weight: 700; white-space: nowrap; color: var(--text); }
.meta { flex: 1 1 auto; color: var(--dim); display: flex; flex-wrap: wrap; gap: 5px; min-width: 0; }
.pill { background: var(--pill-bg); border: 1px solid var(--pill-border); border-radius: 4px; padding: 1px 7px; cursor: pointer; white-space: nowrap; color: var(--text); }
.pill:hover { background: var(--pill-hover); border-color: var(--accent); }
.pill.hit { outline: 2px solid var(--accent); border-color: var(--accent); color: var(--accent); font-weight: 700; }
#empty { color: var(--dim); text-align: center; padding: 40px; font-size: 13px; }
#jump { position: fixed; right: 20px; bottom: 20px; border-radius: 20px; padding: 8px 14px; box-shadow: 0 2px 10px rgba(0,0,0,.3);
background: var(--accent); color: #06121f; border: none; font-weight: 800; }
</style>
</head>
<body>
<header>
<h1>LOG VIEWER</h1>
<select id="fileSelect"></select>
<div class="levels" id="levels">
<span class="lvl-chip on" data-lvl="DEBUG">DEBUG</span>
<span class="lvl-chip on" data-lvl="INFO">INFO</span>
<span class="lvl-chip on" data-lvl="WARN">WARN</span>
<span class="lvl-chip on" data-lvl="ERROR">ERROR</span>
</div>
<input type="text" id="search" placeholder="Cari teks, order_id, request id...">
<label style="font-size:12px;color:var(--dim);display:flex;align-items:center;gap:4px;">
<input type="checkbox" id="autoRefresh"> auto-refresh 5s
</label>
<button id="refreshBtn">Refresh</button>
<span id="stats"></span>
</header>
<div id="trace-bar">
Trace aktif untuk: <b id="traceVal"></b>
<button id="clearTrace">Hapus filter</button>
</div>
<main id="rows"><div id="empty">Memuat...</div></main>
<button id="jump" style="display:none;">↓ Terbaru</button>
<script>
(function () {
var state = { file: null, entries: [], levels: new Set(['DEBUG','INFO','WARN','ERROR']), search: '' };
var params = new URLSearchParams(location.search);
state.file = params.get('file') || '';
function parseLine(raw) {
var m = raw.match(/^\[([^\]]+)\]\s\[(\w+)\s*\]\s(.*)$/);
if (!m) return { ts: '', level: 'INFO', msg: raw, metaRaw: '', meta: null, raw: raw };
var ts = m[1], level = m[2].trim(), rest = m[3];
var sepIdx = rest.indexOf(' | ');
var msg = sepIdx === -1 ? rest : rest.slice(0, sepIdx);
var metaRaw = sepIdx === -1 ? '' : rest.slice(sepIdx + 3);
var meta = null;
if (metaRaw) { try { meta = JSON.parse(metaRaw); } catch (e) { meta = null; } }
return { ts: ts, level: level, msg: msg, metaRaw: metaRaw, meta: meta, raw: raw };
}
function fmtTime(ts) {
if (!ts) return '';
var i = ts.indexOf('T');
return i === -1 ? ts : ts.slice(i + 1, ts.length - 6);
}
async function loadFileList() {
var res = await fetch('/api/logs/files', { headers: { Accept: 'application/json' } });
if (!res.ok) return;
var data = await res.json();
var sel = document.getElementById('fileSelect');
sel.innerHTML = '';
data.files.forEach(function (f) {
var opt = document.createElement('option');
opt.value = f.filename; opt.textContent = f.filename + ' (' + (f.size/1024).toFixed(1) + ' KB)';
sel.appendChild(opt);
});
if (!state.file && data.files.length) state.file = data.files[0].filename;
sel.value = state.file;
}
async function loadFile() {
document.getElementById('rows').innerHTML = '<div id="empty">Memuat...</div>';
var res = await fetch('/api/logs/files/' + encodeURIComponent(state.file), { headers: { Accept: 'application/json' } });
if (!res.ok) {
document.getElementById('rows').innerHTML = '<div id="empty">Gagal memuat file (' + res.status + ')</div>';
return;
}
var data = await res.json();
state.entries = data.content.map(parseLine);
var url = new URL(location.href); url.searchParams.set('file', state.file);
history.replaceState(null, '', url);
render();
}
function matches(e) {
if (!state.levels.has(e.level)) return false;
if (!state.search) return true;
return e.raw.toLowerCase().indexOf(state.search.toLowerCase()) !== -1;
}
function metaHtml(e) {
if (!e.meta || typeof e.meta !== 'object') {
return e.metaRaw ? '<span class="pill">' + escapeHtml(e.metaRaw) + '</span>' : '';
}
return Object.keys(e.meta).map(function (k) {
var v = e.meta[k];
var vs = typeof v === 'object' ? JSON.stringify(v) : String(v);
var isHit = state.search && vs.toLowerCase() === state.search.toLowerCase();
return '<span class="pill' + (isHit ? ' hit' : '') + '" data-val="' + escapeAttr(vs) + '">' +
escapeHtml(k) + ': ' + escapeHtml(vs) + '</span>';
}).join('');
}
function escapeHtml(s) { return s.replace(/[&<>]/g, function (c) { return { '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]; }); }
function escapeAttr(s) { return escapeHtml(s).replace(/"/g, '&quot;'); }
function render() {
var filtered = state.entries.filter(matches);
var container = document.getElementById('rows');
if (!filtered.length) {
container.innerHTML = '<div id="empty">Tidak ada log yang cocok dengan filter.</div>';
} else {
var html = filtered.map(function (e) {
return '<div class="row ' + e.level + '">' +
'<span class="ts" title="' + escapeAttr(e.ts) + '">' + escapeHtml(fmtTime(e.ts)) + '</span>' +
'<span class="badge ' + e.level + '">' + e.level + '</span>' +
'<span class="msg">' + escapeHtml(e.msg) + '</span>' +
'<span class="meta">' + metaHtml(e) + '</span>' +
'</div>';
}).join('');
container.innerHTML = html;
}
var counts = { DEBUG: 0, INFO: 0, WARN: 0, ERROR: 0 };
state.entries.forEach(function (e) { if (counts[e.level] !== undefined) counts[e.level]++; });
document.getElementById('stats').textContent =
filtered.length + ' / ' + state.entries.length + ' baris — ' +
'E:' + counts.ERROR + ' W:' + counts.WARN + ' I:' + counts.INFO + ' D:' + counts.DEBUG;
}
document.getElementById('rows').addEventListener('click', function (ev) {
var pill = ev.target.closest('.pill');
if (!pill || !pill.dataset.val) return;
setSearch(pill.dataset.val);
});
function setSearch(v) {
state.search = v;
document.getElementById('search').value = v;
document.getElementById('trace-bar').classList.toggle('on', !!v);
document.getElementById('traceVal').textContent = v;
render();
}
document.getElementById('clearTrace').addEventListener('click', function () { setSearch(''); });
document.getElementById('search').addEventListener('input', function (ev) { setSearch(ev.target.value); });
document.getElementById('levels').addEventListener('click', function (ev) {
var chip = ev.target.closest('.lvl-chip');
if (!chip) return;
var lvl = chip.dataset.lvl;
if (state.levels.has(lvl)) { state.levels.delete(lvl); chip.classList.remove('on'); }
else { state.levels.add(lvl); chip.classList.add('on'); }
render();
});
document.getElementById('fileSelect').addEventListener('change', function (ev) {
state.file = ev.target.value;
loadFile();
});
document.getElementById('refreshBtn').addEventListener('click', loadFile);
var timer = null;
document.getElementById('autoRefresh').addEventListener('change', function (ev) {
if (timer) { clearInterval(timer); timer = null; }
if (ev.target.checked) timer = setInterval(loadFile, 5000);
});
var jumpBtn = document.getElementById('jump');
window.addEventListener('scroll', function () {
jumpBtn.style.display = (document.body.scrollHeight - window.scrollY - window.innerHeight > 400) ? 'block' : 'none';
});
jumpBtn.addEventListener('click', function () { window.scrollTo(0, document.body.scrollHeight); });
(async function init() {
await loadFileList();
await loadFile();
window.scrollTo(0, document.body.scrollHeight);
})();
})();
</script>
</body>
</html>

285
server/openapi.json Normal file
View File

@ -0,0 +1,285 @@
{
"openapi": "3.0.3",
"info": {
"title": "Midtrans Middleware API",
"version": "1.0.0",
"description": "Express backend integrating Midtrans (Core API + Snap) with an ERP system. Supports two payment-creation flows: a pre-registered shareable payment link (`/createtransaksi`, `POST /api/payment-links`) for ERP-initiated orders, and a direct checkout flow (`/api/payments/charge`, `/api/payments/snap/token`) called straight from the browser."
},
"servers": [
{ "url": "http://localhost:8000", "description": "Local" },
{ "url": "https://be-midtrans-cifo.winteraccess.id", "description": "Production" }
],
"components": {
"securitySchemes": {
"ApiKeyAuth": {
"type": "apiKey",
"in": "header",
"name": "X-API-KEY",
"description": "Required in production (EXTERNAL_API_KEY). Bypassed automatically when NODE_ENV is not 'production' and the key is unset."
},
"BasicAuth": {
"type": "http",
"scheme": "basic",
"description": "Required in production (LOG_BASIC_AUTH_USER/PASS). Bypassed automatically when NODE_ENV is not 'production' and the credentials are unset."
}
},
"schemas": {
"Customer": {
"type": "object",
"properties": {
"name": { "type": "string" },
"phone": { "type": "string" },
"email": { "type": "string" }
}
},
"ErrorResponse": {
"type": "object",
"properties": {
"error": { "type": "string" },
"message": { "type": "string" }
}
}
}
},
"paths": {
"/api/health": {
"get": {
"summary": "Health check",
"tags": ["Health & Config"],
"responses": {
"200": {
"description": "OK",
"content": { "application/json": { "example": { "ok": true, "env": { "isProduction": true, "hasServerKey": true, "hasClientKey": true } } } }
}
}
}
},
"/api/config": {
"get": {
"summary": "Get runtime payment-method toggles and Midtrans client key",
"tags": ["Health & Config"],
"responses": {
"200": {
"description": "OK",
"content": { "application/json": { "example": { "paymentToggles": { "bank_transfer": true, "credit_card": true, "gopay": true, "cstore": true }, "midtransEnv": "production", "clientKey": "Mid-client-xxx" } } }
}
}
},
"post": {
"summary": "Update payment-method toggles at runtime (non-production only)",
"tags": ["Health & Config"],
"requestBody": {
"content": { "application/json": { "example": { "paymentToggles": { "bank_transfer": false } } } }
},
"responses": {
"200": { "description": "Updated" },
"403": { "description": "Disabled in production", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } }
}
}
},
"/api/payment-links": {
"post": {
"summary": "Create a shareable Midtrans Snap payment link from a direct payload",
"description": "order_id is used as-is for tracking, and sanitized (any character outside [A-Za-z0-9-_~.] -> '.') for the value actually sent to Midtrans. mercant_id used for the ERP callback is derived from the segment before ':' in the original order_id. Rate-limited.",
"tags": ["Payment Links"],
"security": [{ "ApiKeyAuth": [] }],
"requestBody": {
"required": true,
"content": {
"application/json": {
"example": {
"order_id": "ERPSKRIP-2608030000000627:TKG-260803000063",
"nominal": 179000,
"customer": { "name": "Yusnika Nur Faidah", "phone": "0881022144656", "email": "yusnika_nur_faidah@example.com" },
"expire_at": 1785852063058
}
}
}
},
"responses": {
"200": {
"description": "Link created",
"content": { "application/json": { "example": { "status": "200", "messages": "SUCCESS", "data": { "url": "http://localhost:5173/pay/eyJ2Ijox...", "order_id": "ERPSKRIP-2608030000000627:TKG-260803000063", "midtrans_order_id": "ERPSKRIP-2608030000000627.TKG-260803000063", "expire_at": 1785852063058 } } } }
},
"400": { "description": "Missing/invalid order_id or nominal" },
"401": { "description": "Invalid X-API-KEY" },
"409": { "description": "Order already completed or has an active link/pending Midtrans transaction" },
"429": { "description": "Rate limited" }
}
}
},
"/api/payment-links/{token}": {
"get": {
"summary": "Resolve a payment link token",
"tags": ["Payment Links"],
"parameters": [{ "name": "token", "in": "path", "required": true, "schema": { "type": "string" } }],
"responses": {
"200": {
"description": "OK",
"content": { "application/json": { "example": { "order_id": "ERPSKRIP-2608030000000627.TKG-260803000063", "nominal": 179000, "customer": { "name": "Yusnika Nur Faidah", "phone": "0881022144656", "email": "yusnika_nur_faidah@example.com" }, "expire_at": 1785852063058, "allowed_methods": null } } }
},
"400": { "description": "Invalid token" },
"410": { "description": "Token expired" }
}
}
},
"/createtransaksi": {
"post": {
"summary": "Create a shareable Midtrans Snap payment link (ERP-facing, mercant_id/item shape)",
"description": "order_id is derived server-side as `mercant_id.item[0].item_id` (dot-joined). expire_at is NOT client-supplied — computed server-side from PAYMENT_LINK_TTL_MINUTES. Rate-limited.",
"tags": ["Payment Links"],
"security": [{ "ApiKeyAuth": [] }],
"requestBody": {
"required": true,
"content": {
"application/json": {
"example": {
"mercant_id": "ERPSKRIP-2608030000000627",
"nominal": 179000,
"nama": "Yusnika Nur Faidah",
"no_telepon": "0881022144656",
"email": "yusnika_nur_faidah@example.com",
"item": [{ "item_id": "TKG-260803000063" }],
"allowed_methods": ["bank_transfer", "gopay"]
}
}
}
},
"responses": {
"200": { "description": "Link created", "content": { "application/json": { "example": { "status": "200", "messages": "SUCCESS", "data": { "url": "http://localhost:5173/pay/eyJ2Ijox..." } } } } },
"400": { "description": "Missing order_id/nominal" },
"401": { "description": "Invalid X-API-KEY" },
"409": { "description": "Order already completed or has an active link/pending Midtrans transaction" },
"429": { "description": "Rate limited" }
}
}
},
"/api/payments/charge": {
"post": {
"summary": "Create a payment transaction via Midtrans Core API (bank transfer, card, GoPay/QRIS, cstore)",
"description": "Called directly from the browser checkout (no pre-registration required). Rate-limited to mitigate abuse/card-testing. Blocks re-charge if the order already has a pending Midtrans transaction, and honors ENABLE_* payment-method toggles.",
"tags": ["Payment Operations"],
"requestBody": {
"required": true,
"content": {
"application/json": {
"example": { "payment_type": "bank_transfer", "transaction_details": { "order_id": "order-123", "gross_amount": 150000 }, "bank_transfer": { "bank": "bca" } }
}
}
},
"responses": {
"200": { "description": "Raw Midtrans Core API charge response (passthrough)" },
"400": { "description": "Charge failed / payment type disabled" },
"409": { "description": "Order already has a pending Midtrans transaction" },
"429": { "description": "Rate limited" }
}
}
},
"/api/payments/snap/token": {
"post": {
"summary": "Generate a Midtrans Snap token for the hosted payment popup",
"description": "Called directly from the browser (PayPage / CheckoutPage) with a raw Midtrans Snap createTransaction payload. Rate-limited.",
"tags": ["Payment Operations"],
"requestBody": {
"required": true,
"content": {
"application/json": {
"example": { "transaction_details": { "order_id": "order-123", "gross_amount": 150000 }, "customer_details": { "first_name": "John", "email": "john@example.com", "phone": "081234567890" }, "item_details": [{ "id": "order-123", "name": "Payment", "price": 150000, "quantity": 1 }] }
}
}
},
"responses": {
"200": { "description": "Snap token", "content": { "application/json": { "example": { "token": { "token": "snap-token-xxx", "redirect_url": "https://app.midtrans.com/snap/v#/xxx" } } } } },
"400": { "description": "Snap token creation failed" },
"429": { "description": "Rate limited" }
}
}
},
"/api/payments/{orderId}/status": {
"get": {
"summary": "Check Midtrans transaction status",
"description": "Passthrough of Midtrans core.transaction.status(). Also fires the ERP-notify fallback (fire-and-forget) if the status is already successful and hasn't been notified yet.",
"tags": ["Payment Operations"],
"parameters": [{ "name": "orderId", "in": "path", "required": true, "schema": { "type": "string" } }],
"responses": {
"200": { "description": "Raw Midtrans status response (passthrough)" }
}
}
},
"/api/payments/notification": {
"post": {
"summary": "Midtrans webhook (unified for Core and Snap)",
"description": "Verifies signature_key (SHA512(order_id+status_code+gross_amount+MIDTRANS_SERVER_KEY)), maps status, and notifies the ERP with { mercant_id, status_code, nominal, signature }. Returns non-2xx on ERP-notify failure so Midtrans retries.",
"tags": ["Webhook"],
"requestBody": {
"required": true,
"content": {
"application/json": {
"example": { "order_id": "ERPSKRIP-2608030000000637.TKG-260801001361", "transaction_status": "settlement", "status_code": "200", "gross_amount": "159000.00", "signature_key": "..." }
}
}
},
"responses": {
"200": { "description": "Processed (or already processed)" },
"400": { "description": "Invalid signature" },
"500": { "description": "ERP notification failed — Midtrans will retry" }
}
}
},
"/api/logs": {
"get": {
"summary": "Get recent in-memory log entries",
"tags": ["Logs (internal)"],
"security": [{ "BasicAuth": [] }],
"parameters": [
{ "name": "limit", "in": "query", "schema": { "type": "integer", "default": 100, "minimum": 1, "maximum": 1000 } },
{ "name": "level", "in": "query", "schema": { "type": "string", "enum": ["debug", "info", "warn", "error"] } },
{ "name": "q", "in": "query", "schema": { "type": "string" }, "description": "Keyword search" }
],
"responses": {
"200": { "description": "OK" },
"401": { "description": "Basic Auth required" },
"403": { "description": "LOG_EXPOSE_API disabled" }
}
}
},
"/api/logs/files": {
"get": {
"summary": "List available daily log files",
"tags": ["Logs (internal)"],
"security": [{ "BasicAuth": [] }],
"responses": {
"200": { "description": "OK", "content": { "application/json": { "example": { "count": 2, "files": [{ "filename": "LOGS_03082026.log", "size": 39013, "modified": "2026-08-03T22:30:00.000Z", "path": "/api/logs/files/LOGS_03082026.log" }] } } } },
"401": { "description": "Basic Auth required" }
}
}
},
"/api/logs/files/{filename}": {
"get": {
"summary": "Read a specific log file as JSON lines (or redirects to the HTML viewer for browser navigation)",
"description": "Browser requests (Accept: text/html) are redirected to GET /api/logs/view?file=... instead of returning raw JSON.",
"tags": ["Logs (internal)"],
"security": [{ "BasicAuth": [] }],
"parameters": [{ "name": "filename", "in": "path", "required": true, "schema": { "type": "string", "pattern": "^LOGS_\\d{8}\\.log$" } }],
"responses": {
"200": { "description": "OK", "content": { "application/json": { "example": { "filename": "LOGS_03082026.log", "lines": 5777, "content": ["[2026-08-03T21:35:54.746+07:00] [INFO ] webhook.notifying_erp | {\"order_id\":\"...\"}"] } } } },
"302": { "description": "Redirect to the HTML viewer (browser navigation)" },
"400": { "description": "Invalid filename" },
"401": { "description": "Basic Auth required" },
"404": { "description": "File not found" }
}
}
},
"/api/logs/view": {
"get": {
"summary": "Readable log viewer — search, level filter, click-to-trace",
"tags": ["Logs (internal)"],
"security": [{ "BasicAuth": [] }],
"parameters": [{ "name": "file", "in": "query", "schema": { "type": "string" }, "description": "Defaults to the most recent log file" }],
"responses": {
"200": { "description": "HTML page", "content": { "text/html": {} } }
}
}
}
}
}