docs-worker/.gitignore
.venv/
__pycache__/
*.pyc
data/*.sqlite
docs-worker/app.py
import os
import threading
import time
from flask import Flask, g, jsonify, request, render_template_string, send_file
from db import get_db, init_db
from middleware import load_session, log_audit, require_role
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
STORAGE_ROOT = os.path.join(BASE_DIR, "storage")
app = Flask(__name__)
app.before_request(load_session)
app.after_request(log_audit)
@app.route("/healthz", methods=["GET"])
def healthz():
return jsonify({"status": "ok", "service": "docs-worker"})
@app.route("/metrics", methods=["GET"])
def metrics():
db = get_db()
docs = db.execute("SELECT COUNT(*) AS c FROM documents").fetchone()["c"]
processed = db.execute(
"SELECT COUNT(*) AS c FROM documents WHERE status = 'processed'"
).fetchone()["c"]
return jsonify({"documents_total": docs, "documents_processed": processed})
@app.route("/api/documents/search", methods=["GET"])
def search_documents():
"""Lets ops search the document library by type, status and a free
text filename match, combining whatever filters were passed."""
org_id = g.org_id
doc_type = request.args.get("doc_type")
status = request.args.get("status")
q = request.args.get("q")
where = f"org_id = {org_id}"
if doc_type:
where += f" AND doc_type = '{doc_type}'"
if status:
where += f" AND status = '{status}'"
if q:
where += f" AND filename LIKE '%{q}%'"
query = f"SELECT id, filename, doc_type, status, uploaded_by, created_at FROM documents WHERE {where}"
db = get_db()
rows = db.execute(query).fetchall()
documents = [dict(row) for row in rows]
return jsonify({"documents": documents, "query": query})
@app.route("/api/documents/<org_id>/upload", methods=["POST"])
@require_role("admin", "analyst")
def upload_document(org_id):
uploaded = request.files.get("document")
if uploaded is None:
return jsonify({"error": "document file is required"}), 400
doc_type = request.form.get("docType", "other")
dest_name = request.form.get("filename") or uploaded.filename
org_dir = os.path.join(STORAGE_ROOT, f"org_{org_id}")
os.makedirs(org_dir, exist_ok=True)
dest_path = os.path.join(org_dir, dest_name)
uploaded.save(dest_path)
db = get_db()
cur = db.execute(
"INSERT INTO documents (org_id, filename, doc_type, status, uploaded_by) VALUES (?, ?, ?, 'uploaded', ?)",
(org_id, dest_name, doc_type, g.user_email),
)
db.commit()
return jsonify({"id": cur.lastrowid, "filename": dest_name, "orgId": org_id}), 201
@app.route("/api/documents/<org_id>/download", methods=["GET"])
def download_document(org_id):
file_name = request.args.get("file")
if not file_name:
return jsonify({"error": "file query parameter is required"}), 400
org_dir = os.path.join(STORAGE_ROOT, f"org_{org_id}")
target = os.path.join(org_dir, file_name)
if not os.path.exists(target):
return jsonify({"error": "document not found", "path": target}), 404
return send_file(target, as_attachment=True, download_name=os.path.basename(file_name))
REPORT_TEMPLATE = """
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><title>{{ title }}</title></head>
<body>
<h1>{{ title }}</h1>
<p>Prepared for: {{ org_name }}</p>
<div class="analyst-note">{{ note | safe }}</div>
</body>
</html>
"""
@app.route("/api/reports/render", methods=["POST"])
def render_report():
"""Renders an ad-hoc analyst report. The title/org name/note fields are
supplied by the caller so analysts can annotate the report however they
like before it goes out to the customer."""
payload = request.get_json(force=True, silent=True) or {}
title = payload.get("title", "Document Review Report")
org_name = payload.get("orgName", "")
note = payload.get("note", "")
template_source = payload.get("templateOverride") or REPORT_TEMPLATE
html = render_template_string(template_source, title=title, org_name=org_name, note=note)
return html, 200, {"Content-Type": "text/html"}
def _process_document_job(document_id, org_id):
time.sleep(1)
db = get_db()
db.execute("UPDATE documents SET status = 'processed' WHERE id = ?", (document_id,))
db.execute(
"INSERT INTO audit_log (org_id, actor, action, detail) VALUES (?, ?, ?, ?)",
(org_id, "docs-worker", "document.processed", f"document_id={document_id}"),
)
db.commit()
@app.route("/api/jobs/process", methods=["POST"])
@require_role("admin", "analyst")
def dispatch_processing_job():
payload = request.get_json(force=True, silent=True) or {}
document_id = payload.get("documentId")
if not document_id:
return jsonify({"error": "documentId is required"}), 400
thread = threading.Thread(target=_process_document_job, args=(document_id, g.org_id), daemon=True)
thread.start()
return jsonify({"documentId": document_id, "status": "processing"}), 202
if __name__ == "__main__":
init_db()
app.run(host="0.0.0.0", port=int(os.environ.get("PORT", 4002)))
docs-worker/data/.gitkeep
docs-worker/db.py
import os
import sqlite3
import threading
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
DATA_DIR = os.path.join(BASE_DIR, "data")
os.makedirs(DATA_DIR, exist_ok=True)
DB_PATH = os.path.join(DATA_DIR, "docs.sqlite")
_local = threading.local()
SCHEMA = """
CREATE TABLE IF NOT EXISTS organizations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
org_id INTEGER NOT NULL,
role TEXT NOT NULL,
user_email TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS documents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
org_id INTEGER NOT NULL,
filename TEXT NOT NULL,
doc_type TEXT NOT NULL DEFAULT 'other',
status TEXT NOT NULL DEFAULT 'uploaded',
uploaded_by TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
org_id INTEGER,
actor TEXT,
action TEXT NOT NULL,
detail TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
"""
def get_db():
if not hasattr(_local, "conn"):
_local.conn = sqlite3.connect(DB_PATH)
_local.conn.row_factory = sqlite3.Row
return _local.conn
def init_db():
conn = get_db()
conn.executescript(SCHEMA)
conn.commit()
_seed(conn)
def _seed(conn):
row = conn.execute("SELECT COUNT(*) AS c FROM organizations").fetchone()
if row["c"] > 0:
return
conn.execute("INSERT INTO organizations (id, name) VALUES (1, 'Acme Manufacturing')")
conn.execute("INSERT INTO organizations (id, name) VALUES (2, 'Globex Trading')")
conn.execute(
"INSERT INTO sessions (token, org_id, role, user_email) VALUES (?, ?, ?, ?)",
("docs_tok_acme_admin_7f3e", 1, "admin", "admin@acme.example"),
)
conn.execute(
"INSERT INTO sessions (token, org_id, role, user_email) VALUES (?, ?, ?, ?)",
("docs_tok_globex_admin_1c9a", 2, "admin", "admin@globex.example"),
)
conn.execute(
"INSERT INTO documents (org_id, filename, doc_type, status, uploaded_by) VALUES (?, ?, ?, ?, ?)",
(1, "invoice-4472.txt", "invoice", "processed", "admin@acme.example"),
)
conn.execute(
"INSERT INTO documents (org_id, filename, doc_type, status, uploaded_by) VALUES (?, ?, ?, ?, ?)",
(1, "agreement-northwind.txt", "agreement", "uploaded", "admin@acme.example"),
)
conn.execute(
"INSERT INTO documents (org_id, filename, doc_type, status, uploaded_by) VALUES (?, ?, ?, ?, ?)",
(2, "freight-contract.txt", "agreement", "uploaded", "admin@globex.example"),
)
conn.commit()
docs-worker/middleware.py
import functools
from flask import g, jsonify, request
from db import get_db
def load_session():
"""Runs before every request: resolves the bearer token in the
Authorization header to an org/role and stashes it on flask.g for
handlers and the audit logger to use."""
g.org_id = None
g.role = None
g.user_email = None
auth_header = request.headers.get("Authorization", "")
token = auth_header.replace("Bearer ", "").strip()
if request.path in ("/healthz", "/metrics"):
return None
if not token:
return jsonify({"error": "missing bearer token"}), 401
db = get_db()
row = db.execute(
"SELECT org_id, role, user_email FROM sessions WHERE token = ?", (token,)
).fetchone()
if row is None:
return jsonify({"error": "invalid session token"}), 401
g.org_id = row["org_id"]
g.role = row["role"]
g.user_email = row["user_email"]
return None
def require_role(*allowed):
def decorator(view):
@functools.wraps(view)
def wrapped(*args, **kwargs):
if g.role not in allowed:
return jsonify({"error": f"role '{g.role}' is not permitted for this action"}), 403
return view(*args, **kwargs)
return wrapped
return decorator
def log_audit(response):
"""Runs after every request to persist an audit trail entry."""
if request.path in ("/healthz", "/metrics"):
return response
db = get_db()
db.execute(
"INSERT INTO audit_log (org_id, actor, action, detail) VALUES (?, ?, ?, ?)",
(
getattr(g, "org_id", None),
getattr(g, "user_email", None),
f"{request.method} {request.path}",
request.query_string.decode("utf-8"),
),
)
db.commit()
return response
docs-worker/requirements.txt
Flask==3.1.3
docs-worker/storage/org_1/agreement-northwind.txt
Signed vendor agreement - Northwind Supplies.
docs-worker/storage/org_1/invoice-4472.txt
Vendor invoice PDF placeholder for Acme Manufacturing Q3.
docs-worker/storage/org_2/freight-contract.txt
Freight contract - Globex Trading.
payments-api/.gitignore
data/*.sqlite
payments-api/data/.gitkeep
payments-api/db.go
package main
import (
"database/sql"
"log"
"os"
"path/filepath"
_ "modernc.org/sqlite"
)
var db *sql.DB
const schema = `
CREATE TABLE IF NOT EXISTS organizations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
plan TEXT NOT NULL DEFAULT 'standard'
);
CREATE TABLE IF NOT EXISTS api_keys (
api_key TEXT PRIMARY KEY,
org_id INTEGER NOT NULL,
role TEXT NOT NULL DEFAULT 'service',
label TEXT
);
CREATE TABLE IF NOT EXISTS accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
org_id INTEGER NOT NULL,
name TEXT NOT NULL,
currency TEXT NOT NULL DEFAULT 'USD',
balance_cents INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'active'
);
CREATE TABLE IF NOT EXISTS beneficiaries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
org_id INTEGER NOT NULL,
name TEXT NOT NULL,
bank_name TEXT,
iban TEXT,
status TEXT NOT NULL DEFAULT 'active'
);
CREATE TABLE IF NOT EXISTS transfers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
org_id INTEGER NOT NULL,
account_id INTEGER NOT NULL,
beneficiary_id INTEGER,
amount_cents INTEGER NOT NULL,
currency TEXT NOT NULL DEFAULT 'USD',
status TEXT NOT NULL DEFAULT 'pending',
memo TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
settled_at TEXT
);
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
org_id INTEGER,
actor TEXT,
action TEXT NOT NULL,
detail TEXT,
ip TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`
func initDB() {
dataDir := "data"
if err := os.MkdirAll(dataDir, 0o755); err != nil {
log.Fatalf("creating data dir: %v", err)
}
dbPath := filepath.Join(dataDir, "payments.sqlite")
var err error
db, err = sql.Open("sqlite", dbPath)
if err != nil {
log.Fatalf("opening database: %v", err)
}
if _, err := db.Exec(schema); err != nil {
log.Fatalf("applying schema: %v", err)
}
seed()
}
func seed() {
var count int
if err := db.QueryRow("SELECT COUNT(*) FROM organizations").Scan(&count); err != nil {
log.Fatalf("counting organizations: %v", err)
}
if count > 0 {
return
}
res, err := db.Exec("INSERT INTO organizations (name, plan) VALUES (?, ?)", "Acme Manufacturing", "enterprise")
if err != nil {
log.Fatalf("seeding organizations: %v", err)
}
acmeID, _ := res.LastInsertId()
res, err = db.Exec("INSERT INTO organizations (name, plan) VALUES (?, ?)", "Globex Trading", "standard")
if err != nil {
log.Fatalf("seeding organizations: %v", err)
}
globexID, _ := res.LastInsertId()
db.Exec("INSERT INTO api_keys (api_key, org_id, role, label) VALUES (?, ?, ?, ?)",
"sk_live_acme_ops_9f2c1a", acmeID, "admin", "Acme treasury ops key")
db.Exec("INSERT INTO api_keys (api_key, org_id, role, label) VALUES (?, ?, ?, ?)",
"sk_live_globex_finance_4b71e0", globexID, "admin", "Globex finance key")
accRes, err := db.Exec("INSERT INTO accounts (org_id, name, currency, balance_cents, status) VALUES (?, ?, ?, ?, ?)",
acmeID, "Acme Operating Account", "USD", 500000000, "active")
if err != nil {
log.Fatalf("seeding accounts: %v", err)
}
acmeAccID, _ := accRes.LastInsertId()
globexAccRes, err := db.Exec("INSERT INTO accounts (org_id, name, currency, balance_cents, status) VALUES (?, ?, ?, ?, ?)",
globexID, "Globex Trading Account", "EUR", 250000000, "active")
if err != nil {
log.Fatalf("seeding accounts: %v", err)
}
globexAccID, _ := globexAccRes.LastInsertId()
benRes, err := db.Exec("INSERT INTO beneficiaries (org_id, name, bank_name, iban, status) VALUES (?, ?, ?, ?, ?)",
acmeID, "Northwind Supplies", "First Union Bank", "GB29NWBK60161331926819", "active")
if err != nil {
log.Fatalf("seeding beneficiaries: %v", err)
}
northwindID, _ := benRes.LastInsertId()
db.Exec(`INSERT INTO transfers (org_id, account_id, beneficiary_id, amount_cents, currency, status, memo)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
acmeID, acmeAccID, northwindID, 340000, "USD", "pending", "Q3 supplies invoice 4472")
db.Exec(`INSERT INTO transfers (org_id, account_id, beneficiary_id, amount_cents, currency, status, memo)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
globexID, globexAccID, nil, 1850000, "EUR", "pending", "Freight contract milestone 2")
}
payments-api/go.mod
module payments-api
go 1.25.0
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.47.0 // indirect
modernc.org/libc v1.75.6 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.12.1 // indirect
modernc.org/sqlite v1.58.0 // indirect
)
payments-api/go.sum
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
modernc.org/libc v1.75.6 h1:yKk8qo+Di4gkmvRboK8ocCqH22FiUCR6jRy2OwtCRus=
modernc.org/libc v1.75.6/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.58.0 h1:38u40/bwkfM7f0Myhosl+SEMltSDxnGdQf8o6Kjmys0=
modernc.org/sqlite v1.58.0/go.mod h1:rsD2CckafgObKC4DhBlGBf+RiHxkc3hINGt1Xw32tVY=
payments-api/handlers_accounts.go
package main
import (
"fmt"
"net/http"
)
type account struct {
ID int64 `json:"id"`
OrgID int64 `json:"orgId"`
Name string `json:"name"`
Currency string `json:"currency"`
BalanceCents int64 `json:"balanceCents"`
Status string `json:"status"`
}
// searchAccounts lets treasury ops slice the account list by status,
// currency and a free text name match, all combined ad-hoc from whatever
// query params the caller sends.
func searchAccounts(w http.ResponseWriter, r *http.Request) {
orgID := orgIDFromContext(r)
where := fmt.Sprintf("org_id = %d", orgID)
if status := r.URL.Query().Get("status"); status != "" {
where += fmt.Sprintf(" AND status = '%s'", status)
}
if currency := r.URL.Query().Get("currency"); currency != "" {
where += fmt.Sprintf(" AND currency = '%s'", currency)
}
if q := r.URL.Query().Get("q"); q != "" {
where += fmt.Sprintf(" AND name LIKE '%%%s%%'", q)
}
query := fmt.Sprintf("SELECT id, org_id, name, currency, balance_cents, status FROM accounts WHERE %s", where)
rows, err := db.Query(query)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
defer rows.Close()
accounts := []account{}
for rows.Next() {
var a account
if err := rows.Scan(&a.ID, &a.OrgID, &a.Name, &a.Currency, &a.BalanceCents, &a.Status); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
accounts = append(accounts, a)
}
writeJSON(w, http.StatusOK, map[string]any{"accounts": accounts, "query": query})
}
func getAccount(w http.ResponseWriter, r *http.Request) {
orgID := orgIDFromContext(r)
id := r.PathValue("id")
var a account
err := db.QueryRow(
"SELECT id, org_id, name, currency, balance_cents, status FROM accounts WHERE id = ? AND org_id = ?",
id, orgID,
).Scan(&a.ID, &a.OrgID, &a.Name, &a.Currency, &a.BalanceCents, &a.Status)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "account not found"})
return
}
writeJSON(w, http.StatusOK, map[string]any{"account": a})
}
payments-api/handlers_beneficiaries.go
package main
import (
"encoding/json"
"net/http"
)
type createBeneficiaryRequest struct {
Name string `json:"name"`
BankName string `json:"bankName"`
IBAN string `json:"iban"`
}
func createBeneficiary(w http.ResponseWriter, r *http.Request) {
orgID := orgIDFromContext(r)
var req createBeneficiaryRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Name == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"})
return
}
res, err := db.Exec(
"INSERT INTO beneficiaries (org_id, name, bank_name, iban, status) VALUES (?, ?, ?, ?, 'active')",
orgID, req.Name, req.BankName, req.IBAN,
)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
id, _ := res.LastInsertId()
writeJSON(w, http.StatusCreated, map[string]any{"id": id, "name": req.Name})
}
payments-api/handlers_health.go
package main
import "net/http"
func healthz(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok", "service": "payments-api"})
}
func metrics(w http.ResponseWriter, r *http.Request) {
var accounts, transfers, pending int64
db.QueryRow("SELECT COUNT(*) FROM accounts").Scan(&accounts)
db.QueryRow("SELECT COUNT(*) FROM transfers").Scan(&transfers)
db.QueryRow("SELECT COUNT(*) FROM transfers WHERE status = 'pending'").Scan(&pending)
writeJSON(w, http.StatusOK, map[string]any{
"accounts_total": accounts,
"transfers_total": transfers,
"transfers_pending": pending,
})
}
payments-api/handlers_transfers.go
package main
import (
"encoding/json"
"net/http"
)
type transfer struct {
ID int64 `json:"id"`
OrgID int64 `json:"orgId"`
AccountID int64 `json:"accountId"`
BeneficiaryID *int64 `json:"beneficiaryId,omitempty"`
AmountCents int64 `json:"amountCents"`
Currency string `json:"currency"`
Status string `json:"status"`
Memo string `json:"memo"`
}
type createTransferRequest struct {
AccountID int64 `json:"accountId"`
BeneficiaryID *int64 `json:"beneficiaryId"`
AmountCents int64 `json:"amountCents"`
Currency string `json:"currency"`
Memo string `json:"memo"`
}
func getTransfer(w http.ResponseWriter, r *http.Request) {
orgID := orgIDFromContext(r)
id := r.PathValue("id")
var t transfer
err := db.QueryRow(
"SELECT id, org_id, account_id, beneficiary_id, amount_cents, currency, status, memo FROM transfers WHERE id = ? AND org_id = ?",
id, orgID,
).Scan(&t.ID, &t.OrgID, &t.AccountID, &t.BeneficiaryID, &t.AmountCents, &t.Currency, &t.Status, &t.Memo)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "transfer not found"})
return
}
writeJSON(w, http.StatusOK, map[string]any{"transfer": t})
}
// createTransfer books a new pending transfer for the caller's org and
// kicks off asynchronous settlement processing for it.
func createTransfer(w http.ResponseWriter, r *http.Request) {
orgID := orgIDFromContext(r)
var req createTransferRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
if req.AccountID == 0 || req.AmountCents == 0 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "accountId and amountCents are required"})
return
}
if req.Currency == "" {
req.Currency = "USD"
}
res, err := db.Exec(
`INSERT INTO transfers (org_id, account_id, beneficiary_id, amount_cents, currency, status, memo)
VALUES (?, ?, ?, ?, ?, 'pending', ?)`,
orgID, req.AccountID, req.BeneficiaryID, req.AmountCents, req.Currency, req.Memo,
)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
transferID, _ := res.LastInsertId()
// Dispatch settlement asynchronously so the caller isn't blocked on the
// downstream clearing check.
go processSettlement(transferID, orgID, req.AmountCents)
writeJSON(w, http.StatusCreated, map[string]any{"id": transferID, "status": "pending"})
}
payments-api/main.go
package main
import (
"log"
"net/http"
"os"
)
func main() {
initDB()
defer db.Close()
go settlementSweep()
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", healthz)
mux.HandleFunc("GET /metrics", metrics)
mux.HandleFunc("GET /api/accounts/search", withAuth(withAudit("accounts.search")(searchAccounts)))
mux.HandleFunc("GET /api/accounts/{id}", withAuth(withAudit("accounts.get")(getAccount)))
mux.HandleFunc("GET /api/transfers/{id}", withAuth(withAudit("transfers.get")(getTransfer)))
mux.HandleFunc("POST /api/transfers", withAuth(requireRole("admin", "service")(withAudit("transfers.create")(createTransfer))))
mux.HandleFunc("POST /api/beneficiaries", withAuth(requireRole("admin", "service")(withAudit("beneficiaries.create")(createBeneficiary))))
port := os.Getenv("PORT")
if port == "" {
port = "4001"
}
log.Printf("payments-api listening on :%s", port)
if err := http.ListenAndServe(":"+port, mux); err != nil {
log.Fatal(err)
}
}
payments-api/middleware.go
package main
import (
"context"
"encoding/json"
"log"
"net/http"
)
type ctxKey string
const (
ctxOrgID ctxKey = "orgID"
ctxRole ctxKey = "role"
)
func writeJSON(w http.ResponseWriter, status int, payload any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(payload); err != nil {
log.Printf("encoding response: %v", err)
}
}
// withAuth validates the caller's API key against the api_keys table and
// attaches the resolved org id and role to the request context for
// downstream handlers and the audit middleware to use.
func withAuth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
apiKey := r.Header.Get("X-Api-Key")
if apiKey == "" {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "missing X-Api-Key header"})
return
}
var orgID int64
var role string
err := db.QueryRow("SELECT org_id, role FROM api_keys WHERE api_key = ?", apiKey).Scan(&orgID, &role)
if err != nil {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid api key"})
return
}
ctx := context.WithValue(r.Context(), ctxOrgID, orgID)
ctx = context.WithValue(ctx, ctxRole, role)
next(w, r.WithContext(ctx))
}
}
// requireRole rejects the request unless the resolved role is in allowed.
func requireRole(allowed ...string) func(http.HandlerFunc) http.HandlerFunc {
return func(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
role, _ := r.Context().Value(ctxRole).(string)
for _, a := range allowed {
if a == role {
next(w, r)
return
}
}
writeJSON(w, http.StatusForbidden, map[string]string{"error": "role not permitted for this action"})
}
}
}
// withAudit records every request that reaches a business handler, after
// the fact, into the audit_log table for compliance review.
func withAudit(action string) func(http.HandlerFunc) http.HandlerFunc {
return func(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
next(w, r)
orgID, _ := r.Context().Value(ctxOrgID).(int64)
detail := r.URL.RawQuery
_, err := db.Exec(
"INSERT INTO audit_log (org_id, actor, action, detail, ip) VALUES (?, ?, ?, ?, ?)",
orgID, r.Header.Get("X-Api-Key"), action, detail, r.RemoteAddr,
)
if err != nil {
log.Printf("audit log insert failed: %v", err)
}
}
}
}
func orgIDFromContext(r *http.Request) int64 {
orgID, _ := r.Context().Value(ctxOrgID).(int64)
return orgID
}
payments-api/settlement.go
package main
import (
"log"
"strconv"
"time"
)
// processSettlement runs the risk/clearing check for a freshly created
// transfer and marks it settled once it clears. Large transfers are held
// a little longer to simulate an additional manual review step.
func processSettlement(transferID, orgID, amountCents int64) {
delay := 500 * time.Millisecond
if amountCents > 1000000 {
delay = 2 * time.Second
}
time.Sleep(delay)
_, err := db.Exec("UPDATE transfers SET status = 'settled', settled_at = datetime('now') WHERE id = ?", transferID)
if err != nil {
log.Printf("settlement update failed for transfer %d: %v", transferID, err)
return
}
_, err = db.Exec(
"INSERT INTO audit_log (org_id, actor, action, detail) VALUES (?, ?, ?, ?)",
orgID, "settlement-worker", "transfer.settled", "transfer_id="+strconv.FormatInt(transferID, 10),
)
if err != nil {
log.Printf("audit log insert failed for transfer %d: %v", transferID, err)
}
}
// settlementSweep periodically catches any transfers that were left
// pending (e.g. after a service restart) and runs them through settlement.
func settlementSweep() {
ticker := time.NewTicker(30 * time.Second)
for range ticker.C {
rows, err := db.Query("SELECT id, org_id, amount_cents FROM transfers WHERE status = 'pending'")
if err != nil {
log.Printf("settlement sweep query failed: %v", err)
continue
}
var pending []transfer
for rows.Next() {
var t transfer
if err := rows.Scan(&t.ID, &t.OrgID, &t.AmountCents); err != nil {
continue
}
pending = append(pending, t)
}
rows.Close()
for _, t := range pending {
go processSettlement(t.ID, t.OrgID, t.AmountCents)
}
}
}
portal/.gitignore
node_modules/
dist/
data/*.sqlite
storage/documents/_incoming/*
!storage/documents/_incoming/.gitkeep
portal/package-lock.json
{
"name": "treasury-portal",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "treasury-portal",
"version": "1.0.0",
"dependencies": {
"cookie-parser": "^1.4.6",
"ejs": "^3.1.9",
"express": "^4.19.2",
"multer": "^1.4.5-lts.1"
},
"devDependencies": {
"@types/cookie-parser": "^1.4.6",
"@types/express": "^4.17.21",
"@types/multer": "^1.4.11",
"@types/node": "^22.0.0",
"typescript": "^5.5.4"
}
},
"node_modules/@types/body-parser": {
"version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/connect": "*",
"@types/node": "*"
}
},
"node_modules/@types/connect": {
"version": "3.4.38",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/cookie-parser": {
"version": "1.4.10",
"resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz",
"integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/express": "*"
}
},
"node_modules/@types/express": {
"version": "4.17.25",
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz",
"integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/body-parser": "*",
"@types/express-serve-static-core": "^4.17.33",
"@types/qs": "*",
"@types/serve-static": "^1"
}
},
"node_modules/@types/express-serve-static-core": {
"version": "4.19.9",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.9.tgz",
"integrity": "sha512-QP2ESEe/ImWY0HDwNAnK9PvEffUyhLTnWkk7KXzHfyeWAnlrDe1fN77bXl6ia8KT3wPlmA7t9/VPRpnf4Ex9sg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
"@types/qs": "*",
"@types/range-parser": "*",
"@types/send": "*"
}
},
"node_modules/@types/http-errors": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/mime": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/multer": {
"version": "1.4.13",
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.13.tgz",
"integrity": "sha512-bhhdtPw7JqCiEfC9Jimx5LqX9BDIPJEh2q/fQ4bqbBPtyEZYr3cvF22NwG0DmPZNYA0CAf2CnqDB4KIGGpJcaw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/express": "*"
}
},
"node_modules/@types/node": {
"version": "22.20.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.2.tgz",
"integrity": "sha512-xlvWf4Vs9n1PEVYwP1n4vvG07M6y8WgvJ2t0vbrWTmijsIHp1cS+uJ2kMIRdY3nHZK0nCYKrPeD171+SzF4/zw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@types/qs": {
"version": "6.15.1",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz",
"integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/range-parser": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
"integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/serve-static": {
"version": "1.15.10",
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz",
"integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/http-errors": "*",
"@types/node": "*",
"@types/send": "<1"
}
},
"node_modules/@types/serve-static/node_modules/@types/send": {
"version": "0.17.6",
"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz",
"integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/mime": "^1",
"@types/node": "*"
}
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
"license": "MIT",
"dependencies": {
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/append-field": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
"license": "MIT"
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/async": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
"license": "MIT"
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"license": "MIT"
},
"node_modules/body-parser": {
"version": "1.20.8",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz",
"integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"content-type": "~1.0.5",
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "~1.2.0",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"on-finished": "~2.4.1",
"qs": "~6.16.0",
"raw-body": "~2.5.3",
"type-is": "~1.6.18",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/body-parser/node_modules/qs": {
"version": "6.16.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
"integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
"license": "BSD-3-Clause",
"dependencies": {
"es-define-property": "^1.0.1",
"side-channel": "^1.1.1"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/brace-expansion": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"license": "MIT"
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
"dependencies": {
"streamsearch": "^1.1.0"
},
"engines": {
"node": ">=10.16.0"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/concat-stream": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz",
"integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==",
"engines": [
"node >= 0.8"
],
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^2.2.2",
"typedarray": "^0.0.6"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
"license": "MIT",
"dependencies": {
"safe-buffer": "5.2.1"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-parser": {
"version": "1.4.7",
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
"license": "MIT",
"dependencies": {
"cookie": "0.7.2",
"cookie-signature": "1.0.6"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/cookie-signature": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"license": "MIT"
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/destroy": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
"license": "MIT",
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/ejs": {
"version": "3.1.10",
"resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
"integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==",
"license": "Apache-2.0",
"dependencies": {
"jake": "^10.8.5"
},
"bin": {
"ejs": "bin/cli.js"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express": {
"version": "4.22.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
"integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
"body-parser": "~1.20.5",
"content-disposition": "~0.5.4",
"content-type": "~1.0.4",
"cookie": "~0.7.1",
"cookie-signature": "~1.0.6",
"debug": "2.6.9",
"depd": "2.0.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"finalhandler": "~1.3.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.0",
"merge-descriptors": "1.0.3",
"methods": "~1.1.2",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"path-to-regexp": "~0.1.12",
"proxy-addr": "~2.0.7",
"qs": "~6.15.1",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
"send": "~0.19.0",
"serve-static": "~1.16.2",
"setprototypeof": "1.2.0",
"statuses": "~2.0.1",
"type-is": "~1.6.18",
"utils-merge": "1.0.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/filelist": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz",
"integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==",
"license": "Apache-2.0",
"dependencies": {
"minimatch": "^5.0.1"
}
},
"node_modules/finalhandler": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
"integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"statuses": "~2.0.2",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/jake": {
"version": "10.9.4",
"resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz",
"integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==",
"license": "Apache-2.0",
"dependencies": {
"async": "^3.2.6",
"filelist": "^1.0.4",
"picocolors": "^1.1.1"
},
"bin": {
"jake": "bin/cli.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/merge-descriptors": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/methods": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/minimatch": {
"version": "5.1.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
"integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=10"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mkdirp": {
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"license": "MIT",
"dependencies": {
"minimist": "^1.2.6"
},
"bin": {
"mkdirp": "bin/cmd.js"
}
},
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/multer": {
"version": "1.4.5-lts.2",
"resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz",
"integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==",
"deprecated": "Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version.",
"license": "MIT",
"dependencies": {
"append-field": "^1.0.0",
"busboy": "^1.0.0",
"concat-stream": "^1.5.2",
"mkdirp": "^0.5.4",
"object-assign": "^4.1.1",
"type-is": "^1.6.4",
"xtend": "^4.0.0"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/path-to-regexp": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC"
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/qs": {
"version": "6.15.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
"license": "BSD-3-Clause",
"dependencies": {
"es-define-property": "^1.0.1",
"side-channel": "^1.1.1"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/raw-body": {
"version": "2.5.3",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/readable-stream/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/send": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
"integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.1",
"mime": "1.6.0",
"ms": "2.1.3",
"on-finished": "~2.4.1",
"range-parser": "~1.2.1",
"statuses": "~2.0.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/send/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/serve-static": {
"version": "1.16.3",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
"integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
"license": "MIT",
"dependencies": {
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"parseurl": "~1.3.3",
"send": "~0.19.1"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/side-channel": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4",
"side-channel-list": "^1.0.1",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/string_decoder/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"license": "MIT",
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/typedarray": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"license": "MIT"
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
"license": "MIT",
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
}
}
}
portal/package.json
{
"name": "treasury-portal",
"version": "1.0.0",
"description": "Admin portal for the treasury operations platform",
"main": "dist/server.js",
"scripts": {
"build": "tsc -p .",
"start": "node --experimental-sqlite dist/server.js",
"dev": "tsc -p . && node --experimental-sqlite dist/server.js"
},
"dependencies": {
"cookie-parser": "^1.4.6",
"ejs": "^3.1.9",
"express": "^4.19.2",
"multer": "^1.4.5-lts.1"
},
"devDependencies": {
"@types/cookie-parser": "^1.4.6",
"@types/express": "^4.17.21",
"@types/multer": "^1.4.11",
"@types/node": "^22.0.0",
"typescript": "^5.5.4"
}
}
portal/src/db/index.ts
import path from "path";
import fs from "fs";
import { DatabaseSync } from "node:sqlite";
const DATA_DIR = path.join(__dirname, "..", "..", "data");
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
const DB_PATH = path.join(DATA_DIR, "portal.sqlite");
export const db = new DatabaseSync(DB_PATH);
db.exec(`
CREATE TABLE IF NOT EXISTS organizations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
plan TEXT NOT NULL DEFAULT 'standard',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS teams (
id INTEGER PRIMARY KEY AUTOINCREMENT,
org_id INTEGER NOT NULL,
name TEXT NOT NULL,
FOREIGN KEY (org_id) REFERENCES organizations(id)
);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
org_id INTEGER NOT NULL,
team_id INTEGER,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'member',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (org_id) REFERENCES organizations(id)
);
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
org_id INTEGER NOT NULL,
role TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS beneficiaries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
org_id INTEGER NOT NULL,
name TEXT NOT NULL,
bank_name TEXT,
iban TEXT,
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (org_id) REFERENCES organizations(id)
);
CREATE TABLE IF NOT EXISTS transfers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
org_id INTEGER NOT NULL,
beneficiary_id INTEGER,
account_ref TEXT,
amount_cents INTEGER NOT NULL,
currency TEXT NOT NULL DEFAULT 'USD',
status TEXT NOT NULL DEFAULT 'pending',
memo TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
org_id INTEGER,
user_id INTEGER,
action TEXT NOT NULL,
detail TEXT,
ip TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`);
function seed() {
const orgCount = db.prepare("SELECT COUNT(*) AS c FROM organizations").get() as any;
if (orgCount.c > 0) return;
const insertOrg = db.prepare("INSERT INTO organizations (name, plan) VALUES (?, ?)");
const acme = insertOrg.run("Acme Manufacturing", "enterprise");
const globex = insertOrg.run("Globex Trading", "standard");
const insertTeam = db.prepare("INSERT INTO teams (org_id, name) VALUES (?, ?)");
insertTeam.run(Number(acme.lastInsertRowid), "Treasury Ops");
insertTeam.run(Number(globex.lastInsertRowid), "Finance");
const insertUser = db.prepare(
"INSERT INTO users (org_id, team_id, email, password_hash, role) VALUES (?, ?, ?, ?, ?)"
);
// password for every seed user is "password123" (sha256 hex, see auth.ts)
const pw = "ef92b778bafe771e89245b89ecbc08a44a4e166c06659911881f383d4473e94f";
insertUser.run(Number(acme.lastInsertRowid), 1, "admin@acme.example", pw, "admin");
insertUser.run(Number(acme.lastInsertRowid), 1, "analyst@acme.example", pw, "analyst");
insertUser.run(Number(globex.lastInsertRowid), 2, "admin@globex.example", pw, "admin");
const insertBen = db.prepare(
"INSERT INTO beneficiaries (org_id, name, bank_name, iban, status) VALUES (?, ?, ?, ?, ?)"
);
insertBen.run(Number(acme.lastInsertRowid), "Northwind Supplies", "First Union Bank", "GB29NWBK60161331926819", "active");
insertBen.run(Number(acme.lastInsertRowid), "Initech Logistics", "Metro Bank", "GB29METR60161334455667", "active");
insertBen.run(Number(globex.lastInsertRowid), "Umbrella Freight", "Globex Bank", "GB29GLBX60161339988776", "active");
const insertTransfer = db.prepare(
"INSERT INTO transfers (org_id, beneficiary_id, account_ref, amount_cents, currency, status, memo) VALUES (?, ?, ?, ?, ?, ?, ?)"
);
insertTransfer.run(Number(acme.lastInsertRowid), 1, "ACC-1001", 250000, "USD", "settled", "Q3 supplies invoice 4471");
insertTransfer.run(Number(acme.lastInsertRowid), 2, "ACC-1001", 75000, "USD", "pending", "Logistics retainer");
insertTransfer.run(Number(globex.lastInsertRowid), 3, "ACC-2002", 1200000, "EUR", "pending", "Freight contract deposit");
}
seed();
portal/src/middleware/audit.ts
import { Request, Response, NextFunction } from "express";
import { db } from "../db";
const insertAudit = db.prepare(
"INSERT INTO audit_log (org_id, user_id, action, detail, ip) VALUES (?, ?, ?, ?, ?)"
);
export function auditMiddleware(req: Request, res: Response, next: NextFunction) {
res.on("finish", () => {
const action = `${req.method} ${req.path}`;
const detail = JSON.stringify({
status: res.statusCode,
query: req.query,
});
insertAudit.run(
req.user?.orgId ?? null,
req.user?.id ?? null,
action,
detail,
req.ip ?? null
);
});
next();
}
portal/src/middleware/auth.ts
import { Request, Response, NextFunction } from "express";
import { db } from "../db";
export function authMiddleware(req: Request, res: Response, next: NextFunction) {
const header = req.headers["authorization"] || "";
const token = Array.isArray(header) ? header[0] : header.replace(/^Bearer\s+/i, "");
if (!token) {
res.status(401).json({ error: "missing bearer token" });
return;
}
const session = db
.prepare("SELECT token, user_id, org_id, role FROM sessions WHERE token = ?")
.get(token) as any;
if (!session) {
res.status(401).json({ error: "invalid or expired session" });
return;
}
const user = db.prepare("SELECT id, org_id, email, role FROM users WHERE id = ?").get(
session.user_id
) as any;
if (!user) {
res.status(401).json({ error: "session user not found" });
return;
}
req.user = {
id: user.id,
orgId: user.org_id,
role: user.role,
email: user.email,
};
next();
}
portal/src/middleware/rbac.ts
import { Request, Response, NextFunction } from "express";
export function requireRole(...allowed: string[]) {
return function (req: Request, res: Response, next: NextFunction) {
if (!req.user) {
res.status(401).json({ error: "not authenticated" });
return;
}
if (!allowed.includes(req.user.role)) {
res.status(403).json({ error: `role '${req.user.role}' is not permitted for this action` });
return;
}
next();
};
}
portal/src/routes/auth.ts
import { Router } from "express";
import crypto from "crypto";
import { db } from "../db";
export const authRouter = Router();
function sha256(value: string): string {
return crypto.createHash("sha256").update(value).digest("hex");
}
authRouter.post("/login", (req, res) => {
const { email, password } = req.body || {};
if (!email || !password) {
res.status(400).json({ error: "email and password are required" });
return;
}
const user = db
.prepare("SELECT id, org_id, role, password_hash FROM users WHERE email = ?")
.get(email) as any;
if (!user || user.password_hash !== sha256(password)) {
res.status(401).json({ error: "invalid credentials" });
return;
}
const token = crypto.randomBytes(24).toString("hex");
db.prepare(
"INSERT INTO sessions (token, user_id, org_id, role) VALUES (?, ?, ?, ?)"
).run(token, user.id, user.org_id, user.role);
res.json({ token, orgId: user.org_id, role: user.role });
});
authRouter.post("/logout", (req, res) => {
const header = req.headers["authorization"] || "";
const token = Array.isArray(header) ? header[0] : header.replace(/^Bearer\s+/i, "");
db.prepare("DELETE FROM sessions WHERE token = ?").run(token);
res.json({ ok: true });
});
portal/src/routes/beneficiaries.ts
import { Router } from "express";
import { db } from "../db";
import { requireRole } from "../middleware/rbac";
export const beneficiariesRouter = Router();
beneficiariesRouter.post("/", requireRole("admin", "analyst"), (req, res) => {
const { name, bankName, iban } = req.body || {};
if (!name) {
res.status(400).json({ error: "beneficiary name is required" });
return;
}
const result = db
.prepare(
"INSERT INTO beneficiaries (org_id, name, bank_name, iban, status) VALUES (?, ?, ?, ?, 'active')"
)
.run(req.user!.orgId, name, bankName ?? null, iban ?? null);
res.status(201).json({ id: Number(result.lastInsertRowid), name, bankName, iban });
});
beneficiariesRouter.get("/", (req, res) => {
const rows = db
.prepare("SELECT id, name, bank_name, iban, status FROM beneficiaries WHERE org_id = ?")
.all(req.user!.orgId);
res.json({ beneficiaries: rows });
});
portal/src/routes/documents.js
const express = require("express");
const fs = require("fs");
const path = require("path");
const multer = require("multer");
const router = express.Router();
const STORAGE_ROOT = path.join(__dirname, "..", "..", "storage", "documents");
const upload = multer({ dest: path.join(STORAGE_ROOT, "_incoming") });
// Old-school download endpoint kept around from the original PHP-to-Node
// port. Caller tells us which org folder and which file inside it to stream.
router.get("/:orgId/download", function (req, res) {
const orgId = req.params.orgId;
const file = req.query.file;
if (!file) {
return res.status(400).json({ error: "file query parameter is required" });
}
const orgDir = path.join(STORAGE_ROOT, "org-" + orgId);
const target = path.join(orgDir, file);
fs.readFile(target, function (err, data) {
if (err) {
return res.status(404).json({ error: "document not found", path: target });
}
res.setHeader("Content-Disposition", "attachment; filename=" + file);
res.send(data);
});
});
router.get("/:orgId/list", function (req, res) {
const orgDir = path.join(STORAGE_ROOT, "org-" + req.params.orgId);
fs.readdir(orgDir, function (err, files) {
if (err) {
return res.json({ files: [] });
}
res.json({ files: files });
});
});
router.post("/:orgId/upload", upload.single("document"), function (req, res) {
const orgId = req.params.orgId;
const orgDir = path.join(STORAGE_ROOT, "org-" + orgId);
if (!fs.existsSync(orgDir)) {
fs.mkdirSync(orgDir, { recursive: true });
}
const destName = req.body.filename || req.file.originalname;
const destPath = path.join(orgDir, destName);
fs.renameSync(req.file.path, destPath);
res.status(201).json({ stored: destName, orgId: orgId });
});
module.exports = router;
portal/src/routes/health.ts
import { Router } from "express";
import { db } from "../db";
export const healthRouter = Router();
healthRouter.get("/healthz", (req, res) => {
res.json({ status: "ok", service: "treasury-portal" });
});
healthRouter.get("/metrics", (req, res) => {
const orgCount = db.prepare("SELECT COUNT(*) AS c FROM organizations").get() as any;
const userCount = db.prepare("SELECT COUNT(*) AS c FROM users").get() as any;
const transferCount = db.prepare("SELECT COUNT(*) AS c FROM transfers").get() as any;
const pendingCount = db
.prepare("SELECT COUNT(*) AS c FROM transfers WHERE status = 'pending'")
.get() as any;
res.json({
organizations: orgCount.c,
users: userCount.c,
transfers_total: transferCount.c,
transfers_pending: pendingCount.c,
uptime_seconds: process.uptime(),
});
});
portal/src/routes/orgs.ts
import { Router } from "express";
import { db } from "../db";
import { requireRole } from "../middleware/rbac";
export const orgsRouter = Router();
orgsRouter.get("/", (req, res) => {
const orgs = db.prepare("SELECT id, name, plan, created_at FROM organizations").all();
res.json({ organizations: orgs });
});
orgsRouter.get("/:id", (req, res) => {
const org = db
.prepare("SELECT id, name, plan, created_at FROM organizations WHERE id = ?")
.get(req.params.id);
if (!org) {
res.status(404).json({ error: "organization not found" });
return;
}
const teams = db.prepare("SELECT id, name FROM teams WHERE org_id = ?").all(req.params.id);
const users = db
.prepare("SELECT id, email, role, team_id FROM users WHERE org_id = ?")
.all(req.params.id);
res.json({ organization: org, teams, users });
});
orgsRouter.post("/:id/teams", requireRole("admin"), (req, res) => {
const { name } = req.body || {};
if (!name) {
res.status(400).json({ error: "team name is required" });
return;
}
const result = db
.prepare("INSERT INTO teams (org_id, name) VALUES (?, ?)")
.run(req.params.id, name);
res.status(201).json({ id: Number(result.lastInsertRowid), orgId: Number(req.params.id), name });
});
portal/src/routes/reports.ts
import { Router } from "express";
import path from "path";
import ejs from "ejs";
import { db } from "../db";
export const reportsRouter = Router();
reportsRouter.post("/statement", (req, res) => {
const {
accountName,
periodStart,
periodEnd,
customerNote,
footerHtml,
lines,
} = req.body || {};
const transfers = db
.prepare(
"SELECT created_at as date, memo, amount_cents as amount FROM transfers WHERE org_id = ? ORDER BY created_at DESC LIMIT 25"
)
.all(req.user!.orgId);
const templatePath = path.join(__dirname, "..", "..", "views", "statement.ejs");
const html = ejs.renderFile(
templatePath,
{
accountName: accountName || "Untitled Account",
periodStart: periodStart || "",
periodEnd: periodEnd || "",
customerNote: customerNote || "",
footerHtml: footerHtml || "",
lines: Array.isArray(lines) && lines.length ? lines : transfers,
},
{ async: false }
);
Promise.resolve(html).then((rendered) => {
res.set("Content-Type", "text/html");
res.send(rendered);
});
});
portal/src/routes/transfers.ts
import { Router } from "express";
import { db } from "../db";
export const transfersRouter = Router();
// Search/filter transfers for the treasury ops dashboard. Supports ad-hoc
// filtering by any combination of status, currency, beneficiary and a free
// text memo search so analysts can slice the ledger however they need.
transfersRouter.get("/search", (req, res) => {
const { status, currency, beneficiary, minAmount, maxAmount, memo, sort } = req.query;
let where = `org_id = ${req.user!.orgId}`;
if (status) {
where += ` AND status = '${status}'`;
}
if (currency) {
where += ` AND currency = '${currency}'`;
}
if (beneficiary) {
where += ` AND beneficiary_id = ${beneficiary}`;
}
if (minAmount) {
where += ` AND amount_cents >= ${minAmount}`;
}
if (maxAmount) {
where += ` AND amount_cents <= ${maxAmount}`;
}
if (memo) {
where += ` AND memo LIKE '%${memo}%'`;
}
const orderBy = sort ? `${sort}` : "created_at DESC";
const sql = `SELECT id, beneficiary_id, account_ref, amount_cents, currency, status, memo, created_at
FROM transfers WHERE ${where} ORDER BY ${orderBy}`;
const rows = db.prepare(sql).all();
res.json({ transfers: rows, sql });
});
transfersRouter.get("/:id", (req, res) => {
const row = db
.prepare("SELECT * FROM transfers WHERE id = ? AND org_id = ?")
.get(req.params.id, req.user!.orgId);
if (!row) {
res.status(404).json({ error: "transfer not found" });
return;
}
res.json({ transfer: row });
});
transfersRouter.post("/", (req, res) => {
const { beneficiaryId, accountRef, amountCents, currency, memo } = req.body || {};
if (!amountCents || !accountRef) {
res.status(400).json({ error: "accountRef and amountCents are required" });
return;
}
const result = db
.prepare(
`INSERT INTO transfers (org_id, beneficiary_id, account_ref, amount_cents, currency, status, memo)
VALUES (?, ?, ?, ?, ?, 'pending', ?)`
)
.run(req.user!.orgId, beneficiaryId ?? null, accountRef, amountCents, currency ?? "USD", memo ?? null);
res.status(201).json({ id: Number(result.lastInsertRowid), status: "pending" });
});
portal/src/server.ts
import express from "express";
import cookieParser from "cookie-parser";
import "./db";
import { authMiddleware } from "./middleware/auth";
import { auditMiddleware } from "./middleware/audit";
import { authRouter } from "./routes/auth";
import { orgsRouter } from "./routes/orgs";
import { beneficiariesRouter } from "./routes/beneficiaries";
import { transfersRouter } from "./routes/transfers";
import { reportsRouter } from "./routes/reports";
import { healthRouter } from "./routes/health";
// eslint-disable-next-line @typescript-eslint/no-var-requires
const documentsRouter = require("./routes/documents");
const app = express();
const PORT = process.env.PORT ? Number(process.env.PORT) : 4000;
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(cookieParser());
app.use(healthRouter);
app.use("/api/auth", authRouter);
// Everything below this line requires an authenticated session and gets
// its activity recorded in the audit trail.
app.use(authMiddleware);
app.use(auditMiddleware);
app.use("/api/orgs", orgsRouter);
app.use("/api/beneficiaries", beneficiariesRouter);
app.use("/api/transfers", transfersRouter);
app.use("/api/reports", reportsRouter);
app.use("/api/documents", documentsRouter);
app.listen(PORT, () => {
console.log(`treasury-portal listening on :${PORT}`);
});
portal/src/types.ts
export interface AuthedUser {
id: number;
orgId: number;
role: string;
email: string;
}
declare global {
namespace Express {
interface Request {
user?: AuthedUser;
}
}
}
export {};
portal/storage/documents/_incoming/.gitkeep
portal/storage/documents/org-1/statement-q3.txt
Q3 2026 Statement
Acme Manufacturing
All transactions reconciled.
portal/storage/documents/org-1/vendor-agreement.txt
Vendor Agreement - Northwind Supplies
Confidential - internal use only.
portal/storage/documents/org-2/statement-q3.txt
Q3 2026 Statement
Globex Trading
portal/tsconfig.json
{
"compilerOptions": {
"target": "ES2021",
"module": "CommonJS",
"moduleResolution": "node",
"outDir": "dist",
"rootDir": "src",
"allowJs": true,
"checkJs": false,
"esModuleInterop": true,
"resolveJsonModule": true,
"strict": false,
"skipLibCheck": true
},
"include": ["src/**/*"]
}
portal/views/statement.ejs
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Statement - <%= accountName %></title>
</head>
<body>
<h1>Treasury Statement</h1>
<h2>Account: <%= accountName %></h2>
<p>Period: <%= periodStart %> - <%= periodEnd %></p>
<div class="customer-note">
<%- customerNote %>
</div>
<table>
<thead>
<tr><th>Date</th><th>Memo</th><th>Amount</th></tr>
</thead>
<tbody>
<% lines.forEach(function(line) { %>
<tr>
<td><%= line.date %></td>
<td><%- line.memo %></td>
<td><%= line.amount %></td>
</tr>
<% }) %>
</tbody>
</table>
<footer><%- footerHtml %></footer>
</body>
</html>
risk-api/.gitignore
target/
data/*.sqlite
dependency-reduced-pom.xml
risk-api/pom.xml
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.reachco</groupId>
<artifactId>risk-api</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.xerial</groupId>
<artifactId>sqlite-jdbc</artifactId>
<version>3.46.1.3</version>
</dependency>
</dependencies>
<build>
<finalName>risk-api</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.6.0</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>com.reachco.risk.Main</mainClass>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
risk-api/src/main/java/com/reachco/risk/AuditFilter.java
package com.reachco.risk;
import com.sun.net.httpserver.Filter;
import com.sun.net.httpserver.HttpExchange;
import java.io.IOException;
import java.sql.Connection;
import java.sql.PreparedStatement;
public class AuditFilter extends Filter {
@Override
public String description() {
return "Records every request that reaches a business handler into audit_log";
}
@Override
public void doFilter(HttpExchange exchange, Chain chain) throws IOException {
chain.doFilter(exchange);
Object orgIdAttr = exchange.getAttribute("orgId");
Object apiKeyAttr = exchange.getAttribute("apiKey");
try {
Connection conn = Database.get();
try (PreparedStatement ps = conn.prepareStatement(
"INSERT INTO audit_log (org_id, actor, action, detail) VALUES (?, ?, ?, ?)")) {
if (orgIdAttr != null) {
ps.setInt(1, (Integer) orgIdAttr);
} else {
ps.setNull(1, java.sql.Types.INTEGER);
}
ps.setString(2, apiKeyAttr == null ? null : apiKeyAttr.toString());
ps.setString(3, exchange.getRequestMethod() + " " + exchange.getRequestURI().getPath());
ps.setString(4, exchange.getRequestURI().getRawQuery());
ps.executeUpdate();
}
} catch (Exception e) {
System.err.println("audit log insert failed: " + e.getMessage());
}
}
}
risk-api/src/main/java/com/reachco/risk/AuthFilter.java
package com.reachco.risk;
import com.sun.net.httpserver.Filter;
import com.sun.net.httpserver.HttpExchange;
import java.io.IOException;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.util.List;
public class AuthFilter extends Filter {
@Override
public String description() {
return "Resolves the caller's API key to an org id and role";
}
@Override
public void doFilter(HttpExchange exchange, Chain chain) throws IOException {
List<String> headerValues = exchange.getRequestHeaders().get("X-Api-Key");
String apiKey = (headerValues == null || headerValues.isEmpty()) ? null : headerValues.get(0);
if (apiKey == null || apiKey.isBlank()) {
Http.sendJson(exchange, 401, "{\"error\":\"missing X-Api-Key header\"}");
return;
}
Connection conn = Database.get();
try (PreparedStatement ps = conn.prepareStatement(
"SELECT org_id, role FROM api_keys WHERE api_key = ?")) {
ps.setString(1, apiKey);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) {
Http.sendJson(exchange, 401, "{\"error\":\"invalid api key\"}");
return;
}
exchange.setAttribute("orgId", rs.getInt("org_id"));
exchange.setAttribute("role", rs.getString("role"));
exchange.setAttribute("apiKey", apiKey);
}
} catch (Exception e) {
Http.sendJson(exchange, 500, "{\"error\":\"auth lookup failed\"}");
return;
}
chain.doFilter(exchange);
}
}
risk-api/src/main/java/com/reachco/risk/Database.java
package com.reachco.risk;
import java.io.File;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
public final class Database {
private static Connection connection;
private Database() {
}
public static synchronized Connection get() {
if (connection != null) {
return connection;
}
try {
File dataDir = new File("data");
if (!dataDir.exists()) {
dataDir.mkdirs();
}
connection = DriverManager.getConnection("jdbc:sqlite:data/risk.sqlite");
init(connection);
return connection;
} catch (SQLException e) {
throw new RuntimeException("failed to open risk database", e);
}
}
private static void init(Connection conn) throws SQLException {
try (Statement st = conn.createStatement()) {
st.executeUpdate(
"CREATE TABLE IF NOT EXISTS organizations (" +
" id INTEGER PRIMARY KEY AUTOINCREMENT," +
" name TEXT NOT NULL" +
")");
st.executeUpdate(
"CREATE TABLE IF NOT EXISTS api_keys (" +
" api_key TEXT PRIMARY KEY," +
" org_id INTEGER NOT NULL," +
" role TEXT NOT NULL DEFAULT 'analyst'" +
")");
st.executeUpdate(
"CREATE TABLE IF NOT EXISTS risk_reviews (" +
" id INTEGER PRIMARY KEY AUTOINCREMENT," +
" org_id INTEGER NOT NULL," +
" transfer_ref TEXT," +
" beneficiary_name TEXT," +
" score INTEGER NOT NULL," +
" level TEXT NOT NULL," +
" notes TEXT," +
" reviewer TEXT," +
" created_at TEXT NOT NULL DEFAULT (datetime('now'))" +
")");
st.executeUpdate(
"CREATE TABLE IF NOT EXISTS audit_log (" +
" id INTEGER PRIMARY KEY AUTOINCREMENT," +
" org_id INTEGER," +
" actor TEXT," +
" action TEXT NOT NULL," +
" detail TEXT," +
" created_at TEXT NOT NULL DEFAULT (datetime('now'))" +
")");
}
seed(conn);
}
private static void seed(Connection conn) throws SQLException {
try (Statement st = conn.createStatement();
ResultSet rs = st.executeQuery("SELECT COUNT(*) AS c FROM organizations")) {
rs.next();
if (rs.getInt("c") > 0) {
return;
}
}
try (Statement st = conn.createStatement()) {
st.executeUpdate("INSERT INTO organizations (id, name) VALUES (1, 'Acme Manufacturing')");
st.executeUpdate("INSERT INTO organizations (id, name) VALUES (2, 'Globex Trading')");
st.executeUpdate("INSERT INTO api_keys (api_key, org_id, role) VALUES ('risk_key_acme_5e91', 1, 'admin')");
st.executeUpdate("INSERT INTO api_keys (api_key, org_id, role) VALUES ('risk_key_globex_2b70', 2, 'admin')");
}
try (PreparedStatement ps = conn.prepareStatement(
"INSERT INTO risk_reviews (org_id, transfer_ref, beneficiary_name, score, level, notes, reviewer) " +
"VALUES (?, ?, ?, ?, ?, ?, ?)")) {
ps.setInt(1, 1);
ps.setString(2, "TRF-1001");
ps.setString(3, "Northwind Supplies");
ps.setInt(4, 22);
ps.setString(5, "low");
ps.setString(6, "Recurring vendor, no watchlist hits.");
ps.setString(7, "system");
ps.executeUpdate();
ps.setInt(1, 1);
ps.setString(2, "TRF-1002");
ps.setString(3, "Initech Logistics");
ps.setInt(4, 61);
ps.setString(5, "medium");
ps.setString(6, "New beneficiary, first transfer above threshold.");
ps.setString(7, "system");
ps.executeUpdate();
ps.setInt(1, 2);
ps.setString(2, "TRF-2001");
ps.setString(3, "Umbrella Freight");
ps.setInt(4, 85);
ps.setString(5, "high");
ps.setString(6, "Beneficiary bank in high-risk jurisdiction.");
ps.setString(7, "system");
ps.executeUpdate();
}
}
}
risk-api/src/main/java/com/reachco/risk/Http.java
package com.reachco.risk;
import com.sun.net.httpserver.HttpExchange;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.Map;
public final class Http {
private Http() {
}
public static void sendJson(HttpExchange exchange, int status, String json) throws IOException {
byte[] bytes = json.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("Content-Type", "application/json");
exchange.sendResponseHeaders(status, bytes.length);
try (OutputStream os = exchange.getResponseBody()) {
os.write(bytes);
}
}
public static void sendHtml(HttpExchange exchange, int status, String html) throws IOException {
byte[] bytes = html.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("Content-Type", "text/html");
exchange.sendResponseHeaders(status, bytes.length);
try (OutputStream os = exchange.getResponseBody()) {
os.write(bytes);
}
}
public static String readBody(HttpExchange exchange) throws IOException {
try (InputStream is = exchange.getRequestBody();
ByteArrayOutputStream buffer = new ByteArrayOutputStream()) {
is.transferTo(buffer);
return buffer.toString(StandardCharsets.UTF_8);
}
}
public static Map<String, String> queryParams(HttpExchange exchange) {
Map<String, String> params = new LinkedHashMap<>();
String query = exchange.getRequestURI().getRawQuery();
if (query == null || query.isBlank()) {
return params;
}
for (String pair : query.split("&")) {
int idx = pair.indexOf('=');
if (idx < 0) {
params.put(urlDecode(pair), "");
} else {
params.put(urlDecode(pair.substring(0, idx)), urlDecode(pair.substring(idx + 1)));
}
}
return params;
}
private static String urlDecode(String value) {
return URLDecoder.decode(value, StandardCharsets.UTF_8);
}
// Extremely small JSON string-field reader, good enough for the flat
// request bodies this service accepts without pulling in a full JSON
// dependency.
public static String jsonField(String json, String field) {
String needle = "\"" + field + "\"";
int keyIdx = json.indexOf(needle);
if (keyIdx < 0) {
return null;
}
int colon = json.indexOf(':', keyIdx + needle.length());
if (colon < 0) {
return null;
}
int i = colon + 1;
while (i < json.length() && Character.isWhitespace(json.charAt(i))) {
i++;
}
if (i >= json.length()) {
return null;
}
if (json.charAt(i) == '"') {
int end = i + 1;
StringBuilder sb = new StringBuilder();
while (end < json.length() && json.charAt(end) != '"') {
char c = json.charAt(end);
if (c == '\\' && end + 1 < json.length()) {
end++;
c = json.charAt(end);
}
sb.append(c);
end++;
}
return sb.toString();
}
int end = i;
while (end < json.length() && ",}".indexOf(json.charAt(end)) < 0) {
end++;
}
return json.substring(i, end).trim();
}
}
risk-api/src/main/java/com/reachco/risk/Main.java
package com.reachco.risk;
import com.reachco.risk.handlers.HealthHandler;
import com.reachco.risk.handlers.RiskExposureHandler;
import com.reachco.risk.handlers.RiskReviewsHandler;
import com.sun.net.httpserver.HttpServer;
import java.net.InetSocketAddress;
import java.util.List;
import java.util.concurrent.Executors;
public class Main {
public static void main(String[] args) throws Exception {
Database.get();
int port = 4003;
String portEnv = System.getenv("PORT");
if (portEnv != null && !portEnv.isBlank()) {
port = Integer.parseInt(portEnv);
}
HttpServer server = HttpServer.create(new InetSocketAddress(port), 0);
server.setExecutor(Executors.newFixedThreadPool(8));
server.createContext("/healthz", new HealthHandler(false));
server.createContext("/metrics", new HealthHandler(true));
server.createContext("/api/risk/reviews", new RiskReviewsHandler())
.getFilters().addAll(List.of(new AuthFilter(), new AuditFilter()));
server.createContext("/api/risk/exposure", new RiskExposureHandler())
.getFilters().addAll(List.of(new AuthFilter(), new AuditFilter()));
server.start();
System.out.println("risk-api listening on :" + port);
}
}
risk-api/src/main/java/com/reachco/risk/handlers/HealthHandler.java
package com.reachco.risk.handlers;
import com.reachco.risk.Database;
import com.reachco.risk.Http;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import java.io.IOException;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.Statement;
public class HealthHandler implements HttpHandler {
private final boolean metrics;
public HealthHandler(boolean metrics) {
this.metrics = metrics;
}
@Override
public void handle(HttpExchange exchange) throws IOException {
if (!metrics) {
Http.sendJson(exchange, 200, "{\"status\":\"ok\",\"service\":\"risk-api\"}");
return;
}
Connection conn = Database.get();
int reviewCount = 0;
int highRiskCount = 0;
try (Statement st = conn.createStatement();
ResultSet rs = st.executeQuery(
"SELECT COUNT(*) AS total, SUM(CASE WHEN level = 'high' THEN 1 ELSE 0 END) AS high_risk " +
"FROM risk_reviews")) {
if (rs.next()) {
reviewCount = rs.getInt("total");
highRiskCount = rs.getInt("high_risk");
}
} catch (Exception e) {
Http.sendJson(exchange, 500, "{\"error\":\"metrics query failed\"}");
return;
}
Http.sendJson(exchange, 200,
"{\"reviews_total\":" + reviewCount + ",\"reviews_high_risk\":" + highRiskCount + "}");
}
}
risk-api/src/main/java/com/reachco/risk/handlers/RiskExposureHandler.java
package com.reachco.risk.handlers;
import com.reachco.risk.Database;
import com.reachco.risk.Http;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import java.io.IOException;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.time.Duration;
import java.util.Map;
public class RiskExposureHandler implements HttpHandler {
private static final HttpClient HTTP_CLIENT = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(3))
.build();
@Override
public void handle(HttpExchange exchange) throws IOException {
if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) {
Http.sendJson(exchange, 405, "{\"error\":\"method not allowed\"}");
return;
}
int orgId = (Integer) exchange.getAttribute("orgId");
Map<String, String> params = Http.queryParams(exchange);
String beneficiaryName = params.getOrDefault("beneficiaryName", "");
String country = params.getOrDefault("country", "US");
int totalScore = 0;
int reviewCount = 0;
int highRiskCount = 0;
Connection conn = Database.get();
try (PreparedStatement ps = conn.prepareStatement(
"SELECT score, level FROM risk_reviews WHERE org_id = ?")) {
ps.setInt(1, orgId);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
totalScore += rs.getInt("score");
reviewCount++;
if ("high".equals(rs.getString("level"))) {
highRiskCount++;
}
}
}
} catch (Exception e) {
Http.sendJson(exchange, 500, "{\"error\":\"exposure query failed\"}");
return;
}
double avgScore = reviewCount == 0 ? 0.0 : (double) totalScore / reviewCount;
String screeningResult = "unavailable";
if (!beneficiaryName.isBlank()) {
screeningResult = screenBeneficiary(beneficiaryName, country);
}
String json = "{" +
"\"orgId\":" + orgId + "," +
"\"reviewCount\":" + reviewCount + "," +
"\"averageScore\":" + avgScore + "," +
"\"highRiskCount\":" + highRiskCount + "," +
"\"screening\":" + jsonQuote(screeningResult) +
"}";
Http.sendJson(exchange, 200, json);
}
// Calls out to the partner sanctions-screening service for the named
// beneficiary so the exposure report can include an up to date hit
// status alongside our own historical review data.
private String screenBeneficiary(String beneficiaryName, String country) {
try {
String encodedName = URLEncoder.encode(beneficiaryName, StandardCharsets.UTF_8);
String encodedCountry = URLEncoder.encode(country, StandardCharsets.UTF_8);
URI uri = URI.create(
"http://risk-partner.internal.example/api/screen?name=" + encodedName +
"&country=" + encodedCountry);
HttpRequest request = HttpRequest.newBuilder(uri)
.timeout(Duration.ofSeconds(3))
.GET()
.build();
HttpResponse<String> response = HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofString());
return "status=" + response.statusCode();
} catch (Exception e) {
return "error=" + e.getClass().getSimpleName();
}
}
private static String jsonQuote(String value) {
return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\"";
}
}
risk-api/src/main/java/com/reachco/risk/handlers/RiskReviewsHandler.java
package com.reachco.risk.handlers;
import com.reachco.risk.Database;
import com.reachco.risk.Http;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import java.io.IOException;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.Statement;
import java.util.Map;
public class RiskReviewsHandler implements HttpHandler {
@Override
public void handle(HttpExchange exchange) throws IOException {
String method = exchange.getRequestMethod();
if ("GET".equalsIgnoreCase(method)) {
search(exchange);
} else if ("POST".equalsIgnoreCase(method)) {
create(exchange);
} else {
Http.sendJson(exchange, 405, "{\"error\":\"method not allowed\"}");
}
}
// Lets risk analysts slice the review queue by level and free-text
// search across the beneficiary name and notes fields.
private void search(HttpExchange exchange) throws IOException {
int orgId = (Integer) exchange.getAttribute("orgId");
Map<String, String> params = Http.queryParams(exchange);
StringBuilder where = new StringBuilder("org_id = ").append(orgId);
String level = params.get("level");
if (level != null && !level.isBlank()) {
where.append(" AND level = '").append(level).append("'");
}
String minScore = params.get("minScore");
if (minScore != null && !minScore.isBlank()) {
where.append(" AND score >= ").append(minScore);
}
String q = params.get("q");
if (q != null && !q.isBlank()) {
where.append(" AND (beneficiary_name LIKE '%").append(q).append("%'")
.append(" OR notes LIKE '%").append(q).append("%')");
}
String sql = "SELECT id, transfer_ref, beneficiary_name, score, level, notes, reviewer, created_at " +
"FROM risk_reviews WHERE " + where;
Connection conn = Database.get();
StringBuilder json = new StringBuilder("{\"reviews\":[");
try (Statement st = conn.createStatement(); ResultSet rs = st.executeQuery(sql)) {
boolean first = true;
while (rs.next()) {
if (!first) {
json.append(",");
}
first = false;
json.append("{")
.append("\"id\":").append(rs.getInt("id")).append(",")
.append("\"transferRef\":").append(quote(rs.getString("transfer_ref"))).append(",")
.append("\"beneficiaryName\":").append(quote(rs.getString("beneficiary_name"))).append(",")
.append("\"score\":").append(rs.getInt("score")).append(",")
.append("\"level\":").append(quote(rs.getString("level"))).append(",")
.append("\"notes\":").append(quote(rs.getString("notes")))
.append("}");
}
} catch (Exception e) {
Http.sendJson(exchange, 500, "{\"error\":" + quote(e.getMessage()) + "}");
return;
}
json.append("],\"query\":").append(quote(sql)).append("}");
Http.sendJson(exchange, 200, json.toString());
}
private void create(HttpExchange exchange) throws IOException {
int orgId = (Integer) exchange.getAttribute("orgId");
String body = Http.readBody(exchange);
String transferRef = Http.jsonField(body, "transferRef");
String beneficiaryName = Http.jsonField(body, "beneficiaryName");
String scoreStr = Http.jsonField(body, "score");
String level = Http.jsonField(body, "level");
String notes = Http.jsonField(body, "notes");
String reviewer = Http.jsonField(body, "reviewer");
if (transferRef == null || scoreStr == null || level == null) {
Http.sendJson(exchange, 400, "{\"error\":\"transferRef, score and level are required\"}");
return;
}
int score = Integer.parseInt(scoreStr.trim());
Connection conn = Database.get();
try (PreparedStatement ps = conn.prepareStatement(
"INSERT INTO risk_reviews (org_id, transfer_ref, beneficiary_name, score, level, notes, reviewer) " +
"VALUES (?, ?, ?, ?, ?, ?, ?)",
Statement.RETURN_GENERATED_KEYS)) {
ps.setInt(1, orgId);
ps.setString(2, transferRef);
ps.setString(3, beneficiaryName);
ps.setInt(4, score);
ps.setString(5, level);
ps.setString(6, notes);
ps.setString(7, reviewer == null ? "analyst" : reviewer);
ps.executeUpdate();
try (ResultSet keys = ps.getGeneratedKeys()) {
long id = keys.next() ? keys.getLong(1) : -1;
Http.sendJson(exchange, 201, "{\"id\":" + id + ",\"level\":" + quote(level) + "}");
}
} catch (Exception e) {
Http.sendJson(exchange, 500, "{\"error\":" + quote(e.getMessage()) + "}");
}
}
private static String quote(String value) {
if (value == null) {
return "null";
}
return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\"";
}
}