rspace-online/modules/rflows/mod.ts

760 lines
26 KiB
TypeScript

/**
* Flows module — budget flows, river visualization, and treasury management.
*
* Proxies flow-service API calls and serves the FlowRiver 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 { getTransakEnv, getTransakWebhookSecret } from "../../shared/transak";
import type { SyncServer } from '../../server/local-first/sync-server';
import { flowsSchema, flowsDocId, type FlowsDoc, type SpaceFlow, type CanvasFlow } from './schemas';
import { demoNodes } from './lib/presets';
import { OpenfortProvider } from './lib/openfort';
import { boardDocId, createTaskItem } from '../rtasks/schemas';
import type { BoardDoc } from '../rtasks/schemas';
import type { OutcomeNodeData } from './lib/types';
import { getAvailableProviders, getProvider, getDefaultProvider } from './lib/onramp-registry';
import type { OnrampProviderId } from './lib/onramp-provider';
import { PimlicoClient } from './lib/pimlico';
let _syncServer: SyncServer | null = null;
let _openfort: OpenfortProvider | null = null;
let _pimlico: PimlicoClient | null = null;
const _completedOutcomes = new Set<string>(); // space:outcomeId — dedup for watcher
const FLOW_SERVICE_URL = process.env.FLOW_SERVICE_URL || "http://payment-flow:3010";
function ensureDoc(space: string): FlowsDoc {
const docId = flowsDocId(space);
let doc = _syncServer!.getDoc<FlowsDoc>(docId);
if (!doc) {
doc = Automerge.change(Automerge.init<FlowsDoc>(), 'init', (d) => {
const init = flowsSchema.init();
d.meta = init.meta;
d.meta.spaceSlug = space;
d.spaceFlows = {};
d.canvasFlows = {} as any;
d.activeFlowId = '';
});
_syncServer!.setDoc(docId, doc);
}
// Migrate v1 → v2: add canvasFlows and activeFlowId
if (!doc.canvasFlows || doc.meta.version < 2) {
_syncServer!.changeDoc<FlowsDoc>(docId, 'migrate to v2', (d) => {
if (!d.canvasFlows) d.canvasFlows = {} as any;
if (!d.activeFlowId) d.activeFlowId = '' as any;
d.meta.version = 2;
});
doc = _syncServer!.getDoc<FlowsDoc>(docId)!;
}
return doc;
}
/**
* Create a DONE task in rTasks when an rFlows outcome is completed.
* Deduplicates by checking for existing `ref:rflows:outcome:{id}` in the board doc.
*/
function createTaskForOutcome(space: string, outcomeId: string, label: string) {
if (!_syncServer) return;
const boardId = `${space}-bcrg`;
const docId = boardDocId(space, boardId);
// Ensure the board doc exists
let doc = _syncServer.getDoc<BoardDoc>(docId);
if (!doc) return; // BCRG board not seeded yet
// Check for duplicate — look for ref:rflows:outcome:{outcomeId}
const refTag = `ref:rflows:outcome:${outcomeId}`;
for (const t of Object.values(doc.tasks)) {
if (t.description?.includes(refTag)) return; // already exists
}
const taskId = crypto.randomUUID();
_syncServer.changeDoc<BoardDoc>(docId, `Auto-create task for outcome ${outcomeId}`, (d) => {
d.tasks[taskId] = createTaskItem(taskId, space, label, {
status: 'DONE',
priority: 'MEDIUM',
description: `${refTag} — Auto-created from rFlows outcome completion`,
labels: ['rflows', 'bcrg'],
createdBy: 'did:system:rflows-watcher',
});
});
console.log(`[rflows] Auto-created DONE task for outcome "${outcomeId}" in space "${space}"`);
}
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);
});
// ─── User on-ramp (email → wallet → widget) ─────────────
routes.post("/api/flows/user-onramp", async (c) => {
try {
const { email, fiatAmount, fiatCurrency, returnUrl, provider: reqProvider } = await c.req.json();
if (!email || !fiatAmount || !fiatCurrency) {
return c.json({ error: "email, fiatAmount, and fiatCurrency are required" }, 400);
}
if (!_openfort) return c.json({ error: "Openfort not configured" }, 503);
// Resolve on-ramp provider: use requested, else first available
const onramp = reqProvider
? getProvider(reqProvider as OnrampProviderId)
: getDefaultProvider();
if (!onramp) return c.json({ error: "No on-ramp provider available" }, 503);
// 1. Find or create Openfort smart wallet for this user (one wallet per email)
const wallet = await _openfort.findOrCreateWallet(email, {
type: 'user-onramp',
email,
});
const sessionId = crypto.randomUUID();
// 2. Create on-ramp session via provider
const { widgetUrl, provider } = await onramp.createSession({
walletAddress: wallet.address,
email,
fiatAmount,
fiatCurrency,
sessionId,
returnUrl,
hostname: new URL(c.req.url).hostname,
});
console.log(`[rflows] On-ramp session created: provider=${provider} session=${sessionId} wallet=${wallet.address}`);
// Non-fatal side-effect: create fund claim → sends email via EncryptID
const encryptidServiceKey = process.env.ENCRYPTID_SERVICE_KEY;
if (encryptidServiceKey) {
const encryptidUrl = process.env.ENCRYPTID_URL || 'https://auth.rspace.online';
try {
await fetch(`${encryptidUrl}/api/internal/fund-claims`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Service-Key': encryptidServiceKey,
},
body: JSON.stringify({
email,
walletAddress: wallet.address,
openfortPlayerId: wallet.playerId,
fiatAmount: String(fiatAmount),
fiatCurrency,
sessionId,
provider,
}),
});
console.log(`[rflows] Fund claim created for ${email}`);
} catch (err) {
console.error('[rflows] Failed to create fund claim:', err);
}
}
return c.json({
success: true,
sessionId,
widgetUrl,
walletAddress: wallet.address,
provider,
isNewUser: true,
});
} catch (err) {
console.error("[rflows] user-onramp failed:", err);
let message: string;
if (err instanceof Error) message = err.message;
else if (err && typeof err === 'object') message = JSON.stringify(err);
else message = String(err);
return c.json({ error: message }, 500);
}
});
// ─── On-ramp config ──────────────────────────────────────
routes.get("/api/onramp/config", (c) => {
const available = getAvailableProviders();
return c.json({
provider: available[0]?.id || null,
available,
});
});
// Legacy endpoint — keep for backwards compat
routes.get("/api/transak/config", (c) => {
return c.json({
provider: "transak",
environment: getTransakEnv(),
});
});
routes.post("/api/transak/webhook", async (c) => {
let body: any;
try { body = await c.req.json(); } catch { return c.json({ error: "Invalid JSON" }, 400); }
// HMAC verification — if webhook secret is set, validate signature
const webhookSecret = getTransakWebhookSecret();
if (webhookSecret) {
const signature = c.req.header("x-transak-signature") || "";
const { createHmac } = await import("crypto");
// Re-serialize for HMAC (Transak signs the raw JSON body)
const expected = createHmac("sha256", webhookSecret).update(JSON.stringify(body)).digest("hex");
if (signature !== expected) {
console.error("[Transak] Invalid webhook signature");
return c.json({ error: "Invalid signature" }, 401);
}
}
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:funnelId" or "flowId" (uses env default)
const [flowId, funnelId] = partnerOrderId.split(":");
if (!flowId) return c.json({ error: "Missing flowId in partnerOrderId" }, 400);
const resolvedFunnelId = funnelId || process.env.FUNNEL_ID || "";
if (!resolvedFunnelId) return c.json({ error: "Missing funnelId" }, 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: "card",
funnelId: resolvedFunnelId,
}),
});
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 });
});
// ─── ERC-4337 UserOperation routes (Pimlico bundler) ─────
const ENTRY_POINT = '0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789'; // v0.6
routes.post("/api/flows/submit-userop", 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); }
if (!_pimlico) return c.json({ error: "Pimlico bundler not configured" }, 503);
const userOp = await c.req.json();
try {
const prepared = await _pimlico.prepareUserOperation(userOp, ENTRY_POINT);
return c.json({ success: true, userOp: prepared, entryPoint: ENTRY_POINT });
} catch (err) {
console.error("[pimlico] prepare failed:", err);
const msg = err instanceof Error ? err.message : String(err);
return c.json({ error: msg }, 500);
}
});
routes.post("/api/flows/send-userop", 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); }
if (!_pimlico) return c.json({ error: "Pimlico bundler not configured" }, 503);
const { userOp } = await c.req.json();
if (!userOp) return c.json({ error: "userOp required" }, 400);
try {
const hash = await _pimlico.sendUserOperation(userOp, ENTRY_POINT);
return c.json({ success: true, userOpHash: hash });
} catch (err) {
console.error("[pimlico] send failed:", err);
const msg = err instanceof Error ? err.message : String(err);
return c.json({ error: msg }, 500);
}
});
routes.get("/api/flows/userop/:hash", 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); }
if (!_pimlico) return c.json({ error: "Pimlico bundler not configured" }, 503);
const hash = c.req.param("hash");
try {
const receipt = await _pimlico.getUserOperationReceipt(hash);
return c.json({ receipt });
} catch (err) {
console.error("[pimlico] receipt failed:", err);
const msg = err instanceof Error ? err.message : String(err);
return c.json({ error: msg }, 500);
}
});
// ─── Coinbase webhook ────────────────────────────────────
routes.post("/api/coinbase/webhook", async (c) => {
let body: any;
try { body = await c.req.json(); } catch { return c.json({ error: "Invalid JSON" }, 400); }
// HMAC verification
const webhookSecret = process.env.COINBASE_WEBHOOK_SECRET;
if (webhookSecret) {
const signature = c.req.header("x-cc-webhook-signature") || "";
const { createHmac } = await import("crypto");
const expected = createHmac("sha256", webhookSecret).update(JSON.stringify(body)).digest("hex");
if (signature !== expected) {
console.error("[Coinbase] Invalid webhook signature");
return c.json({ error: "Invalid signature" }, 401);
}
}
const { event } = body;
if (!event || event.type !== "charge:confirmed") return c.json({ ok: true });
const metadata = event.data?.metadata || {};
const { flowId, funnelId } = metadata;
const pricing = event.data?.pricing?.local;
if (!flowId || !pricing) return c.json({ error: "Missing flowId or pricing" }, 400);
const resolvedFunnelId = funnelId || process.env.FUNNEL_ID || "";
if (!resolvedFunnelId) return c.json({ error: "Missing funnelId" }, 400);
const amountUnits = Math.round(parseFloat(pricing.amount) * 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: "card", funnelId: resolvedFunnelId }),
});
if (!res.ok) {
console.error(`[Coinbase] Deposit failed: ${await res.text()}`);
return c.json({ error: "Deposit failed" }, 500);
}
console.log(`[Coinbase] Deposit OK: flow=${flowId} amount=${amountUnits} USDC`);
return c.json({ ok: true });
});
// ─── Ramp Network webhook ────────────────────────────────
routes.post("/api/ramp/webhook", async (c) => {
let body: any;
try { body = await c.req.json(); } catch { return c.json({ error: "Invalid JSON" }, 400); }
// HMAC verification
const webhookSecret = process.env.RAMP_WEBHOOK_SECRET;
if (webhookSecret) {
const signature = c.req.header("x-body-signature") || "";
const { createHmac } = await import("crypto");
const expected = createHmac("sha256", webhookSecret).update(JSON.stringify(body)).digest("hex");
if (signature !== expected) {
console.error("[Ramp] Invalid webhook signature");
return c.json({ error: "Invalid signature" }, 401);
}
}
if (body.type !== "RELEASED" || body.asset?.symbol !== "USDC") return c.json({ ok: true });
const purchaseViewToken = body.purchaseViewToken || "";
// Ramp uses receiverAddress metadata or custom purchase field for flowId
const flowId = body.metadata?.flowId || body.flowId;
const funnelId = body.metadata?.funnelId || body.funnelId;
if (!flowId) return c.json({ error: "Missing flowId" }, 400);
const resolvedFunnelId = funnelId || process.env.FUNNEL_ID || "";
if (!resolvedFunnelId) return c.json({ error: "Missing funnelId" }, 400);
const amountUnits = Math.round(parseFloat(body.cryptoAmount || "0") * 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: "card", funnelId: resolvedFunnelId }),
});
if (!res.ok) {
console.error(`[Ramp] Deposit failed: ${await res.text()}`);
return c.json({ error: "Deposit failed" }, 500);
}
console.log(`[Ramp] 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 = flowsDocId(space);
ensureDoc(space);
_syncServer!.changeDoc<FlowsDoc>(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 = flowsDocId(space);
const doc = _syncServer!.getDoc<FlowsDoc>(docId);
if (doc) {
const key = `${space}:${flowId}`;
if (doc.spaceFlows[key]) {
_syncServer!.changeDoc<FlowsDoc>(docId, 'remove space flow', (d) => {
delete d.spaceFlows[key];
});
}
}
return c.json({ ok: true });
});
// ─── Page routes ────────────────────────────────────────
const flowsScripts = `
<script src="https://cdn.jsdelivr.net/npm/lz-string@1.5.0/libs/lz-string.min.js"></script>
<script type="module" src="/modules/rflows/folk-flows-app.js"></script>
<script type="module" src="/modules/rflows/folk-flow-river.js"></script>`;
const flowsStyles = `<link rel="stylesheet" href="/modules/rflows/flows.css">`;
// Landing page (also serves demo via centralized /demo → space="demo" rewrite)
routes.get("/", (c) => {
const spaceSlug = c.req.param("space") || "demo";
return c.html(renderShell({
title: `${spaceSlug} — Flows | rSpace`,
moduleId: "rflows",
spaceSlug,
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-flows-app space="${spaceSlug}"${spaceSlug === "demo" ? ' mode="demo"' : ''}></folk-flows-app>`,
scripts: flowsScripts,
styles: flowsStyles,
}));
});
// 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 — rFlows | rSpace`,
moduleId: "rflows",
spaceSlug,
modules: getModuleInfoList(),
theme: "dark",
styles: flowsStyles,
body: `<folk-flows-app space="${spaceSlug}" flow-id="${flowId}"></folk-flows-app>`,
scripts: flowsScripts,
}));
});
// ── Seed template data ──
function seedTemplateFlows(space: string) {
if (!_syncServer) return;
const doc = ensureDoc(space);
// Seed SpaceFlow association if empty
if (Object.keys(doc.spaceFlows).length === 0) {
const docId = flowsDocId(space);
const now = Date.now();
const flowId = crypto.randomUUID();
_syncServer.changeDoc<FlowsDoc>(docId, 'seed template flow', (d) => {
d.spaceFlows[flowId] = {
id: flowId, spaceSlug: space, flowId: 'demo',
addedBy: 'did:demo:seed', createdAt: now,
};
});
}
// Seed a canvas flow with demoNodes if none exist
if (Object.keys(doc.canvasFlows || {}).length === 0) {
const docId = flowsDocId(space);
const now = Date.now();
const canvasFlowId = crypto.randomUUID();
const seedFlow: CanvasFlow = {
id: canvasFlowId,
name: 'BCRG Community Flow',
nodes: demoNodes.map((n) => ({ ...n, data: { ...n.data } })),
createdAt: now,
updatedAt: now,
createdBy: 'did:demo:seed',
};
_syncServer.changeDoc<FlowsDoc>(docId, 'seed canvas flow', (d) => {
d.canvasFlows[canvasFlowId] = seedFlow as any;
d.activeFlowId = canvasFlowId as any;
});
console.log(`[Flows] Template seeded for "${space}": 1 canvas flow + association`);
}
}
export const flowsModule: RSpaceModule = {
id: "rflows",
name: "rFlows",
icon: "🌊",
description: "Budget flows, river visualization, and treasury management",
publicWrite: true,
scoping: { defaultScope: 'space', userConfigurable: false },
docSchemas: [{ pattern: '{space}:flows:data', description: 'Space flow associations', init: flowsSchema.init }],
routes,
landingPage: renderLanding,
seedTemplate: seedTemplateFlows,
async onInit(ctx) {
_syncServer = ctx.syncServer;
if (process.env.OPENFORT_API_KEY && process.env.OPENFORT_PUBLISHABLE_KEY) {
_openfort = new OpenfortProvider({
apiKey: process.env.OPENFORT_API_KEY,
publishableKey: process.env.OPENFORT_PUBLISHABLE_KEY,
chainId: 8453, // Base mainnet — hardcoded to avoid testnet misconfiguration
});
console.log('[rflows] Openfort provider initialized');
}
if (process.env.PIMLICO_API_KEY) {
_pimlico = new PimlicoClient({
apiKey: process.env.PIMLICO_API_KEY,
chainId: 8453, // Base mainnet
});
console.log('[rflows] Pimlico bundler initialized');
}
// Log available on-ramp providers
const onrampProviders = getAvailableProviders();
console.log(`[rflows] On-ramp providers: ${onrampProviders.map((p) => p.id).join(', ') || 'none'}`)
// Watch for completed outcomes in flow docs → auto-create DONE tasks
_syncServer.registerWatcher(':flows:data', (docId, doc) => {
try {
const flowsDoc = doc as FlowsDoc;
if (!flowsDoc.canvasFlows) return;
// Extract space slug from docId (format: {space}:flows:data)
const space = docId.split(':flows:data')[0];
if (!space) return;
for (const flow of Object.values(flowsDoc.canvasFlows)) {
if (!flow.nodes) continue;
for (const node of flow.nodes) {
if (node.type !== 'outcome') continue;
const data = node.data as OutcomeNodeData;
if (data.status !== 'completed') continue;
const key = `${space}:${node.id}`;
if (_completedOutcomes.has(key)) continue;
_completedOutcomes.add(key);
createTaskForOutcome(space, node.id, data.label);
}
}
} catch {}
});
// Pre-populate _completedOutcomes from existing docs to avoid duplicates on restart
for (const id of _syncServer.getDocIds()) {
if (!id.includes(':flows:data')) continue;
const doc = _syncServer.getDoc<FlowsDoc>(id);
if (!doc?.canvasFlows) continue;
const space = id.split(':flows:data')[0];
for (const flow of Object.values(doc.canvasFlows)) {
if (!flow.nodes) continue;
for (const node of flow.nodes) {
if (node.type === 'outcome' && (node.data as OutcomeNodeData).status === 'completed') {
_completedOutcomes.add(`${space}:${node.id}`);
}
}
}
}
},
standaloneDomain: "rflows.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" },
],
subPageInfos: [
{
path: "flow",
title: "Flow Viewer",
icon: "🌊",
tagline: "rFlows Tool",
description: "Visualize a single budget flow — deposits, withdrawals, funnel allocations, and real-time balance. Drill into transactions and manage outcomes.",
features: [
{ icon: "📈", title: "River Visualization", text: "See funds flow through funnels and outcomes as an animated river diagram." },
{ icon: "💸", title: "Deposits & Withdrawals", text: "Track every transaction with full history and on-chain verification." },
{ icon: "🎯", title: "Outcome Tracking", text: "Define funding outcomes and monitor how capital reaches its destination." },
],
},
],
};