347 lines
12 KiB
TypeScript
347 lines
12 KiB
TypeScript
/**
|
|
* Funds module — budget flows, river visualization, and treasury management.
|
|
*
|
|
* Proxies flow-service API calls and serves the BudgetRiver visualization.
|
|
*/
|
|
|
|
import { Hono } from "hono";
|
|
import * as Automerge from "@automerge/automerge";
|
|
import { renderShell } from "../../server/shell";
|
|
import type { RSpaceModule } from "../../shared/module";
|
|
import { getModuleInfoList } from "../../shared/module";
|
|
import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server";
|
|
import { renderLanding } from "./landing";
|
|
import type { SyncServer } from '../../server/local-first/sync-server';
|
|
import { fundsSchema, fundsDocId, type FundsDoc, type SpaceFlow } from './schemas';
|
|
|
|
let _syncServer: SyncServer | null = null;
|
|
|
|
const FLOW_SERVICE_URL = process.env.FLOW_SERVICE_URL || "http://payment-flow:3010";
|
|
|
|
function ensureDoc(space: string): FundsDoc {
|
|
const docId = fundsDocId(space);
|
|
let doc = _syncServer!.getDoc<FundsDoc>(docId);
|
|
if (!doc) {
|
|
doc = Automerge.change(Automerge.init<FundsDoc>(), 'init', (d) => {
|
|
const init = fundsSchema.init();
|
|
d.meta = init.meta;
|
|
d.meta.spaceSlug = space;
|
|
d.spaceFlows = {};
|
|
});
|
|
_syncServer!.setDoc(docId, doc);
|
|
}
|
|
return doc;
|
|
}
|
|
|
|
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 Automerge doc
|
|
if (space) {
|
|
const doc = ensureDoc(space);
|
|
const flowIds = Object.values(doc.spaceFlows).map((sf) => sf.flowId);
|
|
if (flowIds.length === 0) return c.json([]);
|
|
|
|
const flows = await Promise.all(
|
|
flowIds.map(async (fid) => {
|
|
try {
|
|
const res = await fetch(`${FLOW_SERVICE_URL}/api/flows/${fid}`);
|
|
if (res.ok) return await res.json();
|
|
} catch {}
|
|
return null;
|
|
}),
|
|
);
|
|
return c.json(flows.filter(Boolean));
|
|
}
|
|
|
|
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);
|
|
});
|
|
|
|
// ─── Transak fiat on-ramp ────────────────────────────────
|
|
|
|
routes.get("/api/transak/config", (c) => {
|
|
return c.json({
|
|
apiKey: process.env.TRANSAK_API_KEY || "STAGING_KEY",
|
|
environment: process.env.TRANSAK_ENV || "STAGING",
|
|
});
|
|
});
|
|
|
|
routes.post("/api/transak/webhook", async (c) => {
|
|
const rawBody = await c.req.text();
|
|
|
|
// HMAC verification — if TRANSAK_WEBHOOK_SECRET is set, validate signature
|
|
const webhookSecret = process.env.TRANSAK_WEBHOOK_SECRET;
|
|
if (webhookSecret) {
|
|
const signature = c.req.header("x-transak-signature") || "";
|
|
const { createHmac } = await import("crypto");
|
|
const expected = createHmac("sha256", webhookSecret).update(rawBody).digest("hex");
|
|
if (signature !== expected) {
|
|
console.error("[Transak] Invalid webhook signature");
|
|
return c.json({ error: "Invalid signature" }, 401);
|
|
}
|
|
}
|
|
|
|
const body = JSON.parse(rawBody);
|
|
const { webhookData } = body;
|
|
|
|
// Ack non-completion events (Transak sends multiple status updates)
|
|
if (!webhookData || webhookData.status !== "COMPLETED") {
|
|
return c.json({ ok: true });
|
|
}
|
|
|
|
const { partnerOrderId, cryptoAmount, cryptocurrency, network } = webhookData;
|
|
if (!partnerOrderId || cryptocurrency !== "USDC" || !network?.toLowerCase().includes("base")) {
|
|
return c.json({ error: "Invalid webhook data" }, 400);
|
|
}
|
|
|
|
// partnerOrderId format: "flowId" or "flowId:funnelId"
|
|
const [flowId, funnelId] = partnerOrderId.split(":");
|
|
if (!flowId) return c.json({ error: "Missing flowId in partnerOrderId" }, 400);
|
|
|
|
// Convert crypto amount to USDC units (6 decimals)
|
|
const amountUnits = Math.round(parseFloat(cryptoAmount) * 1e6).toString();
|
|
|
|
const depositUrl = `${FLOW_SERVICE_URL}/api/flows/${flowId}/deposit`;
|
|
const res = await fetch(depositUrl, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
amount: amountUnits,
|
|
source: "transak",
|
|
...(funnelId ? { funnelId } : {}),
|
|
}),
|
|
});
|
|
|
|
if (!res.ok) {
|
|
console.error(`[Transak] Deposit failed: ${await res.text()}`);
|
|
return c.json({ error: "Deposit failed" }, 500);
|
|
}
|
|
|
|
console.log(`[Transak] Deposit OK: flow=${flowId} amount=${amountUnits} USDC`);
|
|
return c.json({ ok: true });
|
|
});
|
|
|
|
// ─── 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);
|
|
|
|
const docId = fundsDocId(space);
|
|
ensureDoc(space);
|
|
_syncServer!.changeDoc<FundsDoc>(docId, 'add space flow', (d) => {
|
|
const key = `${space}:${flowId}`;
|
|
if (!d.spaceFlows[key]) {
|
|
d.spaceFlows[key] = { id: key, spaceSlug: space, flowId, addedBy: claims.sub, createdAt: Date.now() };
|
|
}
|
|
});
|
|
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);
|
|
|
|
const docId = fundsDocId(space);
|
|
const doc = _syncServer!.getDoc<FundsDoc>(docId);
|
|
if (doc) {
|
|
const key = `${space}:${flowId}`;
|
|
if (doc.spaceFlows[key]) {
|
|
_syncServer!.changeDoc<FundsDoc>(docId, 'remove space flow', (d) => {
|
|
delete d.spaceFlows[key];
|
|
});
|
|
}
|
|
}
|
|
return c.json({ ok: true });
|
|
});
|
|
|
|
// ─── Page routes ────────────────────────────────────────
|
|
|
|
const fundsScripts = `
|
|
<script src="https://cdn.jsdelivr.net/npm/lz-string@1.5.0/libs/lz-string.min.js"></script>
|
|
<script type="module" src="/modules/rfunds/folk-funds-app.js"></script>
|
|
<script type="module" src="/modules/rfunds/folk-budget-river.js"></script>`;
|
|
|
|
const fundsStyles = `<link rel="stylesheet" href="/modules/rfunds/funds.css">`;
|
|
|
|
// Landing page
|
|
routes.get("/", (c) => {
|
|
const spaceSlug = c.req.param("space") || "demo";
|
|
return c.html(renderShell({
|
|
title: `${spaceSlug} — Funds | rSpace`,
|
|
moduleId: "rfunds",
|
|
spaceSlug,
|
|
modules: getModuleInfoList(),
|
|
theme: "dark",
|
|
body: `<folk-funds-app space="${spaceSlug}"></folk-funds-app>`,
|
|
scripts: `<script type="module" src="/modules/rfunds/folk-funds-app.js"></script>`,
|
|
styles: `<link rel="stylesheet" href="/modules/rfunds/funds.css">`,
|
|
}));
|
|
});
|
|
|
|
// 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: "rfunds",
|
|
spaceSlug,
|
|
modules: getModuleInfoList(),
|
|
theme: "dark",
|
|
styles: fundsStyles,
|
|
body: `<folk-funds-app space="${spaceSlug}" mode="demo"></folk-funds-app>`,
|
|
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: "rfunds",
|
|
spaceSlug,
|
|
modules: getModuleInfoList(),
|
|
theme: "dark",
|
|
styles: fundsStyles,
|
|
body: `<folk-funds-app space="${spaceSlug}" flow-id="${flowId}"></folk-funds-app>`,
|
|
scripts: fundsScripts,
|
|
}));
|
|
});
|
|
|
|
export const fundsModule: RSpaceModule = {
|
|
id: "rfunds",
|
|
name: "rFunds",
|
|
icon: "🌊",
|
|
description: "Budget flows, river visualization, and treasury management",
|
|
scoping: { defaultScope: 'space', userConfigurable: false },
|
|
docSchemas: [{ pattern: '{space}:funds:flows', description: 'Space flow associations', init: fundsSchema.init }],
|
|
routes,
|
|
landingPage: renderLanding,
|
|
async onInit(ctx) {
|
|
_syncServer = ctx.syncServer;
|
|
},
|
|
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"],
|
|
outputPaths: [
|
|
{ path: "budgets", name: "Budgets", icon: "💰", description: "Budget allocations and funnels" },
|
|
{ path: "flows", name: "Flows", icon: "🌊", description: "Revenue and resource flow visualizations" },
|
|
],
|
|
};
|