Production readiness hardening and ops tooling

This commit is contained in:
2026-05-29 10:10:12 +07:00
parent e0b8f9af9a
commit 648e77cee9
68 changed files with 12222 additions and 848 deletions

View File

@ -1,22 +1,45 @@
import express from "express";
import helmet from "helmet";
import morgan from "morgan";
import { env } from "./config/env";
import { requestContext } from "./shared/middleware/requestContext";
import { requestLogging } from "./shared/middleware/requestLogging";
import { rateLimit } from "./shared/middleware/rateLimit";
import { handleErrors, successResponse } from "./shared/middleware/errorMiddleware";
import { NextFunction, Request, Response } from "express";
import adminRoutes from "./routes/admin";
import integrationRoutes from "./routes/integrations";
import deviceRoutes from "./routes/device";
import merchantRoutes from "./routes/merchant";
import { startNotificationOrchestrator } from "./shared/orchestrators/notificationOrchestrator";
import { startDynamicQrExpiryScheduler } from "./shared/services/dynamicQrExpiryScheduler";
import { startExportJobWorker } from "./shared/services/exportJobWorker";
import { startMqttSubscriber } from "./shared/services/mqttSubscriber";
import { getDatabaseHealth } from "./shared/services/health";
import path from "node:path";
import fs from "node:fs";
const app = express();
if (env.TRUST_PROXY === "true") {
app.set("trust proxy", 1);
}
startNotificationOrchestrator();
startDynamicQrExpiryScheduler();
startExportJobWorker();
startMqttSubscriber();
app.use(
helmet({
hidePoweredBy: true,
referrerPolicy: {
policy: "no-referrer"
},
hsts:
process.env.NODE_ENV === "production"
? {
maxAge: 15552000,
includeSubDomains: true
}
: false,
crossOriginResourcePolicy: {
policy: "cross-origin"
},
@ -35,14 +58,40 @@ app.use(
}
})
);
app.use(express.json());
app.use(morgan("dev"));
app.use(express.json({ limit: env.JSON_BODY_LIMIT }));
app.use(requestContext);
app.use(requestLogging);
const loginLimiter = rateLimit({
name: "login",
windowMs: env.RATE_LIMIT_LOGIN_WINDOW_MS,
max: env.RATE_LIMIT_LOGIN_MAX
});
const deviceLimiter = rateLimit({
name: "device",
windowMs: env.RATE_LIMIT_DEVICE_WINDOW_MS,
max: env.RATE_LIMIT_DEVICE_MAX,
key: (req) => `${req.ip}:${req.header("x-device-id") || "legacy"}`
});
const adminWriteLimiter = rateLimit({
name: "admin_write",
windowMs: env.RATE_LIMIT_ADMIN_WRITE_WINDOW_MS,
max: env.RATE_LIMIT_ADMIN_WRITE_MAX,
key: (req) => `${req.ip}:${req.header("authorization") || "anonymous"}`
});
app.get("/", (_req, res) => {
res.json(successResponse(_req, { status: "ok" }));
});
app.get("/favicon.ico", (_req, res) => {
res
.type("image/svg+xml")
.send(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="12" fill="#004ac6"/><path fill="#fff" d="M16 17h12v12H16V17Zm4 4v4h4v-4h-4Zm16-4h12v12H36V17Zm4 4v4h4v-4h-4ZM16 35h12v12H16V35Zm4 4v4h4v-4h-4Zm18-4h4v4h-4v-4Zm6 0h4v10h-4V35Zm-10 6h8v4h-8v-4Zm0 8h4v4h-4v-4Zm8 0h10v4H42v-4Z"/></svg>`
);
});
function resolveUiPageFile(slug: string) {
const workspaceRoot = process.cwd();
const candidates = [
@ -74,7 +123,22 @@ app.get("/ui/:page", (req, res, next) => {
res.sendFile(filePath);
});
app.use("/admin/login", loginLimiter);
app.use("/merchant/login", loginLimiter);
app.use("/admin", (req, res, next) => {
if (req.path === "/login") {
return next();
}
if (["POST", "PATCH", "PUT", "DELETE"].includes(req.method)) {
return adminWriteLimiter(req, res, next);
}
return next();
});
app.use("/device", deviceLimiter);
app.use("/integrations", rateLimit({ name: "integrations", windowMs: env.RATE_LIMIT_DEVICE_WINDOW_MS, max: env.RATE_LIMIT_DEVICE_MAX }));
app.use("/admin", adminRoutes);
app.use("/merchant", merchantRoutes);
app.use("/integrations", integrationRoutes);
app.use("/device", deviceRoutes);
@ -91,6 +155,20 @@ app.get("/health", (req, res) => {
);
});
app.get("/health/deep", async (req, res) => {
const database = await getDatabaseHealth();
const healthy = database.status === "ok";
res.status(healthy ? 200 : 503).json(
successResponse(req, {
status: healthy ? "healthy" : "degraded",
time: new Date().toISOString(),
checks: {
database
}
})
);
});
app.use((req, res) => {
res.status(404).json({
code: "NOT_FOUND",

View File

@ -1,15 +1,59 @@
import "dotenv/config";
export const env = {
PORT: Number(process.env.PORT || 3000),
TRUST_PROXY: process.env.TRUST_PROXY || "false",
JSON_BODY_LIMIT: process.env.JSON_BODY_LIMIT || "1mb",
LOG_FORMAT: process.env.LOG_FORMAT || "dev",
LOG_LEVEL: process.env.LOG_LEVEL || "info",
ADMIN_TOKEN: process.env.ADMIN_TOKEN || "admin-dev-token",
ADMIN_AUTH_ALLOW_LEGACY_TOKEN: process.env.ADMIN_AUTH_ALLOW_LEGACY_TOKEN || "true",
ADMIN_DEV_LOGIN_ENABLED: process.env.ADMIN_DEV_LOGIN_ENABLED || "true",
ADMIN_SESSION_SECRET: process.env.ADMIN_SESSION_SECRET || process.env.ADMIN_TOKEN || "admin-dev-token",
ADMIN_SESSION_TTL_SECONDS: Number(process.env.ADMIN_SESSION_TTL_SECONDS || 28800),
MERCHANT_TOKEN: process.env.MERCHANT_TOKEN || "merchant-dev-token",
MERCHANT_PORTAL_PASSWORD: process.env.MERCHANT_PORTAL_PASSWORD || "merchant",
MERCHANT_AUTH_ALLOW_LEGACY_TOKEN: process.env.MERCHANT_AUTH_ALLOW_LEGACY_TOKEN || "true",
MERCHANT_DEV_LOGIN_ENABLED: process.env.MERCHANT_DEV_LOGIN_ENABLED || "true",
MERCHANT_SESSION_SECRET: process.env.MERCHANT_SESSION_SECRET || process.env.MERCHANT_TOKEN || "merchant-dev-token",
MERCHANT_SESSION_TTL_SECONDS: Number(process.env.MERCHANT_SESSION_TTL_SECONDS || 28800),
DEVICE_TOKEN: process.env.DEVICE_TOKEN || "device-dev-token",
DEVICE_AUTH_ALLOW_LEGACY_TOKEN: process.env.DEVICE_AUTH_ALLOW_LEGACY_TOKEN || "true",
TRACE_HEADER: process.env.TRACE_HEADER || "x-request-id",
IDEMPOTENCY_TTL_MS: Number(process.env.IDEMPOTENCY_TTL_MS || 300000),
INTEGRATION_WEBHOOK_SECRET: process.env.INTEGRATION_WEBHOOK_SECRET || "dev-callback-secret",
MQTT_PUBLISH_MODE: process.env.MQTT_PUBLISH_MODE || "simulator",
MQTT_BROKER_URL: process.env.MQTT_BROKER_URL || "",
MQTT_USERNAME: process.env.MQTT_USERNAME || "",
MQTT_PASSWORD: process.env.MQTT_PASSWORD || "",
MQTT_CLIENT_ID: process.env.MQTT_CLIENT_ID || "qris-platform-backend",
MQTT_CONNECT_TIMEOUT_MS: Number(process.env.MQTT_CONNECT_TIMEOUT_MS || 5000),
MQTT_SUBSCRIBE_ENABLED: process.env.MQTT_SUBSCRIBE_ENABLED || "false",
MQTT_SUBSCRIBE_TOPICS: process.env.MQTT_SUBSCRIBE_TOPICS || "devices/+/uplink/#",
MQTT_PUBLISH_FORCE_FAIL_ALL: process.env.MQTT_PUBLISH_FORCE_FAIL_ALL || "false",
MQTT_PUBLISH_FORCE_FAIL_DEVICE_IDS: process.env.MQTT_PUBLISH_FORCE_FAIL_DEVICE_IDS || "",
MQTT_PUBLISH_DEFAULT_RETRY_INTERVAL_MS: Number(
process.env.MQTT_PUBLISH_DEFAULT_RETRY_INTERVAL_MS || 15000
),
DYNAMIC_QR_EXPIRY_SCHEDULER_ENABLED: process.env.DYNAMIC_QR_EXPIRY_SCHEDULER_ENABLED || "true",
DYNAMIC_QR_EXPIRY_SWEEP_INTERVAL_MS: Number(process.env.DYNAMIC_QR_EXPIRY_SWEEP_INTERVAL_MS || 60000),
DYNAMIC_QR_EXPIRY_SWEEP_LIMIT: Number(process.env.DYNAMIC_QR_EXPIRY_SWEEP_LIMIT || 100),
EXPORT_WORKER_ENABLED: process.env.EXPORT_WORKER_ENABLED || "true",
EXPORT_WORKER_INTERVAL_MS: Number(process.env.EXPORT_WORKER_INTERVAL_MS || 2000),
EXPORT_WORKER_BATCH_SIZE: Number(process.env.EXPORT_WORKER_BATCH_SIZE || 2),
EXPORT_JOB_STALE_RUNNING_MS: Number(process.env.EXPORT_JOB_STALE_RUNNING_MS || 900000),
EXPORT_SETTLEMENT_ADJUSTMENT_MAX_ROWS: Number(process.env.EXPORT_SETTLEMENT_ADJUSTMENT_MAX_ROWS || 5000),
EXPORT_STORAGE_DIR: process.env.EXPORT_STORAGE_DIR || "./storage/exports",
EXPORT_RETENTION_DAYS: Number(process.env.EXPORT_RETENTION_DAYS || 7),
RATE_LIMIT_ENABLED: process.env.RATE_LIMIT_ENABLED || "true",
RATE_LIMIT_LOGIN_WINDOW_MS: Number(process.env.RATE_LIMIT_LOGIN_WINDOW_MS || 60000),
RATE_LIMIT_LOGIN_MAX: Number(process.env.RATE_LIMIT_LOGIN_MAX || 20),
RATE_LIMIT_DEVICE_WINDOW_MS: Number(process.env.RATE_LIMIT_DEVICE_WINDOW_MS || 60000),
RATE_LIMIT_DEVICE_MAX: Number(process.env.RATE_LIMIT_DEVICE_MAX || 600),
RATE_LIMIT_ADMIN_WRITE_WINDOW_MS: Number(process.env.RATE_LIMIT_ADMIN_WRITE_WINDOW_MS || 60000),
RATE_LIMIT_ADMIN_WRITE_MAX: Number(process.env.RATE_LIMIT_ADMIN_WRITE_MAX || 300),
FINANCE_PLATFORM_FEE_BPS: Number(process.env.FINANCE_PLATFORM_FEE_BPS || 70),
SETTLEMENT_ADJUSTMENT_REQUIRE_APPROVAL: process.env.SETTLEMENT_ADJUSTMENT_REQUIRE_APPROVAL || "false",
DATABASE_URL: process.env.DATABASE_URL || "",
PGHOST: process.env.PGHOST || "127.0.0.1",
PGPORT: Number(process.env.PGPORT || 5432),

View File

@ -2,6 +2,7 @@ import { createServer } from "node:http";
import app from "./app";
import { ensureSchema } from "./shared/db/pool";
import { env } from "./config/env";
import { logger } from "./shared/services/logger";
const port = env.PORT;
@ -11,7 +12,9 @@ async function bootstrap() {
await ensureSchema();
server.listen(port, () => {
console.log(`QRIS Soundbox Platform bootstrap ready on :${port}`);
logger.info("server_started", {
port
});
});
}

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
import { Router, Request, Response, NextFunction } from "express";
import { ApiError } from "../shared/errors";
import { requireDeviceToken } from "../shared/middleware/auth";
import { getDeviceScopeError, requireDeviceToken } from "../shared/middleware/auth";
import { successResponse } from "../shared/middleware/errorMiddleware";
import { getDeviceById, patchDevice } from "../shared/store/deviceStore";
import { createDeviceHeartbeat } from "../shared/store/heartbeatStore";
@ -136,6 +136,10 @@ router.post("/heartbeat", requireDeviceToken, async (req: Request, res: Response
if (!payload || !payload.device_id) {
return next(new ApiError("BAD_REQUEST", "device_id is required", 400));
}
const scopeError = getDeviceScopeError(req, payload.device_id);
if (scopeError) {
return next(scopeError);
}
const device = await getDeviceById(payload.device_id);
if (!device) {
@ -199,6 +203,10 @@ router.post("/commands/ack", requireDeviceToken, async (req: Request, res: Respo
if (!payload || !payload.command_id || !payload.device_id || !payload.status) {
return next(new ApiError("BAD_REQUEST", "command_id, device_id, status are required", 400));
}
const scopeError = getDeviceScopeError(req, payload.device_id);
if (scopeError) {
return next(scopeError);
}
if (!["delivered", "failed", "timeout"].includes(payload.status)) {
return next(new ApiError("BAD_REQUEST", "status must be delivered, failed, or timeout", 400));
@ -235,6 +243,10 @@ router.post("/transactions/dynamic-qr", requireDeviceToken, async (req: Request,
if (!payload || !payload.device_id || !payload.terminal_id || !payload.request_id) {
return next(new ApiError("BAD_REQUEST", "device_id, terminal_id, request_id are required", 400));
}
const scopeError = getDeviceScopeError(req, payload.device_id);
if (scopeError) {
return next(scopeError);
}
const amount = normalizePositiveAmount(payload.amount);
if (amount === null) {
@ -310,6 +322,10 @@ router.post("/mqtt/uplink/dynamic-qr/request", requireDeviceToken, async (req: R
if (!payload || !payload.device_id || !payload.terminal_id || !payload.request_id) {
return next(new ApiError("BAD_REQUEST", "device_id, terminal_id, request_id are required", 400));
}
const scopeError = getDeviceScopeError(req, payload.device_id);
if (scopeError) {
return next(scopeError);
}
if (payload.message_type && payload.message_type !== "dynamic_qr_request") {
return next(new ApiError("BAD_REQUEST", "message_type must be dynamic_qr_request", 400));
@ -418,6 +434,10 @@ router.get("/config", requireDeviceToken, async (req: Request, res: Response, ne
if (!deviceId) {
return next(new ApiError("BAD_REQUEST", "device_id is required", 400));
}
const scopeError = getDeviceScopeError(req, deviceId);
if (scopeError) {
return next(scopeError);
}
const device = await getDeviceById(deviceId);
if (!device) {
@ -433,6 +453,10 @@ router.post("/config/ack", requireDeviceToken, async (req: Request, res: Respons
if (!payload || !payload.device_id || !payload.status) {
return next(new ApiError("BAD_REQUEST", "device_id, status are required", 400));
}
const scopeError = getDeviceScopeError(req, payload.device_id);
if (scopeError) {
return next(scopeError);
}
if (!["applied", "failed"].includes(payload.status)) {
return next(new ApiError("BAD_REQUEST", "status must be applied or failed", 400));

348
src/routes/merchant.ts Normal file
View File

@ -0,0 +1,348 @@
import { Router, Request, Response, NextFunction } from "express";
import { ApiError } from "../shared/errors";
import { env } from "../config/env";
import { successResponse } from "../shared/middleware/errorMiddleware";
import { createMerchantSessionToken, verifyMerchantSessionToken, type MerchantSessionPayload } from "../shared/services/sessionToken";
import { getMerchantByCode, getMerchantById, toMerchantPayload } from "../shared/store/merchantStore";
import {
getMerchantUserByEmail,
toMerchantUserPayload,
verifyMerchantPassword
} from "../shared/store/merchantUserStore";
import {
createSettlementBatchEvent,
getSettlementBatchByIdForMerchant,
getSettlementBatchReportRows,
getSettlementFinanceSummary,
listSettlementBatchAdjustments,
listSettlementBatchEntries,
listSettlementBatchEvents,
listSettlementBatches,
settlementBatchReportToBankGenericCsv,
settlementBatchReportToCsv,
SettlementBatchStatus,
toSettlementBatchAdjustmentPayload,
toSettlementBatchEntryPayload,
toSettlementBatchEventPayload,
toSettlementBatchPayload
} from "../shared/store/settlementStore";
import { createAuditLog } from "../shared/store/auditLogStore";
const router = Router();
type MerchantRequest = Request & {
merchantId?: string;
merchantAuth?: {
mode: "legacy_token" | "session";
merchant_id: string;
user_id?: string;
role_name?: string;
session?: MerchantSessionPayload;
};
};
function extractToken(req: Request) {
const raw = req.header("authorization") || "";
if (raw.startsWith("Bearer ")) {
return raw.slice(7);
}
return raw || req.header("x-merchant-token") || "";
}
function parseSettlementBatchStatus(value: string | undefined): SettlementBatchStatus | undefined {
if (value === "created" || value === "paid" || value === "failed" || value === "cancelled") {
return value;
}
return undefined;
}
function parseSettlementExportFormat(value: string | undefined): "standard" | "bank_generic" | undefined {
if (!value || value === "standard" || value === "general") {
return "standard";
}
if (value === "bank_generic") {
return "bank_generic";
}
return undefined;
}
async function requireMerchantToken(req: MerchantRequest, _res: Response, next: NextFunction) {
const token = extractToken(req);
const merchantId = req.header("x-merchant-id") || "";
if (!token) {
return next(new ApiError("UNAUTHORIZED", "Missing merchant bearer token", 401));
}
const session = verifyMerchantSessionToken(token, env.MERCHANT_SESSION_SECRET);
if (session) {
if (merchantId && merchantId !== session.merchant_id) {
return next(new ApiError("FORBIDDEN", "merchant session cannot access another merchant", 403));
}
const merchant = await getMerchantById(session.merchant_id);
if (!merchant) {
return next(new ApiError("UNAUTHORIZED", "Invalid merchant scope", 401));
}
req.merchantId = merchant.id;
req.merchantAuth = {
mode: "session",
merchant_id: merchant.id,
user_id: session.sub,
role_name: session.role_name,
session
};
return next();
}
const allowLegacyToken = env.MERCHANT_AUTH_ALLOW_LEGACY_TOKEN !== "false";
if (!allowLegacyToken || token !== env.MERCHANT_TOKEN) {
return next(new ApiError("UNAUTHORIZED", "Invalid merchant token", 401));
}
if (!merchantId) {
return next(new ApiError("UNAUTHORIZED", "Missing merchant scope", 401));
}
const merchant = await getMerchantById(merchantId);
if (!merchant) {
return next(new ApiError("UNAUTHORIZED", "Invalid merchant scope", 401));
}
req.merchantId = merchant.id;
req.merchantAuth = {
mode: "legacy_token",
merchant_id: merchant.id
};
return next();
}
router.post("/login", async (req: Request, res: Response, next: NextFunction) => {
const payload = (req.body || {}) as {
merchant_id?: string;
merchant_code?: string;
username?: string;
password?: string;
};
const loginId = (payload.merchant_id || payload.merchant_code || payload.username || "").trim();
const password = payload.password || "";
if (!loginId || !password) {
return next(new ApiError("BAD_REQUEST", "merchant id/code and password are required", 400));
}
const merchantUser = loginId.includes("@") ? await getMerchantUserByEmail(loginId) : null;
if (merchantUser) {
if (merchantUser.status !== "active" || !verifyMerchantPassword(merchantUser.password_hash, password)) {
await createAuditLog({
actor_type: "merchant",
actor_id: merchantUser.id,
action: "merchant.login.failed",
entity_type: "merchant_session",
entity_id: merchantUser.merchant_id,
after_json: {
reason: merchantUser.status !== "active" ? "inactive_user" : "invalid_credentials",
auth_mode: "session"
},
source_ip: req.ip,
request_id: req.requestId,
trace_id: req.traceId
});
return next(new ApiError("UNAUTHORIZED", "Invalid merchant credentials", 401));
}
const merchant = await getMerchantById(merchantUser.merchant_id);
if (!merchant) {
return next(new ApiError("UNAUTHORIZED", "Invalid merchant credentials", 401));
}
const token = createMerchantSessionToken(
{
typ: "merchant",
sub: merchantUser.id,
merchant_id: merchant.id,
name: merchantUser.name,
email: merchantUser.email,
role_name: merchantUser.role_name
},
env.MERCHANT_SESSION_SECRET,
env.MERCHANT_SESSION_TTL_SECONDS
);
await createAuditLog({
actor_type: "merchant",
actor_id: merchantUser.id,
action: "merchant.login.success",
entity_type: "merchant_session",
entity_id: merchant.id,
after_json: {
auth_mode: "session",
role_name: merchantUser.role_name
},
source_ip: req.ip,
request_id: req.requestId,
trace_id: req.traceId
});
return res.json(
successResponse(req, {
token,
auth_mode: "session",
merchant: toMerchantPayload(merchant),
user: toMerchantUserPayload(merchantUser)
})
);
}
if (env.MERCHANT_DEV_LOGIN_ENABLED === "false" || password !== env.MERCHANT_PORTAL_PASSWORD) {
await createAuditLog({
actor_type: "merchant",
actor_id: loginId || "unknown",
action: "merchant.login.failed",
entity_type: "merchant_session",
entity_id: loginId || "unknown",
after_json: {
reason: "invalid_legacy_credentials",
auth_mode: "legacy_token"
},
source_ip: req.ip,
request_id: req.requestId,
trace_id: req.traceId
});
return next(new ApiError("UNAUTHORIZED", "Invalid merchant credentials", 401));
}
const merchant = (await getMerchantById(loginId)) || (await getMerchantByCode(loginId));
if (!merchant) {
await createAuditLog({
actor_type: "merchant",
actor_id: loginId || "unknown",
action: "merchant.login.failed",
entity_type: "merchant_session",
entity_id: loginId || "unknown",
after_json: {
reason: "merchant_not_found",
auth_mode: "legacy_token"
},
source_ip: req.ip,
request_id: req.requestId,
trace_id: req.traceId
});
return next(new ApiError("UNAUTHORIZED", "Invalid merchant credentials", 401));
}
await createAuditLog({
actor_type: "merchant",
actor_id: merchant.id,
action: "merchant.login.success",
entity_type: "merchant_session",
entity_id: merchant.id,
after_json: {
auth_mode: "legacy_token"
},
source_ip: req.ip,
request_id: req.requestId,
trace_id: req.traceId
});
res.json(
successResponse(req, {
token: env.MERCHANT_TOKEN,
auth_mode: "legacy_token",
merchant: toMerchantPayload(merchant)
})
);
});
router.get("/profile", requireMerchantToken, async (req: MerchantRequest, res: Response, next: NextFunction) => {
const merchant = await getMerchantById(req.merchantId || "");
if (!merchant) {
return next(new ApiError("NOT_FOUND", "merchant not found", 404));
}
res.json(
successResponse(req, {
merchant: toMerchantPayload(merchant),
auth_mode: req.merchantAuth?.mode,
user: req.merchantAuth?.session
? {
id: req.merchantAuth.session.sub,
name: req.merchantAuth.session.name,
email: req.merchantAuth.session.email,
role_name: req.merchantAuth.session.role_name
}
: null
})
);
});
router.get("/settlement-summary", requireMerchantToken, async (req: MerchantRequest, res: Response) => {
const summary = await getSettlementFinanceSummary({ merchant_id: req.merchantId });
res.json(successResponse(req, summary));
});
router.get("/settlement-batches", requireMerchantToken, async (req: MerchantRequest, res: Response, next: NextFunction) => {
const statusRaw = req.query.status as string | undefined;
const status = parseSettlementBatchStatus(statusRaw);
const limitRaw = req.query.limit as string | undefined;
const limit = limitRaw ? Number(limitRaw) : 100;
if (statusRaw && !status) {
return next(new ApiError("BAD_REQUEST", "status must be created|paid|failed|cancelled", 400));
}
if (!Number.isFinite(limit) || limit <= 0) {
return next(new ApiError("BAD_REQUEST", "limit must be a positive number", 400));
}
const batches = await listSettlementBatches({
merchant_id: req.merchantId,
status,
limit
});
res.json(successResponse(req, batches.map(toSettlementBatchPayload)));
});
router.get("/settlement-batches/:batchId", requireMerchantToken, async (req: MerchantRequest, res: Response, next: NextFunction) => {
const batch = await getSettlementBatchByIdForMerchant(req.params.batchId, req.merchantId || "");
if (!batch) {
return next(new ApiError("NOT_FOUND", "settlement batch not found", 404));
}
const entries = await listSettlementBatchEntries(batch.id);
const adjustments = await listSettlementBatchAdjustments(batch.id);
const events = await listSettlementBatchEvents(batch.id);
res.json(
successResponse(req, {
batch: toSettlementBatchPayload(batch),
entries: entries.map(toSettlementBatchEntryPayload),
adjustments: adjustments.map(toSettlementBatchAdjustmentPayload),
events: events.map(toSettlementBatchEventPayload)
})
);
});
router.get(
"/settlement-batches/:batchId/export.csv",
requireMerchantToken,
async (req: MerchantRequest, res: Response, next: NextFunction) => {
const batch = await getSettlementBatchByIdForMerchant(req.params.batchId, req.merchantId || "");
if (!batch) {
return next(new ApiError("NOT_FOUND", "settlement batch not found", 404));
}
const formatRaw = req.query.format as string | undefined;
const format = parseSettlementExportFormat(formatRaw);
if (!format) {
return next(new ApiError("BAD_REQUEST", "format must be standard|bank_generic", 400));
}
const rows = await getSettlementBatchReportRows(batch.id);
const csv = format === "bank_generic" ? settlementBatchReportToBankGenericCsv(rows) : settlementBatchReportToCsv(rows);
const filename =
format === "bank_generic"
? `${batch.batch_code}-bank-generic-payout.csv`
: `${batch.batch_code}-payout-report.csv`;
await createSettlementBatchEvent({
batch_id: batch.id,
merchant_id: batch.merchant_id,
event_type: "csv_exported",
actor_type: "merchant",
actor_id: batch.merchant_id,
payload_json: {
format,
report_rows: rows.length,
filename
}
});
res.setHeader("Content-Type", "text/csv; charset=utf-8");
res.setHeader("Content-Disposition", `attachment; filename="${filename}"`);
res.send(csv);
}
);
export default router;

View File

@ -41,6 +41,13 @@ export function getPool(): Pool {
return pool;
}
export async function closePool() {
if (pool) {
await pool.end();
pool = null;
}
}
export async function withClient<T>(work: (client: PoolClient) => Promise<T>): Promise<T> {
const client = await getPool().connect();
try {
@ -73,6 +80,41 @@ CREATE TABLE IF NOT EXISTS merchants (
updated_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE IF NOT EXISTS merchant_users (
id TEXT PRIMARY KEY,
merchant_id TEXT NOT NULL REFERENCES merchants (id) ON DELETE CASCADE,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role_name TEXT NOT NULL DEFAULT 'owner' CHECK (role_name IN ('owner', 'finance', 'ops', 'viewer')),
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')),
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_merchant_users_merchant ON merchant_users (merchant_id, status);
CREATE TABLE IF NOT EXISTS export_jobs (
id TEXT PRIMARY KEY,
job_type TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'running', 'completed', 'failed')),
requested_by TEXT,
request_json JSONB NOT NULL DEFAULT '{}'::jsonb,
result_content_type TEXT,
result_filename TEXT,
result_body TEXT,
result_storage_path TEXT,
result_size_bytes INTEGER,
expires_at TIMESTAMPTZ,
error_message TEXT,
created_at TIMESTAMPTZ NOT NULL,
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_export_jobs_status ON export_jobs (status, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_export_jobs_expires_at ON export_jobs (expires_at);
CREATE TABLE IF NOT EXISTS outlets (
id TEXT PRIMARY KEY,
merchant_id TEXT NOT NULL REFERENCES merchants (id) ON DELETE CASCADE,
@ -104,6 +146,12 @@ CREATE TABLE IF NOT EXISTS devices (
communication_mode TEXT NOT NULL DEFAULT 'static' CHECK (communication_mode IN ('static', 'mqtt', 'api')),
capability_profile_json JSONB NOT NULL DEFAULT '{}'::jsonb,
auth_method TEXT,
mqtt_username TEXT,
credential_secret_fingerprint TEXT,
credential_status TEXT NOT NULL DEFAULT 'not_issued' CHECK (credential_status IN ('not_issued', 'active', 'rotation_required', 'revoked')),
credential_issued_at TIMESTAMPTZ,
credential_rotated_at TIMESTAMPTZ,
credential_revoked_at TIMESTAMPTZ,
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')),
last_seen_at TIMESTAMPTZ,
firmware_version TEXT,
@ -111,6 +159,13 @@ CREATE TABLE IF NOT EXISTS devices (
updated_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_devices_status_last_seen ON devices (status, last_seen_at DESC);
ALTER TABLE devices ADD COLUMN IF NOT EXISTS mqtt_username TEXT;
ALTER TABLE devices ADD COLUMN IF NOT EXISTS credential_secret_fingerprint TEXT;
ALTER TABLE devices ADD COLUMN IF NOT EXISTS credential_status TEXT NOT NULL DEFAULT 'not_issued';
ALTER TABLE devices ADD COLUMN IF NOT EXISTS credential_issued_at TIMESTAMPTZ;
ALTER TABLE devices ADD COLUMN IF NOT EXISTS credential_rotated_at TIMESTAMPTZ;
ALTER TABLE devices ADD COLUMN IF NOT EXISTS credential_revoked_at TIMESTAMPTZ;
CREATE INDEX IF NOT EXISTS idx_devices_credential_status ON devices (credential_status);
CREATE TABLE IF NOT EXISTS device_bindings (
id TEXT PRIMARY KEY,
@ -263,6 +318,151 @@ CREATE TABLE IF NOT EXISTS ledger_entries (
CREATE INDEX IF NOT EXISTS idx_ledger_entries_merchant_created ON ledger_entries (merchant_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_ledger_entries_tx ON ledger_entries (transaction_id);
CREATE TABLE IF NOT EXISTS settlement_batches (
id TEXT PRIMARY KEY,
batch_code TEXT NOT NULL UNIQUE,
merchant_id TEXT NOT NULL REFERENCES merchants (id) ON DELETE CASCADE,
currency TEXT NOT NULL DEFAULT 'IDR',
gross_amount NUMERIC(20,2) NOT NULL DEFAULT 0,
platform_fee_amount NUMERIC(20,2) NOT NULL DEFAULT 0,
net_payable_amount NUMERIC(20,2) NOT NULL DEFAULT 0,
entry_count INT NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'created' CHECK (status IN ('created', 'paid', 'failed', 'cancelled')),
cutoff_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
paid_at TIMESTAMPTZ,
failure_reason TEXT,
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb
);
CREATE INDEX IF NOT EXISTS idx_settlement_batches_merchant_created ON settlement_batches (merchant_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_settlement_batches_status_created ON settlement_batches (status, created_at DESC);
CREATE TABLE IF NOT EXISTS settlement_batch_entries (
id TEXT PRIMARY KEY,
batch_id TEXT NOT NULL REFERENCES settlement_batches (id) ON DELETE CASCADE,
ledger_entry_id TEXT NOT NULL REFERENCES ledger_entries (id) ON DELETE CASCADE,
transaction_id TEXT NOT NULL REFERENCES transactions (id) ON DELETE CASCADE,
merchant_id TEXT NOT NULL REFERENCES merchants (id) ON DELETE CASCADE,
amount NUMERIC(20,2) NOT NULL,
currency TEXT NOT NULL DEFAULT 'IDR',
created_at TIMESTAMPTZ NOT NULL,
CONSTRAINT settlement_batch_entries_unique_ledger UNIQUE (ledger_entry_id)
);
CREATE INDEX IF NOT EXISTS idx_settlement_batch_entries_batch ON settlement_batch_entries (batch_id);
CREATE INDEX IF NOT EXISTS idx_settlement_batch_entries_merchant ON settlement_batch_entries (merchant_id, created_at DESC);
CREATE TABLE IF NOT EXISTS settlement_batch_events (
id TEXT PRIMARY KEY,
batch_id TEXT NOT NULL REFERENCES settlement_batches (id) ON DELETE CASCADE,
merchant_id TEXT NOT NULL REFERENCES merchants (id) ON DELETE CASCADE,
event_type TEXT NOT NULL,
actor_type TEXT NOT NULL,
actor_id TEXT,
payload_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_settlement_batch_events_batch ON settlement_batch_events (batch_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_settlement_batch_events_merchant ON settlement_batch_events (merchant_id, created_at DESC);
CREATE TABLE IF NOT EXISTS settlement_batch_adjustments (
id TEXT PRIMARY KEY,
batch_id TEXT NOT NULL REFERENCES settlement_batches (id) ON DELETE CASCADE,
merchant_id TEXT NOT NULL REFERENCES merchants (id) ON DELETE CASCADE,
adjustment_type TEXT NOT NULL CHECK (adjustment_type IN ('credit', 'debit')),
amount NUMERIC(20,2) NOT NULL,
signed_amount NUMERIC(20,2) NOT NULL,
currency TEXT NOT NULL DEFAULT 'IDR',
reason TEXT NOT NULL,
note TEXT,
approval_status TEXT NOT NULL DEFAULT 'approved' CHECK (approval_status IN ('pending', 'approved', 'rejected')),
approved_by TEXT,
approved_at TIMESTAMPTZ,
rejected_by TEXT,
rejected_at TIMESTAMPTZ,
actor_type TEXT NOT NULL DEFAULT 'admin',
actor_id TEXT,
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL
);
ALTER TABLE settlement_batch_adjustments ADD COLUMN IF NOT EXISTS approval_status TEXT NOT NULL DEFAULT 'approved';
ALTER TABLE settlement_batch_adjustments ADD COLUMN IF NOT EXISTS approved_by TEXT;
ALTER TABLE settlement_batch_adjustments ADD COLUMN IF NOT EXISTS approved_at TIMESTAMPTZ;
ALTER TABLE settlement_batch_adjustments ADD COLUMN IF NOT EXISTS rejected_by TEXT;
ALTER TABLE settlement_batch_adjustments ADD COLUMN IF NOT EXISTS rejected_at TIMESTAMPTZ;
CREATE INDEX IF NOT EXISTS idx_settlement_batch_adjustments_batch ON settlement_batch_adjustments (batch_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_settlement_batch_adjustments_merchant ON settlement_batch_adjustments (merchant_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_settlement_batch_adjustments_approval ON settlement_batch_adjustments (approval_status, created_at DESC);
INSERT INTO settlement_batch_adjustments (
id,
batch_id,
merchant_id,
adjustment_type,
amount,
signed_amount,
currency,
reason,
note,
approval_status,
approved_by,
approved_at,
actor_type,
actor_id,
metadata_json,
created_at
)
SELECT
COALESCE(adjustment.item->>'id', 'adj_backfill_' || sb.id || '_' || adjustment.ordinality::text) AS id,
sb.id AS batch_id,
sb.merchant_id,
CASE
WHEN adjustment.item->>'adjustment_type' IN ('credit', 'debit') THEN adjustment.item->>'adjustment_type'
WHEN COALESCE(NULLIF(adjustment.item->>'signed_amount', '')::numeric, 0) >= 0 THEN 'credit'
ELSE 'debit'
END AS adjustment_type,
ABS(COALESCE(NULLIF(adjustment.item->>'amount', '')::numeric, NULLIF(adjustment.item->>'signed_amount', '')::numeric, 0)) AS amount,
COALESCE(
NULLIF(adjustment.item->>'signed_amount', '')::numeric,
CASE
WHEN adjustment.item->>'adjustment_type' = 'debit' THEN -ABS(COALESCE(NULLIF(adjustment.item->>'amount', '')::numeric, 0))
ELSE ABS(COALESCE(NULLIF(adjustment.item->>'amount', '')::numeric, 0))
END
) AS signed_amount,
sb.currency,
COALESCE(NULLIF(adjustment.item->>'reason', ''), 'Backfilled settlement adjustment') AS reason,
NULLIF(adjustment.item->>'note', '') AS note,
'approved' AS approval_status,
COALESCE(NULLIF(adjustment.item->>'actor_id', ''), 'metadata_backfill') AS approved_by,
COALESCE(NULLIF(adjustment.item->>'created_at', '')::timestamptz, sb.created_at) AS approved_at,
COALESCE(NULLIF(adjustment.item->>'actor_type', ''), 'admin') AS actor_type,
NULLIF(adjustment.item->>'actor_id', '') AS actor_id,
jsonb_build_object('source', 'metadata_backfill', 'original', adjustment.item) AS metadata_json,
COALESCE(NULLIF(adjustment.item->>'created_at', '')::timestamptz, sb.created_at) AS created_at
FROM settlement_batches sb
CROSS JOIN LATERAL jsonb_array_elements(sb.metadata_json->'adjustments') WITH ORDINALITY AS adjustment(item, ordinality)
WHERE jsonb_typeof(sb.metadata_json->'adjustments') = 'array'
ON CONFLICT (id) DO NOTHING;
WITH adjustment_totals AS (
SELECT batch_id, COALESCE(SUM(signed_amount), 0) AS total_adjustment_amount
FROM settlement_batch_adjustments
WHERE approval_status = 'approved'
GROUP BY batch_id
)
UPDATE settlement_batches sb
SET metadata_json = sb.metadata_json || jsonb_build_object(
'total_adjustment_amount', adjustment_totals.total_adjustment_amount,
'adjustment_source', 'settlement_batch_adjustments',
'adjustment_backfilled_at', NOW()
)
FROM adjustment_totals
WHERE adjustment_totals.batch_id = sb.id;
CREATE TABLE IF NOT EXISTS roles (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
@ -302,6 +502,14 @@ INSERT INTO roles (id, name, permissions_json, created_at)
VALUES ('role_admin', 'admin', '{"admin":"*"}'::jsonb, NOW())
ON CONFLICT (id) DO NOTHING;
INSERT INTO roles (id, name, permissions_json, created_at)
VALUES
('role_finance', 'finance', '{"admin":["read"],"merchant":["read"],"device":["read"],"transaction":["read"],"settlement":"*","reconciliation":"*","audit":["read"]}'::jsonb, NOW()),
('role_ops', 'ops', '{"admin":["read"],"merchant":"*","outlet":"*","terminal":"*","device":"*","transaction":"*","notification":"*","settlement":["read"],"reconciliation":["read"],"audit":["read"]}'::jsonb, NOW()),
('role_support', 'support', '{"admin":["read"],"merchant":["read"],"outlet":["read"],"terminal":["read"],"device":["read"],"transaction":["read"],"notification":["read"],"settlement":["read"],"audit":["read"]}'::jsonb, NOW()),
('role_viewer', 'viewer', '{"admin":["read"],"merchant":["read"],"outlet":["read"],"terminal":["read"],"device":["read"],"transaction":["read"],"settlement":["read"],"reconciliation":["read"]}'::jsonb, NOW())
ON CONFLICT (id) DO NOTHING;
INSERT INTO users (id, name, email, password_hash, role_id, status, created_at)
VALUES ('user_admin_seed', 'Admin Seed', 'admin@example.local', 'dev-only-admin-password', 'role_admin', 'active', NOW())
ON CONFLICT (id) DO NOTHING;

View File

@ -4,6 +4,7 @@ export type ErrorCode =
| "FORBIDDEN"
| "NOT_FOUND"
| "CONFLICT"
| "RATE_LIMITED"
| "INTERNAL_ERROR"
| "DEVICE_UNAUTHORIZED"
| "DUPLICATE_REQUEST"

View File

@ -1,6 +1,44 @@
import { NextFunction, Request, Response } from "express";
import { ApiError } from "../errors";
import { env } from "../../config/env";
import { verifyDeviceSecret } from "../store/deviceStore";
import { verifySessionToken, type SessionPayload } from "../services/sessionToken";
import { hasPermission } from "../store/userStore";
export type DeviceAuthContext =
| {
mode: "legacy_token";
}
| {
mode: "device_credential";
device_id: string;
};
export type RequestWithDeviceAuth = Request & {
deviceAuth?: DeviceAuthContext;
};
export type AdminAuthContext =
| {
mode: "legacy_token";
user_id: "legacy_admin";
role_name: "admin";
permissions: { admin: "*" };
}
| {
mode: "session";
user_id: string;
name: string;
email: string;
role_id: string;
role_name: string;
permissions: unknown;
session: SessionPayload;
};
export type RequestWithAdminAuth = Request & {
adminAuth?: AdminAuthContext;
};
function extractAdminToken(req: Request) {
const raw = req.header("authorization") || "";
@ -11,30 +49,106 @@ function extractAdminToken(req: Request) {
return raw || req.header("x-admin-token") || "";
}
export function requireAdminToken(req: Request, _res: Response, next: NextFunction) {
export function requireAdminToken(req: RequestWithAdminAuth, _res: Response, next: NextFunction) {
const token = extractAdminToken(req);
if (!token) {
return next(new ApiError("UNAUTHORIZED", "Missing admin bearer token", 401));
}
if (token !== env.ADMIN_TOKEN) {
const session = verifySessionToken(token, env.ADMIN_SESSION_SECRET);
if (session) {
req.adminAuth = {
mode: "session",
user_id: session.sub,
name: session.name,
email: session.email,
role_id: session.role_id,
role_name: session.role_name,
permissions: session.permissions,
session
};
return next();
}
const allowLegacyToken = env.ADMIN_AUTH_ALLOW_LEGACY_TOKEN !== "false";
if (!allowLegacyToken || token !== env.ADMIN_TOKEN) {
return next(new ApiError("UNAUTHORIZED", "Invalid admin token", 401));
}
req.adminAuth = {
mode: "legacy_token",
user_id: "legacy_admin",
role_name: "admin",
permissions: { admin: "*" }
};
return next();
}
export function requireDeviceToken(req: Request, _res: Response, next: NextFunction) {
export function requireAdminPermission(permission: string) {
return (req: RequestWithAdminAuth, _res: Response, next: NextFunction) => {
if (!req.adminAuth) {
return requireAdminToken(req, _res, (error?: unknown) => {
if (error) {
return next(error);
}
if (!hasPermission(req.adminAuth?.permissions, permission)) {
return next(new ApiError("FORBIDDEN", "Admin role cannot perform this action", 403));
}
return next();
});
}
if (!hasPermission(req.adminAuth.permissions, permission)) {
return next(new ApiError("FORBIDDEN", "Admin role cannot perform this action", 403));
}
return next();
};
}
export function requireDeviceToken(req: RequestWithDeviceAuth, _res: Response, next: NextFunction) {
const raw = req.header("authorization") || "";
const token = raw.startsWith("Bearer ") ? raw.slice(7) : raw;
const headerDeviceId = req.header("x-device-id") || "";
const headerSecret = req.header("x-device-secret") || "";
const allowLegacyToken = env.DEVICE_AUTH_ALLOW_LEGACY_TOKEN !== "false";
if (headerDeviceId && headerSecret) {
verifyDeviceSecret(headerDeviceId, headerSecret)
.then((device) => {
if (!device) {
return next(new ApiError("UNAUTHORIZED", "Invalid device credential", 401));
}
req.deviceAuth = {
mode: "device_credential",
device_id: device.id
};
return next();
})
.catch((error) => next(error));
return;
}
if (!allowLegacyToken) {
return next(new ApiError("UNAUTHORIZED", "Device bearer token fallback is disabled", 401));
}
if (!token) {
return next(new ApiError("UNAUTHORIZED", "Missing device bearer token", 401));
return next(new ApiError("UNAUTHORIZED", "Missing device credential", 401));
}
if (token !== env.DEVICE_TOKEN) {
return next(new ApiError("UNAUTHORIZED", "Invalid device token", 401));
}
req.deviceAuth = {
mode: "legacy_token"
};
return next();
}
export function getDeviceScopeError(req: RequestWithDeviceAuth, deviceId: string): ApiError | null {
if (req.deviceAuth?.mode === "device_credential" && req.deviceAuth.device_id !== deviceId) {
return new ApiError("FORBIDDEN", "device credential cannot access another device", 403);
}
return null;
}

View File

@ -1,5 +1,6 @@
import { NextFunction, Request, Response } from "express";
import { ApiError, errorEnvelope } from "../errors";
import { logger } from "../services/logger";
export interface EnvelopeSuccess<T> {
data: T;
@ -17,6 +18,13 @@ export function successResponse<T>(req: Request, data: T): EnvelopeSuccess<T> {
export function handleErrors(err: Error, req: Request, res: Response, _next: NextFunction) {
if (err instanceof ApiError) {
logger.warn("api_error", {
request_id: req.requestId,
trace_id: req.traceId,
code: err.code,
status_code: err.statusCode,
message: err.message
});
res.status(err.statusCode).json(
errorEnvelope(
{
@ -28,6 +36,11 @@ export function handleErrors(err: Error, req: Request, res: Response, _next: Nex
return;
}
logger.error("unhandled_error", {
request_id: req.requestId,
trace_id: req.traceId,
error: err
});
res.status(500).json({
code: "INTERNAL_ERROR",
message: err.message || "Unexpected server error",

View File

@ -0,0 +1,66 @@
import { NextFunction, Request, Response } from "express";
import { env } from "../../config/env";
import { ApiError } from "../errors";
type RateLimitOptions = {
name: string;
windowMs: number;
max: number;
key?: (req: Request) => string;
};
type Bucket = {
count: number;
resetAt: number;
};
const buckets = new Map<string, Bucket>();
function clientIp(req: Request) {
const forwarded = String(req.headers["x-forwarded-for"] || "");
return forwarded.split(",")[0]?.trim() || req.ip || req.socket.remoteAddress || "unknown";
}
function defaultKey(req: Request) {
return clientIp(req);
}
setInterval(() => {
const now = Date.now();
for (const [key, bucket] of buckets.entries()) {
if (bucket.resetAt <= now) {
buckets.delete(key);
}
}
}, 60000).unref?.();
export function rateLimit(options: RateLimitOptions) {
const enabled = String(env.RATE_LIMIT_ENABLED).toLowerCase() !== "false";
const windowMs = Math.max(options.windowMs || 60000, 1000);
const max = Math.max(options.max || 60, 1);
return (req: Request, res: Response, next: NextFunction) => {
if (!enabled) {
return next();
}
const now = Date.now();
const key = `${options.name}:${(options.key || defaultKey)(req)}`;
const existing = buckets.get(key);
const bucket = existing && existing.resetAt > now ? existing : { count: 0, resetAt: now + windowMs };
bucket.count += 1;
buckets.set(key, bucket);
const remaining = Math.max(max - bucket.count, 0);
res.setHeader("RateLimit-Limit", String(max));
res.setHeader("RateLimit-Remaining", String(remaining));
res.setHeader("RateLimit-Reset", String(Math.ceil(bucket.resetAt / 1000)));
if (bucket.count > max) {
res.setHeader("Retry-After", String(Math.ceil((bucket.resetAt - now) / 1000)));
return next(new ApiError("RATE_LIMITED", "Too many requests, please retry later", 429));
}
return next();
};
}

View File

@ -0,0 +1,34 @@
import { NextFunction, Request, Response } from "express";
import { logger } from "../services/logger";
function classifyLevel(statusCode: number) {
if (statusCode >= 500) {
return "error" as const;
}
if (statusCode >= 400) {
return "warn" as const;
}
return "info" as const;
}
export function requestLogging(req: Request, res: Response, next: NextFunction) {
const startedAt = process.hrtime.bigint();
res.on("finish", () => {
const durationMs = Number(process.hrtime.bigint() - startedAt) / 1_000_000;
const level = classifyLevel(res.statusCode);
logger[level]("http_request", {
request_id: req.requestId,
trace_id: req.traceId,
method: req.method,
path: req.originalUrl || req.url,
status_code: res.statusCode,
duration_ms: Number(durationMs.toFixed(2)),
content_length: res.getHeader("content-length") || null,
user_agent: req.header("user-agent") || null,
ip: req.ip
});
});
next();
}

View File

@ -0,0 +1,73 @@
import { env } from "../../config/env";
import { expireDueDynamicQrTransactions } from "./dynamicQrExpiry";
type SchedulerRunResult = Awaited<ReturnType<typeof expireDueDynamicQrTransactions>>;
type SchedulerStatus = {
enabled: boolean;
running: boolean;
interval_ms: number;
limit: number;
last_started_at: string | null;
last_finished_at: string | null;
last_result: SchedulerRunResult | null;
last_error: string | null;
};
const enabled = String(env.DYNAMIC_QR_EXPIRY_SCHEDULER_ENABLED).toLowerCase() !== "false";
const intervalMs = Math.max(env.DYNAMIC_QR_EXPIRY_SWEEP_INTERVAL_MS || 60000, 5000);
const limit = Math.min(Math.max(env.DYNAMIC_QR_EXPIRY_SWEEP_LIMIT || 100, 1), 500);
let timer: NodeJS.Timeout | null = null;
let running = false;
let lastStartedAt: string | null = null;
let lastFinishedAt: string | null = null;
let lastResult: SchedulerRunResult | null = null;
let lastError: string | null = null;
async function runSweep() {
if (running) {
return;
}
running = true;
lastStartedAt = new Date().toISOString();
lastError = null;
try {
lastResult = await expireDueDynamicQrTransactions({
limit,
source: "system",
request_id: `dynamic_qr_expiry_scheduler_${lastStartedAt}`
});
} catch (error) {
lastError = error instanceof Error ? error.message : "UNKNOWN_ERROR";
} finally {
running = false;
lastFinishedAt = new Date().toISOString();
}
}
export function startDynamicQrExpiryScheduler() {
if (!enabled || timer) {
return;
}
timer = setInterval(() => {
void runSweep();
}, intervalMs);
timer.unref?.();
}
export function getDynamicQrExpirySchedulerStatus(): SchedulerStatus {
return {
enabled,
running,
interval_ms: intervalMs,
limit,
last_started_at: lastStartedAt,
last_finished_at: lastFinishedAt,
last_result: lastResult,
last_error: lastError
};
}

View File

@ -0,0 +1,277 @@
import { env } from "../../config/env";
import fs from "node:fs/promises";
import path from "node:path";
import { ApiError } from "../errors";
import {
claimNextPendingExportJob,
completeExportJob,
createExportJob,
failExportJob,
markExpiredExportJobs,
resetStaleRunningExportJobs,
type ExportJobEntity
} from "../store/exportJobStore";
import { listSettlementAdjustments, settlementAdjustmentReportToCsv } from "../store/settlementStore";
import { logger } from "./logger";
export type SettlementAdjustmentExportRequest = {
merchant_id?: string;
adjustment_type?: string;
approval_status?: string;
from?: string;
to?: string;
limit?: number;
};
type WorkerStatus = {
enabled: boolean;
running: boolean;
interval_ms: number;
batch_size: number;
max_settlement_adjustment_rows: number;
storage_dir: string;
retention_days: number;
last_started_at: string | null;
last_finished_at: string | null;
last_error: string | null;
last_processed_job_id: string | null;
last_processed_count: number;
stale_reset_count: number;
expired_cleanup_count: number;
};
const enabled = String(env.EXPORT_WORKER_ENABLED).toLowerCase() !== "false";
const intervalMs = Math.max(env.EXPORT_WORKER_INTERVAL_MS || 2000, 500);
const batchSize = Math.min(Math.max(env.EXPORT_WORKER_BATCH_SIZE || 2, 1), 10);
const staleRunningMs = Math.max(env.EXPORT_JOB_STALE_RUNNING_MS || 900000, 60000);
const maxSettlementAdjustmentRows = Math.min(Math.max(env.EXPORT_SETTLEMENT_ADJUSTMENT_MAX_ROWS || 5000, 1), 10000);
const exportStorageDir = path.resolve(process.cwd(), env.EXPORT_STORAGE_DIR || "./storage/exports");
const retentionDays = Math.max(env.EXPORT_RETENTION_DAYS || 7, 1);
let timer: NodeJS.Timeout | null = null;
let running = false;
let lastStartedAt: string | null = null;
let lastFinishedAt: string | null = null;
let lastError: string | null = null;
let lastProcessedJobId: string | null = null;
let lastProcessedCount = 0;
let staleResetCount = 0;
let expiredCleanupCount = 0;
let staleResetDone = false;
function exportExpiresAt() {
return new Date(Date.now() + retentionDays * 24 * 60 * 60 * 1000).toISOString();
}
function safeFilename(value: string) {
return value.replace(/[^a-zA-Z0-9._-]/g, "-");
}
async function writeExportResult(job: ExportJobEntity, filename: string, body: string) {
await fs.mkdir(exportStorageDir, { recursive: true });
const safeName = safeFilename(filename);
const filePath = path.join(exportStorageDir, `${job.id}-${safeName}`);
await fs.writeFile(filePath, body, "utf8");
return {
path: filePath,
size: Buffer.byteLength(body, "utf8")
};
}
function parseSettlementAdjustmentType(value?: string): "credit" | "debit" | undefined {
if (!value) {
return undefined;
}
return value === "credit" || value === "debit" ? value : undefined;
}
function parseSettlementAdjustmentApprovalStatus(value?: string): "pending" | "approved" | "rejected" | undefined {
if (!value) {
return undefined;
}
return value === "pending" || value === "approved" || value === "rejected" ? value : undefined;
}
function isIsoDate(value: string) {
const date = new Date(value);
return !Number.isNaN(date.getTime());
}
function normalizeSettlementAdjustmentExportRequest(payload: SettlementAdjustmentExportRequest) {
const merchantId = payload.merchant_id?.trim();
const adjustmentType = parseSettlementAdjustmentType(payload.adjustment_type);
const approvalStatus = parseSettlementAdjustmentApprovalStatus(payload.approval_status);
const from = payload.from?.trim();
const to = payload.to?.trim();
const limit = payload.limit ? Number(payload.limit) : 500;
if (payload.adjustment_type && !adjustmentType) {
throw new ApiError("BAD_REQUEST", "adjustment_type must be credit|debit", 400);
}
if (payload.approval_status && !approvalStatus) {
throw new ApiError("BAD_REQUEST", "approval_status must be pending|approved|rejected", 400);
}
if (from && !isIsoDate(from)) {
throw new ApiError("BAD_REQUEST", "from must be valid ISO datetime", 400);
}
if (to && !isIsoDate(to)) {
throw new ApiError("BAD_REQUEST", "to must be valid ISO datetime", 400);
}
if (!Number.isFinite(limit) || limit <= 0) {
throw new ApiError("BAD_REQUEST", "limit must be a positive number", 400);
}
return {
merchant_id: merchantId || undefined,
adjustment_type: adjustmentType,
approval_status: approvalStatus,
from: from || undefined,
to: to || undefined,
limit: Math.min(limit, maxSettlementAdjustmentRows),
max_limit: maxSettlementAdjustmentRows
};
}
export async function createSettlementAdjustmentExportJob(payload: {
requested_by?: string;
request: SettlementAdjustmentExportRequest;
}) {
const requestJson = normalizeSettlementAdjustmentExportRequest(payload.request);
return createExportJob({
job_type: "settlement_adjustments_csv",
requested_by: payload.requested_by || "admin",
request_json: requestJson
});
}
async function processSettlementAdjustmentExport(job: ExportJobEntity) {
const report = await listSettlementAdjustments({
...(job.request_json || {}),
max_limit: maxSettlementAdjustmentRows
});
const csv = settlementAdjustmentReportToCsv(report);
const filename = `settlement-adjustment-report-${job.id}.csv`;
const stored = await writeExportResult(job, filename, csv);
return completeExportJob(job.id, {
result_content_type: "text/csv; charset=utf-8",
result_filename: filename,
result_storage_path: stored.path,
result_size_bytes: stored.size,
expires_at: exportExpiresAt()
});
}
async function processExportJob(job: ExportJobEntity) {
if (job.job_type === "settlement_adjustments_csv") {
return processSettlementAdjustmentExport(job);
}
throw new Error(`unsupported export job type: ${job.job_type}`);
}
async function runWorkerOnce() {
if (running) {
return;
}
running = true;
lastStartedAt = new Date().toISOString();
lastError = null;
lastProcessedCount = 0;
try {
if (!staleResetDone) {
await resetStaleRunningJobs();
await cleanupExpiredExports();
staleResetDone = true;
}
for (let i = 0; i < batchSize; i += 1) {
const job = await claimNextPendingExportJob();
if (!job) {
break;
}
try {
await processExportJob(job);
lastProcessedJobId = job.id;
lastProcessedCount += 1;
logger.info("export_job_completed", { job_id: job.id, job_type: job.job_type });
} catch (error) {
const message = error instanceof Error ? error.message : "unknown";
await failExportJob(job.id, message);
lastError = message;
logger.error("export_job_failed", { job_id: job.id, job_type: job.job_type, error });
}
}
} catch (error) {
lastError = error instanceof Error ? error.message : "unknown";
logger.error("export_worker_run_failed", { error });
} finally {
running = false;
lastFinishedAt = new Date().toISOString();
}
}
async function resetStaleRunningJobs() {
const cutoffIso = new Date(Date.now() - staleRunningMs).toISOString();
const resetCount = await resetStaleRunningExportJobs(cutoffIso);
staleResetCount += resetCount;
if (resetCount > 0) {
logger.warn("export_jobs_reset_stale_running", { reset_count: resetCount, cutoff_at: cutoffIso });
}
}
async function cleanupExpiredExports() {
const expired = await markExpiredExportJobs(new Date().toISOString());
for (const job of expired) {
if (!job.result_storage_path) {
continue;
}
try {
await fs.unlink(job.result_storage_path);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== "ENOENT") {
logger.warn("export_result_file_delete_failed", {
job_id: job.id,
path: job.result_storage_path,
error
});
}
}
}
expiredCleanupCount += expired.length;
if (expired.length > 0) {
logger.info("export_results_expired", { count: expired.length });
}
}
export function startExportJobWorker() {
if (!enabled || timer) {
return;
}
timer = setInterval(() => {
void runWorkerOnce();
}, intervalMs);
timer.unref?.();
}
export function getExportJobWorkerStatus(): WorkerStatus {
return {
enabled,
running,
interval_ms: intervalMs,
batch_size: batchSize,
max_settlement_adjustment_rows: maxSettlementAdjustmentRows,
storage_dir: exportStorageDir,
retention_days: retentionDays,
last_started_at: lastStartedAt,
last_finished_at: lastFinishedAt,
last_error: lastError,
last_processed_job_id: lastProcessedJobId,
last_processed_count: lastProcessedCount,
stale_reset_count: staleResetCount,
expired_cleanup_count: expiredCleanupCount
};
}

View File

@ -0,0 +1,42 @@
import { getPool } from "../db/pool";
import { getMqttPublisherStatus } from "./mqttPublisher";
import { getMqttSubscriberStatus } from "./mqttSubscriber";
export async function getDatabaseHealth() {
const startedAt = process.hrtime.bigint();
try {
const result = await getPool().query("SELECT NOW() AS now");
const durationMs = Number(process.hrtime.bigint() - startedAt) / 1_000_000;
return {
status: "ok" as const,
latency_ms: Number(durationMs.toFixed(2)),
server_time: result.rows[0]?.now || null
};
} catch (error) {
const durationMs = Number(process.hrtime.bigint() - startedAt) / 1_000_000;
return {
status: "error" as const,
latency_ms: Number(durationMs.toFixed(2)),
error: error instanceof Error ? error.message : "unknown"
};
}
}
export async function getServiceHealth() {
const database = await getDatabaseHealth();
const mqttPublisher = getMqttPublisherStatus();
const mqttSubscriber = getMqttSubscriberStatus();
const brokerRequired = mqttPublisher.mode === "broker";
const mqttOk = !brokerRequired || mqttPublisher.connected || mqttPublisher.publish_attempt_count === 0;
return {
status: database.status === "ok" && mqttOk ? "healthy" : "degraded",
checks: {
database,
mqtt: {
publisher: mqttPublisher,
subscriber: mqttSubscriber
}
}
};
}

View File

@ -0,0 +1,76 @@
import { env } from "../../config/env";
type LogLevel = "debug" | "info" | "warn" | "error";
const levelRank: Record<LogLevel, number> = {
debug: 10,
info: 20,
warn: 30,
error: 40
};
function normalizeLevel(value: string): LogLevel {
if (value === "debug" || value === "info" || value === "warn" || value === "error") {
return value;
}
return "info";
}
function shouldLog(level: LogLevel) {
return levelRank[level] >= levelRank[normalizeLevel(env.LOG_LEVEL)];
}
function serializeError(error: unknown) {
if (!(error instanceof Error)) {
return error;
}
return {
name: error.name,
message: error.message,
stack: env.LOG_FORMAT === "json" ? error.stack : undefined
};
}
export function log(level: LogLevel, event: string, fields?: Record<string, unknown>) {
if (!shouldLog(level)) {
return;
}
const payload = {
timestamp: new Date().toISOString(),
level,
event,
...(fields || {})
};
if (env.LOG_FORMAT === "json") {
const normalized = Object.fromEntries(
Object.entries(payload).map(([key, value]) => [key, key === "error" ? serializeError(value) : value])
);
const line = JSON.stringify(normalized);
if (level === "error") {
console.error(line);
} else if (level === "warn") {
console.warn(line);
} else {
console.log(line);
}
return;
}
const suffix = fields ? ` ${JSON.stringify(fields, (_key, value) => (value instanceof Error ? serializeError(value) : value))}` : "";
if (level === "error") {
console.error(`[${level}] ${event}${suffix}`);
} else if (level === "warn") {
console.warn(`[${level}] ${event}${suffix}`);
} else {
console.log(`[${level}] ${event}${suffix}`);
}
}
export const logger = {
debug: (event: string, fields?: Record<string, unknown>) => log("debug", event, fields),
info: (event: string, fields?: Record<string, unknown>) => log("info", event, fields),
warn: (event: string, fields?: Record<string, unknown>) => log("warn", event, fields),
error: (event: string, fields?: Record<string, unknown>) => log("error", event, fields)
};

View File

@ -1,4 +1,5 @@
import { env } from "../../config/env";
import mqtt, { type IClientOptions, type MqttClient } from "mqtt";
type PaymentSuccessPayload = {
message_type: "payment_success";
@ -47,11 +48,102 @@ const forcedFailDevices = new Set(
.map((item) => item.trim())
.filter(Boolean)
);
const publishMode = String(env.MQTT_PUBLISH_MODE || "simulator").toLowerCase();
let brokerClientPromise: Promise<MqttClient> | null = null;
let brokerClientRef: MqttClient | null = null;
let lastConnectedAt: string | null = null;
let lastDisconnectedAt: string | null = null;
let lastError: { message: string; at: string } | null = null;
let publishAttemptCount = 0;
let publishSuccessCount = 0;
let publishFailureCount = 0;
function shouldForceFail(deviceId: string): boolean {
return forcedFailAll || forcedFailDevices.has(deviceId);
}
function getBrokerClient(): Promise<MqttClient> {
if (brokerClientPromise) {
return brokerClientPromise;
}
brokerClientPromise = new Promise((resolve, reject) => {
if (!env.MQTT_BROKER_URL) {
reject(new Error("MQTT_BROKER_URL_MISSING"));
return;
}
const options: IClientOptions = {
clientId: env.MQTT_CLIENT_ID,
username: env.MQTT_USERNAME || undefined,
password: env.MQTT_PASSWORD || undefined,
connectTimeout: env.MQTT_CONNECT_TIMEOUT_MS,
reconnectPeriod: 5000,
clean: true
};
const client = mqtt.connect(env.MQTT_BROKER_URL, options);
const timeout = setTimeout(() => {
client.end(true);
brokerClientPromise = null;
reject(new Error("MQTT_BROKER_CONNECT_TIMEOUT"));
}, env.MQTT_CONNECT_TIMEOUT_MS + 1000);
const failBeforeConnect = (error: Error) => {
clearTimeout(timeout);
brokerClientPromise = null;
lastError = { message: error.message, at: new Date().toISOString() };
client.end(true);
reject(error);
};
client.once("connect", () => {
clearTimeout(timeout);
client.removeListener("error", failBeforeConnect);
brokerClientRef = client;
lastConnectedAt = new Date().toISOString();
resolve(client);
});
client.once("error", failBeforeConnect);
client.on("error", (error) => {
lastError = { message: error.message, at: new Date().toISOString() };
});
client.on("close", () => {
if (!client.connected) {
brokerClientPromise = null;
brokerClientRef = null;
lastDisconnectedAt = new Date().toISOString();
}
});
});
return brokerClientPromise;
}
async function publishToBroker<TPayload>(
topic: string,
payload: TPayload
): Promise<void> {
const client = await getBrokerClient();
await new Promise<void>((resolve, reject) => {
client.publish(
topic,
JSON.stringify(payload),
{
qos: 1,
retain: false
},
(error?: Error) => {
if (error) {
reject(error);
return;
}
resolve();
}
);
});
}
export function buildPaymentSuccessPayload(
input: {
transaction_id: string;
@ -101,8 +193,10 @@ async function publishMqttPayload<TPayload>(
payload: TPayload
): Promise<MqttPublishResult<TPayload>> {
const publishedAt = new Date().toISOString();
publishAttemptCount += 1;
if (shouldForceFail(deviceId)) {
publishFailureCount += 1;
return {
ok: false,
topic,
@ -114,6 +208,25 @@ async function publishMqttPayload<TPayload>(
};
}
if (publishMode === "broker") {
try {
await publishToBroker(topic, payload);
} catch (error) {
const message = error instanceof Error ? error.message : "unknown";
publishFailureCount += 1;
return {
ok: false,
topic,
qos: 1,
retained: false,
publishedAt,
reason: `MQTT_BROKER_PUBLISH_FAILED:${message}`,
payload
};
}
}
publishSuccessCount += 1;
return {
ok: true,
topic,
@ -135,3 +248,26 @@ export async function publishDynamicQrResponse(deviceId: string, payload: Dynami
export async function publishConfigPush(deviceId: string, payload: ConfigPushPayload) {
return publishMqttPayload(deviceId, makeConfigPushTopic(deviceId), payload);
}
export function getMqttPublisherStatus() {
const mode = publishMode === "broker" ? "broker" : "simulator";
const brokerUrl = env.MQTT_BROKER_URL
? env.MQTT_BROKER_URL.replace(/(mqtts?:\/\/)([^:@/]+):([^@/]+)@/i, "$1***:***@")
: "";
return {
mode,
broker_url: mode === "broker" ? brokerUrl : null,
client_id: mode === "broker" ? env.MQTT_CLIENT_ID : null,
username: mode === "broker" ? env.MQTT_USERNAME || null : null,
connected: mode === "broker" ? Boolean(brokerClientRef?.connected) : true,
last_connected_at: lastConnectedAt,
last_disconnected_at: lastDisconnectedAt,
last_error: lastError,
publish_attempt_count: publishAttemptCount,
publish_success_count: publishSuccessCount,
publish_failure_count: publishFailureCount,
forced_fail_all: forcedFailAll,
forced_fail_device_count: forcedFailDevices.size
};
}

View File

@ -0,0 +1,140 @@
import mqtt, { type IClientOptions, type MqttClient } from "mqtt";
import { env } from "../../config/env";
import { createMqttMessage } from "../store/mqttMessageStore";
type SubscriberStatus = {
enabled: boolean;
connected: boolean;
topics: string[];
client_id: string | null;
last_connected_at: string | null;
last_disconnected_at: string | null;
last_message_at: string | null;
last_error: { message: string; at: string } | null;
received_count: number;
recorded_count: number;
failed_count: number;
};
const status: SubscriberStatus = {
enabled: String(env.MQTT_SUBSCRIBE_ENABLED).toLowerCase() === "true",
connected: false,
topics: env.MQTT_SUBSCRIBE_TOPICS.split(",").map((topic) => topic.trim()).filter(Boolean),
client_id: null,
last_connected_at: null,
last_disconnected_at: null,
last_message_at: null,
last_error: null,
received_count: 0,
recorded_count: 0,
failed_count: 0
};
let clientRef: MqttClient | null = null;
let started = false;
function parseTopic(topic: string) {
const match = topic.match(/^devices\/([^/]+)\/uplink\/(.+)$/);
if (!match) {
return null;
}
return {
device_id: match[1],
message_type: match[2].replace(/\//g, "_")
};
}
function parsePayload(raw: Buffer): Record<string, unknown> {
const text = raw.toString("utf8");
try {
const parsed = JSON.parse(text) as unknown;
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : { raw_text: text };
} catch (_error) {
return { raw_text: text };
}
}
export function startMqttSubscriber() {
if (started || !status.enabled) {
return;
}
started = true;
if (!env.MQTT_BROKER_URL) {
status.last_error = { message: "MQTT_BROKER_URL_MISSING", at: new Date().toISOString() };
return;
}
const clientId = `${env.MQTT_CLIENT_ID}-subscriber`;
status.client_id = clientId;
const options: IClientOptions = {
clientId,
username: env.MQTT_USERNAME || undefined,
password: env.MQTT_PASSWORD || undefined,
connectTimeout: env.MQTT_CONNECT_TIMEOUT_MS,
reconnectPeriod: 5000,
clean: true
};
const client = mqtt.connect(env.MQTT_BROKER_URL, options);
clientRef = client;
client.on("connect", () => {
status.connected = true;
status.last_connected_at = new Date().toISOString();
for (const topic of status.topics) {
client.subscribe(topic, { qos: 1 }, (error) => {
if (error) {
status.last_error = { message: error.message, at: new Date().toISOString() };
}
});
}
});
client.on("message", (topic, raw) => {
status.received_count += 1;
status.last_message_at = new Date().toISOString();
const parsedTopic = parseTopic(topic);
if (!parsedTopic) {
status.failed_count += 1;
status.last_error = { message: `UNSUPPORTED_TOPIC:${topic}`, at: new Date().toISOString() };
return;
}
const payload = parsePayload(raw);
createMqttMessage({
direction: "uplink",
device_id: parsedTopic.device_id,
topic,
message_type: String(payload.message_type || parsedTopic.message_type),
correlation_id: typeof payload.correlation_id === "string" ? payload.correlation_id : undefined,
payload_json: payload,
publish_status: "recorded"
})
.then(() => {
status.recorded_count += 1;
})
.catch((error: unknown) => {
status.failed_count += 1;
const message = error instanceof Error ? error.message : "unknown";
status.last_error = { message, at: new Date().toISOString() };
});
});
client.on("error", (error) => {
status.last_error = { message: error.message, at: new Date().toISOString() };
});
client.on("close", () => {
status.connected = false;
status.last_disconnected_at = new Date().toISOString();
});
}
export function getMqttSubscriberStatus() {
return {
...status,
connected: status.enabled ? Boolean(clientRef?.connected) : false
};
}

View File

@ -0,0 +1,124 @@
import { createHmac, timingSafeEqual } from "node:crypto";
export type SessionPayload = {
typ: "admin";
sub: string;
name: string;
email: string;
role_id: string;
role_name: string;
permissions: unknown;
iat: number;
exp: number;
};
export type MerchantSessionPayload = {
typ: "merchant";
sub: string;
merchant_id: string;
name: string;
email: string;
role_name: string;
iat: number;
exp: number;
};
function base64UrlEncode(value: string | Buffer) {
return Buffer.from(value).toString("base64url");
}
function base64UrlDecode(value: string) {
return Buffer.from(value, "base64url").toString("utf8");
}
function sign(unsigned: string, secret: string) {
return createHmac("sha256", secret).update(unsigned).digest("base64url");
}
export function createSessionToken(payload: Omit<SessionPayload, "iat" | "exp">, secret: string, ttlSeconds: number) {
const now = Math.floor(Date.now() / 1000);
const body: SessionPayload = {
...payload,
iat: now,
exp: now + ttlSeconds
};
const encoded = base64UrlEncode(JSON.stringify(body));
const signature = sign(encoded, secret);
return `${encoded}.${signature}`;
}
export function verifySessionToken(token: string, secret: string): SessionPayload | null {
const [encoded, signature] = token.split(".");
if (!encoded || !signature) {
return null;
}
const expected = sign(encoded, secret);
const expectedBuffer = Buffer.from(expected);
const actualBuffer = Buffer.from(signature);
if (expectedBuffer.length !== actualBuffer.length || !timingSafeEqual(expectedBuffer, actualBuffer)) {
return null;
}
let payload: SessionPayload;
try {
payload = JSON.parse(base64UrlDecode(encoded)) as SessionPayload;
} catch (_error) {
return null;
}
if (payload.typ !== "admin" || !payload.sub || !payload.role_id || !payload.exp) {
return null;
}
if (payload.exp <= Math.floor(Date.now() / 1000)) {
return null;
}
return payload;
}
export function createMerchantSessionToken(
payload: Omit<MerchantSessionPayload, "iat" | "exp">,
secret: string,
ttlSeconds: number
) {
const now = Math.floor(Date.now() / 1000);
const body: MerchantSessionPayload = {
...payload,
iat: now,
exp: now + ttlSeconds
};
const encoded = base64UrlEncode(JSON.stringify(body));
const signature = sign(encoded, secret);
return `${encoded}.${signature}`;
}
export function verifyMerchantSessionToken(token: string, secret: string): MerchantSessionPayload | null {
const [encoded, signature] = token.split(".");
if (!encoded || !signature) {
return null;
}
const expected = sign(encoded, secret);
const expectedBuffer = Buffer.from(expected);
const actualBuffer = Buffer.from(signature);
if (expectedBuffer.length !== actualBuffer.length || !timingSafeEqual(expectedBuffer, actualBuffer)) {
return null;
}
let payload: MerchantSessionPayload;
try {
payload = JSON.parse(base64UrlDecode(encoded)) as MerchantSessionPayload;
} catch (_error) {
return null;
}
if (payload.typ !== "merchant" || !payload.sub || !payload.merchant_id || !payload.exp) {
return null;
}
if (payload.exp <= Math.floor(Date.now() / 1000)) {
return null;
}
return payload;
}

View File

@ -3,7 +3,7 @@ import { getPool } from "../db/pool";
export interface AuditLogEntity {
id: string;
actor_type: "admin" | "device" | "webhook" | "system";
actor_type: "admin" | "merchant" | "device" | "webhook" | "system";
actor_id?: string;
action: string;
entity_type: string;
@ -88,6 +88,7 @@ export async function listAuditLogs(filter?: {
entity_type?: string;
entity_id?: string;
action?: string;
action_contains?: string;
from?: string;
to?: string;
limit?: number;
@ -111,6 +112,11 @@ export async function listAuditLogs(filter?: {
params.push(filter.action);
}
if (filter?.action_contains) {
clauses.push(`action ILIKE $${i++}`);
params.push(`%${filter.action_contains}%`);
}
if (filter?.from) {
clauses.push(`created_at >= $${i++}`);
params.push(filter.from);

View File

@ -1,6 +1,8 @@
import { randomUUID } from "node:crypto";
import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
import { getPool } from "../db/pool";
export type DeviceCredentialStatus = "not_issued" | "active" | "rotation_required" | "revoked";
export interface DeviceEntity {
id: string;
device_code: string;
@ -10,6 +12,12 @@ export interface DeviceEntity {
communication_mode?: "static" | "mqtt" | "api";
capability_profile_json?: Record<string, unknown>;
auth_method?: string;
mqtt_username?: string;
credential_secret_fingerprint?: string;
credential_status: DeviceCredentialStatus;
credential_issued_at?: string;
credential_rotated_at?: string;
credential_revoked_at?: string;
status: "active" | "inactive";
last_seen_at?: string;
firmware_version?: string;
@ -35,6 +43,12 @@ function mapDevice(row: any): DeviceEntity {
communication_mode: row.communication_mode,
capability_profile_json: row.capability_profile_json || {},
auth_method: row.auth_method || undefined,
mqtt_username: row.mqtt_username || undefined,
credential_secret_fingerprint: row.credential_secret_fingerprint || undefined,
credential_status: row.credential_status || "not_issued",
credential_issued_at: row.credential_issued_at || undefined,
credential_rotated_at: row.credential_rotated_at || undefined,
credential_revoked_at: row.credential_revoked_at || undefined,
status: row.status,
last_seen_at: row.last_seen_at || undefined,
firmware_version: row.firmware_version || undefined,
@ -51,6 +65,12 @@ export async function createDevice(payload: {
communication_mode?: DeviceEntity["communication_mode"];
capability_profile_json?: Record<string, unknown>;
auth_method?: string;
mqtt_username?: string;
credential_secret_fingerprint?: string;
credential_status?: DeviceCredentialStatus;
credential_issued_at?: string;
credential_rotated_at?: string;
credential_revoked_at?: string;
status?: DeviceEntity["status"];
firmware_version?: string;
last_seen_at?: string;
@ -68,12 +88,18 @@ export async function createDevice(payload: {
communication_mode,
capability_profile_json,
auth_method,
mqtt_username,
credential_secret_fingerprint,
credential_status,
credential_issued_at,
credential_rotated_at,
credential_revoked_at,
status,
last_seen_at,
firmware_version,
created_at,
updated_at
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19)
RETURNING *`,
[
id,
@ -84,6 +110,12 @@ export async function createDevice(payload: {
payload.communication_mode || "static",
payload.capability_profile_json || {},
payload.auth_method || "token",
payload.mqtt_username || null,
payload.credential_secret_fingerprint || null,
payload.credential_status || "not_issued",
payload.credential_issued_at || null,
payload.credential_rotated_at || null,
payload.credential_revoked_at || null,
payload.status || "active",
payload.last_seen_at || null,
payload.firmware_version,
@ -122,10 +154,16 @@ export async function patchDevice(id: string, patch: Partial<DeviceEntity>): Pro
communication_mode = $6,
capability_profile_json = $7,
auth_method = $8,
status = $9,
firmware_version = $10,
last_seen_at = $11,
updated_at = $12
mqtt_username = $9,
credential_secret_fingerprint = $10,
credential_status = $11,
credential_issued_at = $12,
credential_rotated_at = $13,
credential_revoked_at = $14,
status = $15,
firmware_version = $16,
last_seen_at = $17,
updated_at = $18
WHERE id = $1
RETURNING *`,
[
@ -137,6 +175,12 @@ export async function patchDevice(id: string, patch: Partial<DeviceEntity>): Pro
merged.communication_mode || "static",
merged.capability_profile_json || {},
merged.auth_method,
merged.mqtt_username || null,
merged.credential_secret_fingerprint || null,
merged.credential_status || "not_issued",
merged.credential_issued_at || null,
merged.credential_rotated_at || null,
merged.credential_revoked_at || null,
merged.status,
merged.firmware_version,
merged.last_seen_at || null,
@ -147,6 +191,58 @@ export async function patchDevice(id: string, patch: Partial<DeviceEntity>): Pro
return mapDevice(rows[0]);
}
export function toDevicePayload(device: DeviceEntity) {
return { ...device };
function fingerprintSecret(secret: string) {
return createHash("sha256").update(secret).digest("hex");
}
function safeEqualHex(a: string, b: string) {
const left = Buffer.from(a, "hex");
const right = Buffer.from(b, "hex");
return left.length === right.length && timingSafeEqual(left, right);
}
function normalizeMqttUsername(device: DeviceEntity) {
return device.id;
}
export async function rotateDeviceMqttCredential(id: string): Promise<{
device: DeviceEntity;
username: string;
password: string;
}> {
const existing = await getDeviceById(id);
if (!existing) {
throw new Error("DEVICE_NOT_FOUND");
}
const password = randomBytes(24).toString("base64url");
const username = normalizeMqttUsername(existing);
const now = nowIso();
const updated = await patchDevice(id, {
communication_mode: "mqtt",
auth_method: "mqtt_username_password",
mqtt_username: username,
credential_secret_fingerprint: fingerprintSecret(password),
credential_status: "active",
credential_issued_at: existing.credential_issued_at || now,
credential_rotated_at: now,
credential_revoked_at: undefined
});
return { device: updated, username, password };
}
export async function verifyDeviceSecret(id: string, secret: string): Promise<DeviceEntity | null> {
const device = await getDeviceById(id);
if (!device || device.credential_status !== "active" || !device.credential_secret_fingerprint) {
return null;
}
const candidate = fingerprintSecret(secret);
return safeEqualHex(candidate, device.credential_secret_fingerprint) ? device : null;
}
export function toDevicePayload(device: DeviceEntity) {
const { credential_secret_fingerprint, ...payload } = device;
return { ...payload };
}

View File

@ -0,0 +1,235 @@
import { randomUUID } from "node:crypto";
import { getPool } from "../db/pool";
export type ExportJobEntity = {
id: string;
job_type: string;
status: "pending" | "running" | "completed" | "failed";
requested_by?: string;
request_json: Record<string, unknown>;
result_content_type?: string;
result_filename?: string;
result_body?: string;
result_storage_path?: string;
result_size_bytes?: number;
expires_at?: string;
error_message?: string;
created_at: string;
started_at?: string;
completed_at?: string;
};
function nowIso() {
return new Date().toISOString();
}
function mapExportJob(row: any): ExportJobEntity {
return {
id: row.id,
job_type: row.job_type,
status: row.status,
requested_by: row.requested_by || undefined,
request_json: row.request_json || {},
result_content_type: row.result_content_type || undefined,
result_filename: row.result_filename || undefined,
result_body: row.result_body || undefined,
result_storage_path: row.result_storage_path || undefined,
result_size_bytes: row.result_size_bytes === null || row.result_size_bytes === undefined ? undefined : Number(row.result_size_bytes),
expires_at: row.expires_at || undefined,
error_message: row.error_message || undefined,
created_at: row.created_at,
started_at: row.started_at || undefined,
completed_at: row.completed_at || undefined
};
}
export async function createExportJob(payload: {
job_type: string;
requested_by?: string;
request_json?: Record<string, unknown>;
}): Promise<ExportJobEntity> {
const { rows } = await getPool().query(
`INSERT INTO export_jobs (id, job_type, status, requested_by, request_json, created_at)
VALUES ($1,$2,'pending',$3,$4,$5)
RETURNING *`,
[randomUUID(), payload.job_type, payload.requested_by || null, payload.request_json || {}, nowIso()]
);
return mapExportJob(rows[0]);
}
export async function getExportJobById(id: string): Promise<ExportJobEntity | null> {
const { rows } = await getPool().query("SELECT * FROM export_jobs WHERE id = $1", [id]);
return rows[0] ? mapExportJob(rows[0]) : null;
}
export async function listExportJobs(filter?: {
job_type?: string;
status?: ExportJobEntity["status"];
limit?: number;
}): Promise<ExportJobEntity[]> {
const clauses: string[] = [];
const params: unknown[] = [];
let i = 1;
if (filter?.job_type) {
clauses.push(`job_type = $${i++}`);
params.push(filter.job_type);
}
if (filter?.status) {
clauses.push(`status = $${i++}`);
params.push(filter.status);
}
const limit = Math.min(Math.max(filter?.limit || 20, 1), 100);
params.push(limit);
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
const { rows } = await getPool().query(
`SELECT * FROM export_jobs ${where} ORDER BY created_at DESC LIMIT $${i}`,
params
);
return rows.map(mapExportJob);
}
export async function markExportJobRunning(id: string): Promise<ExportJobEntity> {
const { rows } = await getPool().query(
`UPDATE export_jobs
SET status = 'running',
started_at = COALESCE(started_at, $2)
WHERE id = $1
RETURNING *`,
[id, nowIso()]
);
return mapExportJob(rows[0]);
}
export async function claimNextPendingExportJob(): Promise<ExportJobEntity | null> {
const { rows } = await getPool().query(
`UPDATE export_jobs
SET status = 'running',
started_at = COALESCE(started_at, $1),
error_message = NULL
WHERE id = (
SELECT id
FROM export_jobs
WHERE status = 'pending'
ORDER BY created_at ASC
FOR UPDATE SKIP LOCKED
LIMIT 1
)
RETURNING *`,
[nowIso()]
);
return rows[0] ? mapExportJob(rows[0]) : null;
}
export async function resetStaleRunningExportJobs(cutoffIso: string): Promise<number> {
const { rowCount } = await getPool().query(
`UPDATE export_jobs
SET status = 'pending',
error_message = 'reset from stale running state'
WHERE status = 'running'
AND started_at IS NOT NULL
AND started_at < $1`,
[cutoffIso]
);
return rowCount || 0;
}
export async function countExportJobsByStatus(): Promise<Record<ExportJobEntity["status"], number>> {
const { rows } = await getPool().query("SELECT status, COUNT(*) AS count FROM export_jobs GROUP BY status");
const counts: Record<ExportJobEntity["status"], number> = {
pending: 0,
running: 0,
completed: 0,
failed: 0
};
for (const row of rows) {
if (row.status in counts) {
counts[row.status as ExportJobEntity["status"]] = Number(row.count || 0);
}
}
return counts;
}
export async function completeExportJob(
id: string,
payload: {
result_content_type: string;
result_filename: string;
result_body?: string | null;
result_storage_path?: string | null;
result_size_bytes?: number;
expires_at?: string;
}
): Promise<ExportJobEntity> {
const { rows } = await getPool().query(
`UPDATE export_jobs
SET status = 'completed',
result_content_type = $2,
result_filename = $3,
result_body = $4,
result_storage_path = $5,
result_size_bytes = $6,
expires_at = $7,
completed_at = $8
WHERE id = $1
RETURNING *`,
[
id,
payload.result_content_type,
payload.result_filename,
payload.result_body || null,
payload.result_storage_path || null,
payload.result_size_bytes || null,
payload.expires_at || null,
nowIso()
]
);
return mapExportJob(rows[0]);
}
export async function markExpiredExportJobs(cutoffIso: string): Promise<ExportJobEntity[]> {
const { rows } = await getPool().query(
`UPDATE export_jobs
SET result_body = NULL,
result_storage_path = NULL,
error_message = COALESCE(error_message, 'export result expired')
WHERE status = 'completed'
AND expires_at IS NOT NULL
AND expires_at < $1
AND (result_body IS NOT NULL OR result_storage_path IS NOT NULL)
RETURNING *`,
[cutoffIso]
);
return rows.map(mapExportJob);
}
export async function failExportJob(id: string, error: string): Promise<ExportJobEntity> {
const { rows } = await getPool().query(
`UPDATE export_jobs
SET status = 'failed',
error_message = $2,
completed_at = $3
WHERE id = $1
RETURNING *`,
[id, error, nowIso()]
);
return mapExportJob(rows[0]);
}
export function toExportJobPayload(job: ExportJobEntity) {
return {
id: job.id,
job_type: job.job_type,
status: job.status,
requested_by: job.requested_by,
request_json: job.request_json,
result_content_type: job.result_content_type,
result_filename: job.result_filename,
result_size_bytes: job.result_size_bytes,
expires_at: job.expires_at,
error_message: job.error_message,
created_at: job.created_at,
started_at: job.started_at,
completed_at: job.completed_at,
download_url: job.status === "completed" && (job.result_body || job.result_storage_path) ? `/admin/exports/${job.id}/download` : null
};
}

View File

@ -1,4 +1,5 @@
import { randomUUID } from "node:crypto";
import { env } from "../../config/env";
import { getPool } from "../db/pool";
import type { TransactionEntity } from "./transactionStore";
@ -34,7 +35,29 @@ function mapLedgerEntry(row: any): LedgerEntryEntity {
};
}
export async function createPaidLedgerPlaceholder(tx: TransactionEntity): Promise<LedgerEntryEntity> {
function roundMoney(value: number) {
return Math.round((value + Number.EPSILON) * 100) / 100;
}
function calculatePlatformFee(amount: number) {
const feeBps = Math.max(0, env.FINANCE_PLATFORM_FEE_BPS);
const feeAmount = roundMoney((amount * feeBps) / 10000);
return {
fee_bps: feeBps,
platform_fee_amount: feeAmount,
merchant_payable_amount: roundMoney(amount - feeAmount)
};
}
async function upsertLedgerEntry(payload: {
transaction_id: string;
merchant_id: string;
entry_type: LedgerEntryEntity["entry_type"];
amount: number;
currency: string;
direction: LedgerEntryEntity["direction"];
metadata_json: Record<string, unknown>;
}): Promise<LedgerEntryEntity> {
const { rows } = await getPool().query(
`INSERT INTO ledger_entries (
id,
@ -51,22 +74,19 @@ export async function createPaidLedgerPlaceholder(tx: TransactionEntity): Promis
ON CONFLICT (transaction_id, entry_type) DO UPDATE
SET amount = EXCLUDED.amount,
currency = EXCLUDED.currency,
direction = EXCLUDED.direction,
metadata_json = ledger_entries.metadata_json || EXCLUDED.metadata_json
RETURNING *`,
[
randomUUID(),
tx.id,
tx.merchant_id,
"gross_income",
tx.amount,
tx.currency,
"credit",
payload.transaction_id,
payload.merchant_id,
payload.entry_type,
payload.amount,
payload.currency,
payload.direction,
"posted",
{
placeholder: true,
source: "fase1_paid_transaction",
partner_reference: tx.partner_reference
},
payload.metadata_json,
nowIso()
]
);
@ -74,6 +94,61 @@ export async function createPaidLedgerPlaceholder(tx: TransactionEntity): Promis
return mapLedgerEntry(rows[0]);
}
export async function createPaidLedgerEntries(tx: TransactionEntity): Promise<LedgerEntryEntity[]> {
const fee = calculatePlatformFee(tx.amount);
const baseMetadata = {
source: "fase1_finance_light",
partner_reference: tx.partner_reference,
fee_bps: fee.fee_bps
};
const gross = await upsertLedgerEntry({
transaction_id: tx.id,
merchant_id: tx.merchant_id,
entry_type: "gross_income",
amount: tx.amount,
currency: tx.currency,
direction: "credit",
metadata_json: {
...baseMetadata,
note: "gross paid amount"
}
});
const platformFee = await upsertLedgerEntry({
transaction_id: tx.id,
merchant_id: tx.merchant_id,
entry_type: "platform_fee",
amount: fee.platform_fee_amount,
currency: tx.currency,
direction: "debit",
metadata_json: {
...baseMetadata,
note: "platform fee deducted from merchant payable"
}
});
const merchantPayable = await upsertLedgerEntry({
transaction_id: tx.id,
merchant_id: tx.merchant_id,
entry_type: "merchant_payable",
amount: fee.merchant_payable_amount,
currency: tx.currency,
direction: "credit",
metadata_json: {
...baseMetadata,
note: "net merchant payable after platform fee"
}
});
return [gross, platformFee, merchantPayable];
}
export async function createPaidLedgerPlaceholder(tx: TransactionEntity): Promise<LedgerEntryEntity> {
const [gross] = await createPaidLedgerEntries(tx);
return gross;
}
export async function listLedgerEntries(filter?: {
transaction_id?: string;
merchant_id?: string;

View File

@ -104,6 +104,14 @@ export async function getMerchantById(id: string): Promise<MerchantEntity | null
return mapRowToMerchant(rows[0]);
}
export async function getMerchantByCode(code: string): Promise<MerchantEntity | null> {
const { rows } = await getPool().query("SELECT * FROM merchants WHERE merchant_code = $1", [code]);
if (rows.length === 0) {
return null;
}
return mapRowToMerchant(rows[0]);
}
export async function listMerchants(): Promise<MerchantEntity[]> {
const { rows } = await getPool().query("SELECT * FROM merchants ORDER BY created_at DESC");
return rows.map(mapRowToMerchant);

View File

@ -0,0 +1,72 @@
import { scryptSync, timingSafeEqual } from "node:crypto";
import { getPool } from "../db/pool";
export type MerchantUserEntity = {
id: string;
merchant_id: string;
name: string;
email: string;
password_hash: string;
role_name: "owner" | "finance" | "ops" | "viewer";
status: "active" | "inactive";
created_at: string;
updated_at: string;
};
function mapMerchantUser(row: any): MerchantUserEntity {
return {
id: row.id,
merchant_id: row.merchant_id,
name: row.name,
email: row.email,
password_hash: row.password_hash,
role_name: row.role_name,
status: row.status,
created_at: row.created_at,
updated_at: row.updated_at
};
}
export async function getMerchantUserByEmail(email: string): Promise<MerchantUserEntity | null> {
const { rows } = await getPool().query(
"SELECT * FROM merchant_users WHERE LOWER(email) = LOWER($1) LIMIT 1",
[email]
);
return rows[0] ? mapMerchantUser(rows[0]) : null;
}
function verifyScryptPassword(stored: string, password: string) {
const parts = stored.split("$");
if (parts.length !== 4 || parts[0] !== "scrypt") {
return false;
}
const [, salt, keyLengthRaw, expectedHex] = parts;
const keyLength = Number(keyLengthRaw);
if (!salt || !Number.isFinite(keyLength) || keyLength <= 0 || !expectedHex) {
return false;
}
const expected = Buffer.from(expectedHex, "hex");
const actual = scryptSync(password, salt, keyLength);
return expected.length === actual.length && timingSafeEqual(expected, actual);
}
export function verifyMerchantPassword(stored: string, password: string) {
if (stored.startsWith("scrypt$")) {
return verifyScryptPassword(stored, password);
}
return stored === password;
}
export function toMerchantUserPayload(user: MerchantUserEntity) {
return {
id: user.id,
merchant_id: user.merchant_id,
name: user.name,
email: user.email,
role_name: user.role_name,
status: user.status,
created_at: user.created_at,
updated_at: user.updated_at
};
}

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import { getPool } from "../db/pool";
import { createPaidLedgerPlaceholder } from "./ledgerStore";
import { createPaidLedgerEntries } from "./ledgerStore";
export type TransactionStatus =
| "initiated"
@ -269,7 +269,7 @@ export async function updateTransactionStatus(
const updated = mapTransaction(rows[0]);
if (to === "paid") {
await createPaidLedgerPlaceholder(updated);
await createPaidLedgerEntries(updated);
}
return updated;

View File

@ -0,0 +1,107 @@
import { scryptSync, timingSafeEqual } from "node:crypto";
import { getPool } from "../db/pool";
export type RoleEntity = {
id: string;
name: string;
permissions_json: unknown;
created_at: string;
};
export type UserEntity = {
id: string;
name: string;
email: string;
password_hash: string;
role_id: string;
status: "active" | "inactive";
created_at: string;
role_name: string;
permissions_json: unknown;
};
function mapUser(row: any): UserEntity {
return {
id: row.id,
name: row.name,
email: row.email,
password_hash: row.password_hash,
role_id: row.role_id,
status: row.status,
created_at: row.created_at,
role_name: row.role_name,
permissions_json: row.permissions_json
};
}
export async function getUserByEmail(email: string): Promise<UserEntity | null> {
const { rows } = await getPool().query(
`SELECT users.*, roles.name AS role_name, roles.permissions_json
FROM users
JOIN roles ON roles.id = users.role_id
WHERE LOWER(users.email) = LOWER($1)
LIMIT 1`,
[email]
);
return rows[0] ? mapUser(rows[0]) : null;
}
function verifyScryptPassword(stored: string, password: string) {
const parts = stored.split("$");
if (parts.length !== 4 || parts[0] !== "scrypt") {
return false;
}
const [, salt, keyLengthRaw, expectedHex] = parts;
const keyLength = Number(keyLengthRaw);
if (!salt || !Number.isFinite(keyLength) || keyLength <= 0 || !expectedHex) {
return false;
}
const expected = Buffer.from(expectedHex, "hex");
const actual = scryptSync(password, salt, keyLength);
return expected.length === actual.length && timingSafeEqual(expected, actual);
}
export function verifyPassword(stored: string, password: string) {
if (stored.startsWith("scrypt$")) {
return verifyScryptPassword(stored, password);
}
return stored === password;
}
export function hasPermission(permissions: unknown, permission: string) {
if (!permission) {
return true;
}
if (Array.isArray(permissions)) {
return permissions.includes("*") || permissions.includes(permission);
}
if (!permissions || typeof permissions !== "object") {
return false;
}
const adminValue = (permissions as Record<string, unknown>).admin;
if (adminValue === "*" || adminValue === true) {
return true;
}
const value = (permissions as Record<string, unknown>)[permission];
if (value === true || value === "*") {
return true;
}
const [domain, action] = permission.split(":");
const domainValue = (permissions as Record<string, unknown>)[domain];
if (domainValue === "*" || domainValue === true) {
return true;
}
if (Array.isArray(domainValue)) {
return domainValue.includes("*") || domainValue.includes(action);
}
if (domainValue && typeof domainValue === "object") {
const scoped = domainValue as Record<string, unknown>;
return scoped[action] === true || scoped[action] === "*";
}
return false;
}