/** * Funds module — budget flows, river visualization, and treasury management. * * Proxies flow-service API calls and serves the BudgetRiver visualization. */ import { Hono } from "hono"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { sql } from "../../shared/db/pool"; import { renderShell } from "../../server/shell"; import type { RSpaceModule } from "../../shared/module"; import { getModuleInfoList } from "../../shared/module"; import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server"; const FLOW_SERVICE_URL = process.env.FLOW_SERVICE_URL || "http://payment-flow:3010"; // ── DB initialization ── const SCHEMA_SQL = readFileSync(resolve(import.meta.dir, "db/schema.sql"), "utf-8"); async function initDB() { try { await sql.unsafe(SCHEMA_SQL); console.log("[Funds] DB schema initialized"); } catch (e) { console.error("[Funds] DB init error:", e); } } initDB(); const routes = new Hono(); // ─── Flow Service API proxy ───────────────────────────── // These proxy to the payment-flow backend so the frontend // can call them from the same origin. routes.get("/api/flows", async (c) => { const owner = c.req.header("X-Owner-Address") || ""; const space = c.req.query("space") || ""; // If space filter provided, get flow IDs from space_flows table if (space) { try { const rows = await sql.unsafe( "SELECT flow_id FROM rfunds.space_flows WHERE space_slug = $1", [space], ); if (rows.length === 0) return c.json([]); // Fetch each flow from flow-service const flows = await Promise.all( rows.map(async (r: any) => { try { const res = await fetch(`${FLOW_SERVICE_URL}/api/flows/${r.flow_id}`); if (res.ok) return await res.json(); } catch {} return null; }), ); return c.json(flows.filter(Boolean)); } catch { // Fall through to unfiltered fetch } } const res = await fetch(`${FLOW_SERVICE_URL}/api/flows?owner=${encodeURIComponent(owner)}`); return c.json(await res.json(), res.status as any); }); routes.get("/api/flows/:flowId", async (c) => { const res = await fetch(`${FLOW_SERVICE_URL}/api/flows/${c.req.param("flowId")}`); return c.json(await res.json(), res.status as any); }); routes.post("/api/flows", async (c) => { // Auth-gated: require EncryptID token const token = extractToken(c.req.raw.headers); if (!token) return c.json({ error: "Authentication required" }, 401); let claims; try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } const body = await c.req.text(); const res = await fetch(`${FLOW_SERVICE_URL}/api/flows`, { method: "POST", headers: { "Content-Type": "application/json", "X-Owner-Address": claims.sub, }, body, }); return c.json(await res.json(), res.status as any); }); routes.post("/api/flows/:flowId/deposit", async (c) => { const body = await c.req.text(); const res = await fetch(`${FLOW_SERVICE_URL}/api/flows/${c.req.param("flowId")}/deposit`, { method: "POST", headers: { "Content-Type": "application/json" }, body, }); return c.json(await res.json(), res.status as any); }); routes.post("/api/flows/:flowId/withdraw", async (c) => { const body = await c.req.text(); const res = await fetch(`${FLOW_SERVICE_URL}/api/flows/${c.req.param("flowId")}/withdraw`, { method: "POST", headers: { "Content-Type": "application/json" }, body, }); return c.json(await res.json(), res.status as any); }); routes.post("/api/flows/:flowId/activate", async (c) => { const res = await fetch(`${FLOW_SERVICE_URL}/api/flows/${c.req.param("flowId")}/activate`, { method: "POST" }); return c.json(await res.json(), res.status as any); }); routes.post("/api/flows/:flowId/pause", async (c) => { const res = await fetch(`${FLOW_SERVICE_URL}/api/flows/${c.req.param("flowId")}/pause`, { method: "POST" }); return c.json(await res.json(), res.status as any); }); routes.post("/api/flows/:flowId/funnels", async (c) => { const body = await c.req.text(); const res = await fetch(`${FLOW_SERVICE_URL}/api/flows/${c.req.param("flowId")}/funnels`, { method: "POST", headers: { "Content-Type": "application/json" }, body, }); return c.json(await res.json(), res.status as any); }); routes.post("/api/flows/:flowId/outcomes", async (c) => { const body = await c.req.text(); const res = await fetch(`${FLOW_SERVICE_URL}/api/flows/${c.req.param("flowId")}/outcomes`, { method: "POST", headers: { "Content-Type": "application/json" }, body, }); return c.json(await res.json(), res.status as any); }); routes.get("/api/flows/:flowId/transactions", async (c) => { const res = await fetch(`${FLOW_SERVICE_URL}/api/flows/${c.req.param("flowId")}/transactions`); return c.json(await res.json(), res.status as any); }); // ─── Space-flow association endpoints ──────────────────── routes.post("/api/space-flows", async (c) => { const token = extractToken(c.req.raw.headers); if (!token) return c.json({ error: "Authentication required" }, 401); let claims; try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } const { space, flowId } = await c.req.json(); if (!space || !flowId) return c.json({ error: "space and flowId required" }, 400); await sql.unsafe( `INSERT INTO rfunds.space_flows (space_slug, flow_id, added_by) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`, [space, flowId, claims.sub], ); return c.json({ ok: true }); }); routes.delete("/api/space-flows/:flowId", async (c) => { const token = extractToken(c.req.raw.headers); if (!token) return c.json({ error: "Authentication required" }, 401); try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } const flowId = c.req.param("flowId"); const space = c.req.query("space") || ""; if (!space) return c.json({ error: "space query param required" }, 400); await sql.unsafe( "DELETE FROM rfunds.space_flows WHERE space_slug = $1 AND flow_id = $2", [space, flowId], ); return c.json({ ok: true }); }); // ─── Page routes ──────────────────────────────────────── const fundsScripts = ` `; const fundsStyles = ``; // Landing page — TBFF info + flow list routes.get("/", (c) => { const spaceSlug = c.req.param("space") || "demo"; return c.html(renderShell({ title: `rFunds — TBFF Flow Funding | rSpace`, moduleId: "funds", spaceSlug, modules: getModuleInfoList(), theme: "dark", styles: fundsStyles, body: ``, scripts: fundsScripts, })); }); // Demo mode — hardcoded demo data, no API needed routes.get("/demo", (c) => { const spaceSlug = c.req.param("space") || "demo"; return c.html(renderShell({ title: `TBFF Demo — rFunds | rSpace`, moduleId: "funds", spaceSlug, modules: getModuleInfoList(), theme: "dark", styles: fundsStyles, body: ``, scripts: fundsScripts, })); }); // Flow detail — specific flow from API routes.get("/flow/:flowId", (c) => { const spaceSlug = c.req.param("space") || "demo"; const flowId = c.req.param("flowId"); return c.html(renderShell({ title: `Flow — rFunds | rSpace`, moduleId: "funds", spaceSlug, modules: getModuleInfoList(), theme: "dark", styles: fundsStyles, body: ``, scripts: fundsScripts, })); }); export const fundsModule: RSpaceModule = { id: "funds", name: "rFunds", icon: "\uD83C\uDF0A", description: "Budget flows, river visualization, and treasury management", routes, standaloneDomain: "rfunds.online", feeds: [ { id: "treasury-flows", name: "Treasury Flows", kind: "economic", description: "Budget flow states, deposits, withdrawals, and funnel allocations", filterable: true, }, { id: "transactions", name: "Transaction Stream", kind: "economic", description: "Real-time deposit and withdrawal events", }, ], acceptsFeeds: ["governance", "data"], };