diff --git a/server/api-docs.html b/server/api-docs.html
new file mode 100644
index 0000000..9f1d6ec
--- /dev/null
+++ b/server/api-docs.html
@@ -0,0 +1,18 @@
+
+
+
+
+
+ Midtrans Middleware — API Docs
+
+
+
+
+
+
+
diff --git a/server/index.cjs b/server/index.cjs
index 1084981..fe8d2b7 100644
--- a/server/index.cjs
+++ b/server/index.cjs
@@ -226,6 +226,24 @@ if (!fs.existsSync(LOG_DIR)) {
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
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)
}
-function requireLogBasicAuth(req, res, next) {
+function requireAdminAuth(req, res, next) {
if (!LOG_BASIC_AUTH_USER || !LOG_BASIC_AUTH_PASS) {
if (isDevEnv()) return next()
- logWarn('logs.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.' })
+ logWarn('admin.auth.not_configured', { id: req.id })
+ 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'] || ''
@@ -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)
if (!ok) {
- if (scheme === 'Basic') logWarn('logs.auth.failed', { id: req.id, user })
- res.set('WWW-Authenticate', 'Basic realm="Midtrans Middleware Logs"')
- return res.status(401).json({ error: 'UNAUTHORIZED', message: 'Login required to access logs' })
+ if (scheme === 'Basic') logWarn('admin.auth.failed', { id: req.id, user })
+ res.set('WWW-Authenticate', 'Basic realm="Midtrans Middleware Admin"')
+ return res.status(401).json({ error: 'UNAUTHORIZED', message: 'Login required' })
}
next()
}
@@ -528,7 +546,7 @@ function requireLogBasicAuth(req, res, next) {
* Get recent logs from memory (dev/debug only)
* 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) {
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
* GET /api/logs/files
*/
-app.get('/api/logs/files', requireLogBasicAuth, (req, res) => {
+app.get('/api/logs/files', 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.' })
}
@@ -579,18 +597,23 @@ app.get('/api/logs/files', requireLogBasicAuth, (req, res) => {
* Read specific log file
* 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) {
return res.status(403).json({ error: 'FORBIDDEN', message: 'Log API disabled. Set LOG_EXPOSE_API=true to enable.' })
}
try {
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
if (!filename.match(/^LOGS_\d{8}\.log$/)) {
return res.status(400).json({ error: 'INVALID_FILENAME', message: 'Invalid log filename format' })
}
-
+
const filePath = path.join(LOG_DIR, filename)
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)
* POST /api/config
diff --git a/server/logviewer.html b/server/logviewer.html
new file mode 100644
index 0000000..248cac3
--- /dev/null
+++ b/server/logviewer.html
@@ -0,0 +1,246 @@
+
+
+
+
+
+Midtrans Middleware — Log Viewer
+
+
+
+
+
+ Trace aktif untuk:
+
+
+Memuat...
+
+
+
+
+
diff --git a/server/openapi.json b/server/openapi.json
new file mode 100644
index 0000000..31f9d9d
--- /dev/null
+++ b/server/openapi.json
@@ -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": {} } }
+ }
+ }
+ }
+ }
+}