diff --git a/modules/funds/components/folk-funds-app.ts b/modules/funds/components/folk-funds-app.ts
index 6210ad7..e6b0a9b 100644
--- a/modules/funds/components/folk-funds-app.ts
+++ b/modules/funds/components/folk-funds-app.ts
@@ -12,7 +12,7 @@
*/
import type { FlowNode, FunnelNodeData, OutcomeNodeData, SourceNodeData } from "../lib/types";
-import { computeSufficiencyState } from "../lib/simulation";
+import { computeSufficiencyState, computeSystemSufficiency } from "../lib/simulation";
import { demoNodes } from "../lib/presets";
import { mapFlowToNodes } from "../lib/map-flow";
@@ -37,13 +37,29 @@ interface Transaction {
}
type View = "landing" | "detail";
-type Tab = "table" | "river" | "transactions";
+type Tab = "diagram" | "table" | "river" | "transactions";
+
+// ─── Auth helpers (reads EncryptID session from localStorage) ──
+
+function getSession(): { accessToken: string; claims: { sub: string; username?: string } } | null {
+ try {
+ const raw = localStorage.getItem("encryptid_session");
+ if (!raw) return null;
+ const session = JSON.parse(raw);
+ if (!session?.accessToken) return null;
+ return session;
+ } catch { return null; }
+}
+
+function isAuthenticated(): boolean { return getSession() !== null; }
+function getAccessToken(): string | null { return getSession()?.accessToken ?? null; }
+function getUsername(): string | null { return getSession()?.claims?.username ?? null; }
class FolkFundsApp extends HTMLElement {
private shadow: ShadowRoot;
private space = "";
private view: View = "landing";
- private tab: Tab = "table";
+ private tab: Tab = "diagram";
private flowId = "";
private isDemo = false;
@@ -91,7 +107,8 @@ class FolkFundsApp extends HTMLElement {
this.render();
try {
const base = this.getApiBase();
- const res = await fetch(`${base}/api/flows`);
+ const params = this.space ? `?space=${encodeURIComponent(this.space)}` : "";
+ const res = await fetch(`${base}/api/flows${params}`);
if (res.ok) {
const data = await res.json();
this.flows = Array.isArray(data) ? data : (data.flows || []);
@@ -171,6 +188,8 @@ class FolkFundsApp extends HTMLElement {
private renderLanding(): string {
const demoUrl = this.getApiBase() ? `${this.getApiBase().replace(/\/funds$/, "")}/funds/demo` : "/demo";
+ const authed = isAuthenticated();
+ const username = getUsername();
return `
@@ -178,46 +197,92 @@ class FolkFundsApp extends HTMLElement {
rFunds
Token Bonding Flow Funnel
- Distribute resources through cascading funnels with sufficiency-based overflow.
- Each funnel fills to its threshold, then excess flows to the next layer —
+ Design transparent resource flows with sufficiency-based cascading.
+ Funnels fill to their threshold, then overflow routes surplus to the next layer —
ensuring every level has enough before abundance cascades forward.
-
Try the Demo →
+
+
Try the Demo →
+ ${authed
+ ? `
Create Flow `
+ : `
Sign in to create flows `
+ }
+
+
+
+
+
+
+
💰
+
Sources
+
Revenue streams split across funnels by configurable allocation percentages.
+
+
+
🏛
+
Funnels
+
Budget buckets with min/max thresholds and sufficiency-based overflow cascading.
+
+
+
🎯
+
Outcomes
+
Funding targets that receive spending allocations. Track progress toward each goal.
+
+
+
🌊
+
River View
+
Animated sankey diagram showing live fund flows through your entire system.
+
+
+
✨
+
Enoughness
+
System-wide sufficiency scoring. Golden glow when funnels reach their threshold.
+
+
-
Your Flows
+
${this.flows.length > 0 ? `
${this.flows.map((f) => this.renderFlowCard(f)).join("")}
` : `
`}
How TBFF Works
-
-
-
🌊
-
Sources
-
Revenue streams and deposits flow into the system, split across funnels by configurable allocation percentages.
+
+
+
1
+
+
Define Sources
+
Add revenue streams — grants, donations, sales, or any recurring income — with allocation splits.
+
-
-
🏛
-
Funnels
-
Budget buckets with min/max thresholds. When a funnel reaches sufficiency, overflow cascades to the next layer.
+
+
2
+
+
Configure Funnels
+
Set minimum, sufficient, and maximum thresholds. Overflow rules determine where surplus flows.
+
-
-
🎯
-
Outcomes
-
Funding targets that receive spending allocations from funnels. Track progress toward each goal.
+
+
3
+
+
Track Outcomes
+
Funding targets receive allocations as funnels reach sufficiency. Watch the river flow in real time.
+
@@ -258,8 +323,9 @@ class FolkFundsApp extends HTMLElement {
- Table
+ Diagram
River
+ Table
Transactions
@@ -270,6 +336,7 @@ class FolkFundsApp extends HTMLElement {
}
private renderTab(): string {
+ if (this.tab === "diagram") return this.renderDiagramTab();
if (this.tab === "river") return this.renderRiverTab();
if (this.tab === "transactions") return this.renderTransactionsTab();
return this.renderTableTab();
@@ -441,6 +508,234 @@ class FolkFundsApp extends HTMLElement {
return (node.data as any).label || id;
}
+ // ─── Diagram tab ──────────────────────────────────────
+
+ private renderDiagramTab(): string {
+ if (this.nodes.length === 0) {
+ return '
No nodes to display.
';
+ }
+
+ const sources = this.nodes.filter((n) => n.type === "source");
+ const funnels = this.nodes.filter((n) => n.type === "funnel");
+ const outcomes = this.nodes.filter((n) => n.type === "outcome");
+
+ // Layout constants
+ const NODE_W = 200;
+ const SOURCE_H = 54;
+ const FUNNEL_H = 120;
+ const OUTCOME_H = 70;
+ const PAD = 60;
+ const ROW_GAP = 160;
+ const COL_GAP = 40;
+
+ // Compute layers: root funnels (no overflow targeting them) vs child funnels
+ const overflowTargets = new Set
();
+ funnels.forEach((n) => {
+ const d = n.data as FunnelNodeData;
+ d.overflowAllocations?.forEach((a) => overflowTargets.add(a.targetId));
+ });
+ const rootFunnels = funnels.filter((n) => !overflowTargets.has(n.id));
+ const childFunnels = funnels.filter((n) => overflowTargets.has(n.id));
+
+ // Assign positions
+ const positions = new Map();
+
+ const placeRow = (nodes: { id: string }[], y: number, h: number) => {
+ const totalW = nodes.length * NODE_W + (nodes.length - 1) * COL_GAP;
+ const startX = PAD + Math.max(0, (svgW - 2 * PAD - totalW) / 2);
+ nodes.forEach((n, i) => {
+ positions.set(n.id, { x: startX + i * (NODE_W + COL_GAP), y, w: NODE_W, h });
+ });
+ };
+
+ // Estimate SVG size
+ const maxCols = Math.max(sources.length, rootFunnels.length, childFunnels.length, outcomes.length, 1);
+ const svgW = Math.max(800, maxCols * (NODE_W + COL_GAP) + 2 * PAD);
+ let currentY = PAD;
+
+ if (sources.length > 0) { placeRow(sources, currentY, SOURCE_H); currentY += SOURCE_H + ROW_GAP; }
+ if (rootFunnels.length > 0) { placeRow(rootFunnels, currentY, FUNNEL_H); currentY += FUNNEL_H + ROW_GAP; }
+ if (childFunnels.length > 0) { placeRow(childFunnels, currentY, FUNNEL_H); currentY += FUNNEL_H + ROW_GAP; }
+ if (outcomes.length > 0) { placeRow(outcomes, currentY, OUTCOME_H); currentY += OUTCOME_H + PAD; }
+ const svgH = currentY;
+
+ // Build SVG
+ const defs = `
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+
+ // Edges
+ let edges = "";
+
+ // Source → Funnel edges
+ sources.forEach((sn) => {
+ const sd = sn.data as SourceNodeData;
+ const sp = positions.get(sn.id);
+ if (!sp) return;
+ sd.targetAllocations?.forEach((alloc) => {
+ const tp = positions.get(alloc.targetId);
+ if (!tp) return;
+ const x1 = sp.x + sp.w / 2;
+ const y1 = sp.y + sp.h;
+ const x2 = tp.x + tp.w / 2;
+ const y2 = tp.y;
+ const cy1 = y1 + (y2 - y1) * 0.4;
+ const cy2 = y1 + (y2 - y1) * 0.6;
+ edges += ` `;
+ edges += `${alloc.percentage}% `;
+ });
+ });
+
+ // Overflow edges (funnel → funnel)
+ funnels.forEach((fn) => {
+ const fd = fn.data as FunnelNodeData;
+ const fp = positions.get(fn.id);
+ if (!fp) return;
+ fd.overflowAllocations?.forEach((alloc) => {
+ const tp = positions.get(alloc.targetId);
+ if (!tp) return;
+ const x1 = fp.x + fp.w / 2;
+ const y1 = fp.y + fp.h;
+ const x2 = tp.x + tp.w / 2;
+ const y2 = tp.y;
+ // If same row, draw sideways
+ if (Math.abs(y1 - fp.h - (y2 - tp?.h)) < 10) {
+ const sideY = fp.y + fp.h / 2;
+ const midX = (fp.x + fp.w + tp.x) / 2;
+ edges += ` `;
+ edges += `${alloc.percentage}% `;
+ } else {
+ const cy1 = y1 + (y2 - y1) * 0.4;
+ const cy2 = y1 + (y2 - y1) * 0.6;
+ edges += ` `;
+ edges += `${alloc.percentage}% `;
+ }
+ });
+ });
+
+ // Spending edges (funnel → outcome)
+ funnels.forEach((fn) => {
+ const fd = fn.data as FunnelNodeData;
+ const fp = positions.get(fn.id);
+ if (!fp) return;
+ fd.spendingAllocations?.forEach((alloc, i) => {
+ const tp = positions.get(alloc.targetId);
+ if (!tp) return;
+ const x1 = fp.x + fp.w / 2;
+ const y1 = fp.y + fp.h;
+ const x2 = tp.x + tp.w / 2;
+ const y2 = tp.y;
+ const cy1 = y1 + (y2 - y1) * 0.4;
+ const cy2 = y1 + (y2 - y1) * 0.6;
+ edges += ` `;
+ edges += `${alloc.percentage}% `;
+ });
+ });
+
+ // Render nodes
+ let nodesSvg = "";
+
+ // Sources
+ sources.forEach((sn) => {
+ const sd = sn.data as SourceNodeData;
+ const p = positions.get(sn.id)!;
+ nodesSvg += `
+
+ ${this.esc(sd.label)}
+ $${sd.flowRate.toLocaleString()}/mo · ${sd.sourceType}
+ `;
+ });
+
+ // Funnels
+ funnels.forEach((fn) => {
+ const fd = fn.data as FunnelNodeData;
+ const p = positions.get(fn.id)!;
+ const sufficiency = computeSufficiencyState(fd);
+ const isSufficient = sufficiency === "sufficient" || sufficiency === "abundant";
+ const threshold = fd.sufficientThreshold ?? fd.maxThreshold;
+ const fillPct = Math.min(1, fd.currentValue / (fd.maxCapacity || 1));
+ const fillH = fillPct * (p.h - 36);
+ const fillY = p.y + 36 + (p.h - 36) - fillH;
+
+ const borderColor = fd.currentValue > fd.maxThreshold ? "#f59e0b"
+ : fd.currentValue < fd.minThreshold ? "#ef4444"
+ : isSufficient ? "#fbbf24" : "#0ea5e9";
+ const fillColor = fd.currentValue > fd.maxThreshold ? "#f59e0b"
+ : fd.currentValue < fd.minThreshold ? "#ef4444"
+ : isSufficient ? "#fbbf24" : "#0ea5e9";
+ const statusLabel = sufficiency === "abundant" ? "Abundant"
+ : sufficiency === "sufficient" ? "Sufficient"
+ : fd.currentValue < fd.minThreshold ? "Critical" : "Seeking";
+
+ nodesSvg += `
+ ${isSufficient ? ` ` : ""}
+
+
+ ${this.esc(fd.label)}
+ $${Math.floor(fd.currentValue).toLocaleString()} / $${Math.floor(threshold).toLocaleString()}
+ ${statusLabel}
+
+
+ `;
+ });
+
+ // Outcomes
+ outcomes.forEach((on) => {
+ const od = on.data as OutcomeNodeData;
+ const p = positions.get(on.id)!;
+ const fillPct = od.fundingTarget > 0 ? Math.min(1, od.fundingReceived / od.fundingTarget) : 0;
+ const statusColor = od.status === "completed" ? "#10b981"
+ : od.status === "blocked" ? "#ef4444"
+ : od.status === "in-progress" ? "#3b82f6" : "#64748b";
+
+ nodesSvg += `
+
+ ${this.esc(od.label)}
+
+
+ ${Math.round(fillPct * 100)}% · $${Math.floor(od.fundingReceived).toLocaleString()}
+ `;
+ });
+
+ // Sufficiency badge
+ const score = computeSystemSufficiency(this.nodes);
+ const scorePct = Math.round(score * 100);
+ const scoreColor = scorePct >= 90 ? "#fbbf24" : scorePct >= 60 ? "#10b981" : scorePct >= 30 ? "#f59e0b" : "#ef4444";
+
+ return `
+
+
+ ${defs}
+ ${edges}
+ ${nodesSvg}
+
+
+ ${scorePct}%
+ ENOUGH
+
+
+
+ Source
+ Funnel
+ Overflow
+ Spending
+ Outcome
+ Sufficient
+
+
`;
+ }
+
// ─── River tab ────────────────────────────────────────
private renderRiverTab(): string {
@@ -544,6 +839,62 @@ class FolkFundsApp extends HTMLElement {
if (this.tab === "river" && this.nodes.length > 0) {
this.mountRiver();
}
+
+ // Create flow button (landing page, auth-gated)
+ const createBtn = this.shadow.querySelector('[data-action="create-flow"]');
+ createBtn?.addEventListener("click", () => this.handleCreateFlow());
+ }
+
+ private async handleCreateFlow() {
+ const token = getAccessToken();
+ if (!token) return;
+
+ const name = prompt("Flow name:");
+ if (!name) return;
+
+ this.loading = true;
+ this.render();
+ try {
+ const base = this.getApiBase();
+ const res = await fetch(`${base}/api/flows`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${token}`,
+ },
+ body: JSON.stringify({ name }),
+ });
+ if (res.ok) {
+ const data = await res.json();
+ const flowId = data.id || data.flowId;
+ // Associate with current space
+ if (flowId && this.space) {
+ await fetch(`${base}/api/space-flows`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${token}`,
+ },
+ body: JSON.stringify({ space: this.space, flowId }),
+ });
+ }
+ // Navigate to the new flow
+ if (flowId) {
+ const detailUrl = this.getApiBase()
+ ? `${this.getApiBase().replace(/\/funds$/, "")}/funds/flow/${encodeURIComponent(flowId)}`
+ : `/flow/${encodeURIComponent(flowId)}`;
+ window.location.href = detailUrl;
+ return;
+ }
+ } else {
+ const err = await res.json().catch(() => ({}));
+ this.error = (err as any).error || `Failed to create flow (${res.status})`;
+ }
+ } catch {
+ this.error = "Failed to create flow";
+ }
+ this.loading = false;
+ this.render();
}
private esc(s: string): string {
diff --git a/modules/funds/components/funds.css b/modules/funds/components/funds.css
index e5818a5..670d7fc 100644
--- a/modules/funds/components/funds.css
+++ b/modules/funds/components/funds.css
@@ -14,30 +14,56 @@ body[data-theme="light"] main {
.funds-hero {
text-align: center;
- padding: 48px 20px 40px;
+ padding: 56px 20px 48px;
border-bottom: 1px solid #1e293b;
- margin-bottom: 40px;
+ margin-bottom: 48px;
}
.funds-hero__title {
- font-size: 36px; font-weight: 700; margin: 0 0 8px;
+ font-size: 42px; font-weight: 800; margin: 0 0 8px;
background: linear-gradient(135deg, #0ea5e9, #6366f1, #ec4899);
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
background-clip: text;
}
.funds-hero__subtitle { font-size: 18px; color: #94a3b8; margin: 0 0 16px; font-weight: 500; }
-.funds-hero__desc { font-size: 14px; color: #64748b; line-height: 1.7; max-width: 560px; margin: 0 auto 24px; }
+.funds-hero__desc { font-size: 15px; color: #64748b; line-height: 1.7; max-width: 560px; margin: 0 auto 28px; }
+.funds-hero__desc em { color: #fbbf24; font-style: normal; font-weight: 600; }
+.funds-hero__actions { display: flex; align-items: center; justify-content: center; gap: 12px; flex-wrap: wrap; }
.funds-hero__cta {
display: inline-block; padding: 10px 24px; border-radius: 8px;
background: #4f46e5; color: #fff; text-decoration: none; font-weight: 600; font-size: 14px;
- transition: background 0.2s;
+ border: none; cursor: pointer; transition: background 0.2s;
}
.funds-hero__cta:hover { background: #6366f1; }
+.funds-hero__cta--secondary {
+ background: transparent; border: 1px solid #4f46e5; color: #a5b4fc;
+}
+.funds-hero__cta--secondary:hover { background: rgba(79,70,229,0.15); }
+.funds-hero__auth-hint { font-size: 13px; color: #64748b; }
+
+/* Features grid */
+.funds-features { margin-bottom: 48px; }
+.funds-features__grid {
+ display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 12px;
+}
+.funds-features__card {
+ background: #1e293b; border: 1px solid #334155; border-radius: 10px; padding: 20px;
+ transition: border-color 0.2s;
+}
+.funds-features__card:hover { border-color: #475569; }
+.funds-features__icon { font-size: 24px; margin-bottom: 8px; }
+.funds-features__card h3 { font-size: 14px; font-weight: 600; color: #e2e8f0; margin: 0 0 6px; }
+.funds-features__card p { font-size: 12px; color: #94a3b8; line-height: 1.6; margin: 0; }
/* Flow list */
.funds-flows { margin-bottom: 48px; }
-.funds-flows__heading { font-size: 18px; font-weight: 600; color: #e2e8f0; margin: 0 0 16px; }
+.funds-flows__header { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 16px; gap: 12px; flex-wrap: wrap; }
+.funds-flows__heading { font-size: 18px; font-weight: 600; color: #e2e8f0; margin: 0; }
+.funds-flows__user { font-size: 12px; color: #64748b; }
.funds-flows__grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; }
-.funds-flows__empty { text-align: center; color: #64748b; padding: 32px 16px; font-size: 14px; }
+.funds-flows__empty {
+ text-align: center; color: #64748b; padding: 32px 16px; font-size: 14px;
+ background: #1e293b; border: 1px solid #334155; border-radius: 10px;
+}
.funds-flows__empty a { color: #6366f1; text-decoration: none; }
.funds-flows__empty a:hover { text-decoration: underline; }
@@ -53,7 +79,23 @@ body[data-theme="light"] main {
/* About / how-it-works section */
.funds-about { margin-bottom: 48px; }
-.funds-about__heading { font-size: 18px; font-weight: 600; color: #e2e8f0; margin: 0 0 16px; }
+.funds-about__heading { font-size: 18px; font-weight: 600; color: #e2e8f0; margin: 0 0 20px; }
+
+/* Steps layout (replaces the old card grid for "how it works") */
+.funds-about__steps { display: flex; flex-direction: column; gap: 16px; }
+.funds-about__step {
+ display: flex; gap: 16px; align-items: flex-start;
+ background: #1e293b; border: 1px solid #334155; border-radius: 10px; padding: 20px;
+}
+.funds-about__step-num {
+ width: 32px; height: 32px; border-radius: 50%; flex-shrink: 0;
+ background: #4f46e5; color: #fff; font-weight: 700; font-size: 14px;
+ display: flex; align-items: center; justify-content: center;
+}
+.funds-about__step h3 { font-size: 14px; font-weight: 600; color: #e2e8f0; margin: 0 0 4px; }
+.funds-about__step p { font-size: 13px; color: #94a3b8; line-height: 1.6; margin: 0; }
+
+/* Legacy about grid (kept for compat) */
.funds-about__grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px; }
.funds-about__card {
background: #1e293b; border: 1px solid #334155; border-radius: 10px; padding: 20px;
@@ -141,6 +183,16 @@ body[data-theme="light"] main {
.funds-status--seeking { color: #0ea5e9; }
.funds-status--critical { color: #ef4444; }
+/* ── Diagram tab ────────────────────────────────────── */
+.funds-diagram { overflow-x: auto; }
+.funds-diagram svg { display: block; margin: 0 auto; }
+.funds-diagram__legend {
+ display: flex; flex-wrap: wrap; gap: 16px; justify-content: center;
+ margin-top: 12px; font-size: 12px; color: #94a3b8;
+}
+.funds-diagram__legend-item { display: flex; align-items: center; gap: 5px; }
+.funds-diagram__dot { width: 10px; height: 10px; border-radius: 3px; flex-shrink: 0; }
+
/* ── River tab ───────────────────────────────────────── */
.funds-river-container { min-height: 500px; }
diff --git a/modules/funds/db/schema.sql b/modules/funds/db/schema.sql
new file mode 100644
index 0000000..eeff52f
--- /dev/null
+++ b/modules/funds/db/schema.sql
@@ -0,0 +1,10 @@
+-- rFunds module schema
+CREATE SCHEMA IF NOT EXISTS rfunds;
+
+CREATE TABLE IF NOT EXISTS rfunds.space_flows (
+ space_slug TEXT NOT NULL,
+ flow_id TEXT NOT NULL,
+ added_by TEXT,
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ PRIMARY KEY (space_slug, flow_id)
+);
diff --git a/modules/funds/mod.ts b/modules/funds/mod.ts
index 951d1b7..5f45a1a 100644
--- a/modules/funds/mod.ts
+++ b/modules/funds/mod.ts
@@ -5,12 +5,30 @@
*/
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 ─────────────────────────────
@@ -19,6 +37,33 @@ const routes = new Hono();
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);
});
@@ -29,10 +74,20 @@ routes.get("/api/flows/:flowId", async (c) => {
});
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" },
+ headers: {
+ "Content-Type": "application/json",
+ "X-Owner-Address": claims.sub,
+ },
body,
});
return c.json(await res.json(), res.status as any);
@@ -93,6 +148,43 @@ routes.get("/api/flows/:flowId/transactions", async (c) => {
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 = `