From 86fc403138b1957aa266a94052a494e968a72067 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 25 Feb 2026 02:19:23 +0000 Subject: [PATCH] feat: rFunds landing page overhaul with EncryptID auth and space-scoped flows Enhanced landing page with gradient hero, 5-card features grid, auth-aware "Your Flows" section, and 3-step "How TBFF Works" walkthrough. Added EncryptID token verification on flow creation, space_flows DB table for per-space flow association, and space-scoped flow listing API. Co-Authored-By: Claude Opus 4.6 --- modules/funds/components/folk-funds-app.ts | 403 +++++++++++++++++++-- modules/funds/components/funds.css | 68 +++- modules/funds/db/schema.sql | 10 + modules/funds/mod.ts | 94 ++++- 4 files changed, 540 insertions(+), 35 deletions(-) create mode 100644 modules/funds/db/schema.sql 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 + ? `` + : `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

+
+

${authed ? `Flows in ${this.esc(this.space)}` : "Your Flows"}

+ ${authed ? `Signed in as ${this.esc(username || "")}` : ""} +
${this.flows.length > 0 ? `
${this.flows.map((f) => this.renderFlowCard(f)).join("")}
` : `
-

No flows yet.

-

- Explore the demo to see how TBFF works. -

+ ${authed + ? `

No flows in this space yet.

+

Explore the demo or create your first flow.

` + : `

Sign in to see your space’s flows, or explore the demo.

` + }
`}

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 {
- + +
@@ -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 = `