Includes: API (Express/TypeScript), Astro site, Python workers, document generators, FCC compliance tools, Canada CRTC formation, Ansible infrastructure, and deployment scripts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
337 lines
12 KiB
TypeScript
337 lines
12 KiB
TypeScript
/**
|
|
* Admin crypto treasury endpoints.
|
|
*
|
|
* All endpoints require the admin token (X-Admin-Token header) — same
|
|
* gate used by admin-filings and reseller-certs. Read-only endpoints
|
|
* return 200; mutating endpoints audit to order_audit_log.
|
|
*/
|
|
|
|
import crypto from "node:crypto";
|
|
import { Router } from "express";
|
|
import type { Request, Response } from "express";
|
|
import { pool } from "../db.js";
|
|
|
|
const router = Router();
|
|
|
|
// ── Auth middleware ─────────────────────────────────────────────────────
|
|
|
|
function requireAdminToken(req: Request, res: Response): boolean {
|
|
const expected = process.env.ADMIN_API_TOKEN || "";
|
|
const supplied = (req.headers["x-admin-token"] || "").toString().trim();
|
|
if (!expected) {
|
|
// If token not configured, reject — fail-closed.
|
|
res.status(503).json({ error: "ADMIN_API_TOKEN not set" });
|
|
return false;
|
|
}
|
|
// Timing-safe compare. timingSafeEqual throws on length mismatch, so
|
|
// guard with a length check first (a cheap length-disclosure trade-off
|
|
// that's negligible compared to the attack surface of naïve ==).
|
|
const sb = Buffer.from(supplied);
|
|
const eb = Buffer.from(expected);
|
|
const ok = sb.length === eb.length && crypto.timingSafeEqual(sb, eb);
|
|
if (!ok) {
|
|
res.status(403).json({ error: "forbidden" });
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
async function auditLog(
|
|
actor: string, action: string, target: string, details?: unknown,
|
|
) {
|
|
// order_audit_log schema requires: order_type IN ('formation','service','quote'),
|
|
// order_id (integer, NOT NULL), action, actor_type IN
|
|
// ('system','admin','worker','customer') + optional order_number,
|
|
// actor_name, metadata. We use order_type='service' (closest match —
|
|
// crypto treasury is an internal service action) and store the real
|
|
// crypto-treasury target (order_number or 'sweep:N') in order_number.
|
|
try {
|
|
await pool.query(
|
|
`INSERT INTO order_audit_log
|
|
(order_type, order_id, order_number, action, actor_type, actor_name, metadata)
|
|
VALUES ('service', 0, $1, $2, 'admin', $3, $4::jsonb)`,
|
|
[target, action, actor, JSON.stringify(details ?? {})],
|
|
);
|
|
} catch (err) {
|
|
console.error("[admin-crypto] audit log failed:", err);
|
|
// non-fatal
|
|
}
|
|
}
|
|
|
|
// ── GET /api/v1/admin/crypto-payments ───────────────────────────────────
|
|
|
|
router.get(
|
|
"/api/v1/admin/crypto-payments",
|
|
async (req: Request, res: Response) => {
|
|
if (!requireAdminToken(req, res)) return;
|
|
|
|
const stateFilter = typeof req.query.state === "string" ? req.query.state : "";
|
|
const params: (string | number)[] = [];
|
|
const where: string[] = [];
|
|
if (stateFilter) {
|
|
where.push(`j.state = $${params.length + 1}`);
|
|
params.push(stateFilter);
|
|
}
|
|
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
|
|
|
|
const r = await pool.query(
|
|
`
|
|
SELECT j.order_id, j.order_type, j.state, j.coin,
|
|
j.amount_coin, j.amount_usd_cents, j.needed_usd_cents,
|
|
j.offramp_provider, j.offramp_ref, j.relay_deposit_id,
|
|
j.target_card_id, j.last_error, j.attempt_count,
|
|
j.next_retry_at, j.received_at, j.funds_at_relay_at, j.settled_at,
|
|
j.created_at, j.updated_at,
|
|
(
|
|
SELECT COALESCE(SUM(amount_usd_cents), 0)
|
|
FROM vendor_obligations
|
|
WHERE order_id = j.order_id
|
|
) AS total_obligations_cents,
|
|
(
|
|
SELECT COUNT(*)
|
|
FROM vendor_obligations
|
|
WHERE order_id = j.order_id AND status = 'paid'
|
|
) AS obligations_paid
|
|
FROM crypto_payment_jobs j
|
|
${whereSql}
|
|
ORDER BY j.created_at DESC
|
|
LIMIT 200
|
|
`,
|
|
params,
|
|
);
|
|
res.json({ jobs: r.rows, count: r.rows.length });
|
|
},
|
|
);
|
|
|
|
// NOTE: specific paths (sweeps, tax-export) are registered BELOW before
|
|
// the /:order_id param route to avoid Express pattern-match conflicts
|
|
// (order_id='sweeps' would otherwise match this handler).
|
|
|
|
// ── GET /api/v1/admin/crypto-payments/:order_id ────────────────────────
|
|
// Registered AFTER the specific sub-paths below. Express matches in
|
|
// registration order; putting this last ensures /sweeps and
|
|
// /tax-export aren't interpreted as order_ids.
|
|
|
|
// ── Detail view (must come AFTER specific sub-paths below) ────────────
|
|
//
|
|
// We re-register this at the end of the file so Express pattern-matches
|
|
// /sweeps and /tax-export first.
|
|
|
|
// ── POST /api/v1/admin/crypto-payments/:order_id/retry-offramp ─────────
|
|
|
|
router.post(
|
|
"/api/v1/admin/crypto-payments/:order_id/retry-offramp",
|
|
async (req: Request, res: Response) => {
|
|
if (!requireAdminToken(req, res)) return;
|
|
const orderId = req.params.order_id;
|
|
|
|
const r = await pool.query(
|
|
`UPDATE crypto_payment_jobs
|
|
SET state = 'sizing',
|
|
last_error = NULL,
|
|
next_retry_at = NULL,
|
|
updated_at = NOW()
|
|
WHERE order_id = $1
|
|
AND state IN ('manual','failed','offramping')
|
|
RETURNING state, attempt_count`,
|
|
[orderId],
|
|
);
|
|
if (r.rows.length === 0) {
|
|
res.status(409).json({
|
|
error: "job not in a retry-able state (must be manual / failed / offramping)",
|
|
});
|
|
return;
|
|
}
|
|
await auditLog(
|
|
req.headers["x-admin-user"]?.toString() || "admin",
|
|
"crypto_retry_offramp", orderId, { new_state: "sizing" },
|
|
);
|
|
res.json({ ok: true, state: r.rows[0].state });
|
|
},
|
|
);
|
|
|
|
// ── POST /api/v1/admin/crypto-payments/:order_id/mark-settled ──────────
|
|
|
|
router.post(
|
|
"/api/v1/admin/crypto-payments/:order_id/mark-settled",
|
|
async (req: Request, res: Response) => {
|
|
if (!requireAdminToken(req, res)) return;
|
|
const orderId = req.params.order_id;
|
|
const { note } = req.body ?? {};
|
|
|
|
const r = await pool.query(
|
|
`UPDATE crypto_payment_jobs
|
|
SET state = 'settled',
|
|
settled_at = NOW(),
|
|
last_error = NULL,
|
|
updated_at = NOW()
|
|
WHERE order_id = $1
|
|
RETURNING state`,
|
|
[orderId],
|
|
);
|
|
if (r.rows.length === 0) {
|
|
res.status(404).json({ error: "job not found" }); return;
|
|
}
|
|
await auditLog(
|
|
req.headers["x-admin-user"]?.toString() || "admin",
|
|
"crypto_manual_settle", orderId, { note },
|
|
);
|
|
res.json({ ok: true });
|
|
},
|
|
);
|
|
|
|
// ── GET /api/v1/admin/crypto-payments/sweeps ───────────────────────────
|
|
|
|
router.get(
|
|
"/api/v1/admin/crypto-payments/sweeps",
|
|
async (req: Request, res: Response) => {
|
|
if (!requireAdminToken(req, res)) return;
|
|
const r = await pool.query(
|
|
`SELECT * FROM cold_wallet_sweeps
|
|
WHERE status IN ('pending','approved','broadcast')
|
|
ORDER BY created_at DESC
|
|
LIMIT 100`,
|
|
);
|
|
res.json({ sweeps: r.rows });
|
|
},
|
|
);
|
|
|
|
// ── POST /api/v1/admin/crypto-payments/sweeps/:id/approve ──────────────
|
|
|
|
router.post(
|
|
"/api/v1/admin/crypto-payments/sweeps/:id/approve",
|
|
async (req: Request, res: Response) => {
|
|
if (!requireAdminToken(req, res)) return;
|
|
const sweepId = Number(req.params.id);
|
|
if (!Number.isFinite(sweepId)) {
|
|
res.status(400).json({ error: "bad sweep id" }); return;
|
|
}
|
|
const actor = req.headers["x-admin-user"]?.toString() || "admin";
|
|
const r = await pool.query(
|
|
`UPDATE cold_wallet_sweeps
|
|
SET status = 'approved',
|
|
approved_by = $2,
|
|
approved_at = NOW(),
|
|
updated_at = NOW()
|
|
WHERE id = $1
|
|
AND status = 'pending'
|
|
RETURNING coin, amount_coin`,
|
|
[sweepId, actor],
|
|
);
|
|
if (r.rows.length === 0) {
|
|
res.status(409).json({ error: "sweep not in pending state" }); return;
|
|
}
|
|
await auditLog(actor, "crypto_sweep_approve", `sweep:${sweepId}`,
|
|
{ coin: r.rows[0].coin, amount_coin: r.rows[0].amount_coin });
|
|
res.json({ ok: true });
|
|
},
|
|
);
|
|
|
|
// ── GET /api/v1/admin/crypto-payments/tax-export?year=YYYY ─────────────
|
|
//
|
|
// IRS Form 8949 columns: description, date_acquired, date_sold,
|
|
// proceeds, cost_basis, gain_loss. Covers all offramp/disposal rows
|
|
// whose disposed_at is in the given tax year.
|
|
router.get(
|
|
"/api/v1/admin/crypto-payments/tax-export",
|
|
async (req: Request, res: Response) => {
|
|
if (!requireAdminToken(req, res)) return;
|
|
const year = Number(req.query.year) || new Date().getUTCFullYear() - 1;
|
|
|
|
const r = await pool.query(
|
|
`
|
|
SELECT order_id, coin,
|
|
amount_coin, fx_rate_usd,
|
|
acquired_at, disposed_at,
|
|
basis_usd_cents, proceeds_usd_cents,
|
|
provider, provider_ref
|
|
FROM crypto_payment_ledger
|
|
WHERE movement_type = 'offramp'
|
|
AND state IN ('pending','confirmed')
|
|
AND disposed_at >= make_timestamptz($1, 1, 1, 0, 0, 0, 'UTC')
|
|
AND disposed_at < make_timestamptz($1 + 1, 1, 1, 0, 0, 0, 'UTC')
|
|
ORDER BY disposed_at ASC, id ASC
|
|
`,
|
|
[year],
|
|
);
|
|
|
|
// Build the CSV
|
|
const header = [
|
|
"Description", // e.g., "0.00873 BTC — Order CO-SMOKE02"
|
|
"Date Acquired", // MM/DD/YYYY
|
|
"Date Sold",
|
|
"Proceeds (USD)",
|
|
"Cost Basis (USD)",
|
|
"Gain/(Loss) (USD)",
|
|
"Provider Reference",
|
|
];
|
|
const lines: string[] = [header.join(",")];
|
|
const fmt = (d: Date) =>
|
|
`${String(d.getUTCMonth() + 1).padStart(2, "0")}/${String(d.getUTCDate()).padStart(2, "0")}/${d.getUTCFullYear()}`;
|
|
for (const row of r.rows) {
|
|
const amount = Number(row.amount_coin);
|
|
const proceeds = Number(row.proceeds_usd_cents || 0) / 100;
|
|
const basis = Number(row.basis_usd_cents || 0) / 100;
|
|
const gain = proceeds - basis;
|
|
const acquired = row.acquired_at ? fmt(new Date(row.acquired_at)) : "";
|
|
const sold = row.disposed_at ? fmt(new Date(row.disposed_at)) : "";
|
|
const desc = `${Math.abs(amount).toFixed(8)} ${row.coin} — Order ${row.order_id}`;
|
|
lines.push([
|
|
`"${desc}"`,
|
|
`"${acquired}"`,
|
|
`"${sold}"`,
|
|
proceeds.toFixed(2),
|
|
basis.toFixed(2),
|
|
gain.toFixed(2),
|
|
`"${row.provider_ref || ""}"`,
|
|
].join(","));
|
|
}
|
|
res.setHeader("Content-Type", "text/csv");
|
|
res.setHeader(
|
|
"Content-Disposition",
|
|
`attachment; filename="crypto-disposals-${year}.csv"`,
|
|
);
|
|
res.send(lines.join("\n"));
|
|
},
|
|
);
|
|
|
|
// ── GET /api/v1/admin/crypto-payments/:order_id (must be LAST) ─────────
|
|
// Registered last so specific paths above match first.
|
|
router.get(
|
|
"/api/v1/admin/crypto-payments/:order_id",
|
|
async (req: Request, res: Response) => {
|
|
if (!requireAdminToken(req, res)) return;
|
|
const orderId = req.params.order_id;
|
|
|
|
const job = await pool.query(
|
|
"SELECT * FROM crypto_payment_jobs WHERE order_id = $1",
|
|
[orderId],
|
|
);
|
|
if (job.rows.length === 0) {
|
|
res.status(404).json({ error: "job not found" }); return;
|
|
}
|
|
|
|
const [ledger, obligations, deposit] = await Promise.all([
|
|
pool.query(
|
|
`SELECT * FROM crypto_payment_ledger WHERE order_id = $1 ORDER BY created_at ASC`,
|
|
[orderId],
|
|
),
|
|
pool.query(
|
|
`SELECT * FROM vendor_obligations WHERE order_id = $1 ORDER BY obligation_kind, id`,
|
|
[orderId],
|
|
),
|
|
job.rows[0].relay_deposit_id
|
|
? pool.query("SELECT * FROM relay_deposits WHERE id = $1", [job.rows[0].relay_deposit_id])
|
|
: Promise.resolve({ rows: [] }),
|
|
]);
|
|
|
|
res.json({
|
|
job: job.rows[0],
|
|
ledger: ledger.rows,
|
|
obligations: obligations.rows,
|
|
relay_deposit: deposit.rows[0] || null,
|
|
});
|
|
},
|
|
);
|
|
|
|
export default router;
|