Wire real auth and backends into all 21 rSpace modules

- Auth: 10 modules now use EncryptID token verification on write
  routes (vote, inbox, forum, files, notes, work, cal, trips,
  cart, providers). All POST/PUT/DELETE without valid token → 401.
- Tube: S3Client for Cloudflare R2 bucket (upload, streaming, range)
- Data: Umami analytics proxy (stats, active, tracker, events)
- Wallet: Safe Global API proxy with 12 chains + cache headers
- Network: Twenty CRM GraphQL client (people, companies, graph)
- Maps: sync-url endpoint for WebSocket connection
- Inbox: background IMAP sync worker (30s poll via ImapFlow)
- Forum: provisioner already wired (Hetzner + Cloudflare + cloud-init)
- Config: .gitignore fix, docker-compose env vars + rmail network,
  added @aws-sdk/client-s3, imapflow, mailparser deps

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-21 19:37:26 +00:00
parent 2015a9c277
commit cf3be7d7a9
21 changed files with 5209 additions and 112 deletions

1
.gitignore vendored
View File

@ -6,6 +6,7 @@ dist/
# Data storage # Data storage
data/ data/
!modules/data/
# IDE # IDE
.vscode/ .vscode/

View File

@ -23,6 +23,22 @@ services:
- FLOW_ID=a79144ec-e6a2-4e30-a42a-6d8237a5953d - FLOW_ID=a79144ec-e6a2-4e30-a42a-6d8237a5953d
- FUNNEL_ID=0ff6a9ac-1667-4fc7-9a01-b1620810509f - FUNNEL_ID=0ff6a9ac-1667-4fc7-9a01-b1620810509f
- FILES_DIR=/data/files - FILES_DIR=/data/files
- R2_ENDPOINT=${R2_ENDPOINT}
- R2_BUCKET=${R2_BUCKET:-rtube-videos}
- R2_ACCESS_KEY_ID=${R2_ACCESS_KEY_ID}
- R2_SECRET_ACCESS_KEY=${R2_SECRET_ACCESS_KEY}
- R2_PUBLIC_URL=${R2_PUBLIC_URL}
- UMAMI_URL=https://analytics.rspace.online
- UMAMI_WEBSITE_ID=292f6ac6-79f8-497b-ba6a-7a51e3b87b9f
- MAPS_SYNC_URL=wss://sync.rmaps.online
- IMAP_HOST=mail.rmail.online
- IMAP_PORT=993
- IMAP_TLS_REJECT_UNAUTHORIZED=false
- HETZNER_API_TOKEN=${HETZNER_API_TOKEN}
- CLOUDFLARE_API_TOKEN=${CLOUDFLARE_API_TOKEN}
- CLOUDFLARE_ZONE_ID=${CLOUDFLARE_ZONE_ID}
- TWENTY_API_URL=https://rnetwork.online
- TWENTY_API_TOKEN=${TWENTY_API_TOKEN}
depends_on: depends_on:
rspace-db: rspace-db:
condition: service_healthy condition: service_healthy
@ -43,6 +59,7 @@ services:
- traefik-public - traefik-public
- rspace-internal - rspace-internal
- payment-network - payment-network
- rmail-mailcow
rspace-db: rspace-db:
image: postgres:16-alpine image: postgres:16-alpine
@ -76,4 +93,7 @@ networks:
payment-network: payment-network:
name: payment-infra_payment-network name: payment-infra_payment-network
external: true external: true
rmail-mailcow:
name: mailcowdockerized_mailcow-network
external: true
rspace-internal: rspace-internal:

View File

@ -12,6 +12,7 @@ import { sql } from "../../shared/db/pool";
import { renderShell } from "../../server/shell"; import { renderShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module"; import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module";
import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server";
const routes = new Hono(); const routes = new Hono();
@ -65,6 +66,11 @@ routes.get("/api/events", async (c) => {
// POST /api/events — create event // POST /api/events — create event
routes.post("/api/events", async (c) => { routes.post("/api/events", 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 body = await c.req.json(); const body = await c.req.json();
const { title, description, start_time, end_time, all_day, timezone, source_id, location_id, location_name, const { title, description, start_time, end_time, all_day, timezone, source_id, location_id, location_name,
is_virtual, virtual_url, virtual_platform, r_tool_source, r_tool_entity_id } = body; is_virtual, virtual_url, virtual_platform, r_tool_source, r_tool_entity_id } = body;
@ -72,11 +78,11 @@ routes.post("/api/events", async (c) => {
const rows = await sql.unsafe( const rows = await sql.unsafe(
`INSERT INTO rcal.events (title, description, start_time, end_time, all_day, timezone, source_id, `INSERT INTO rcal.events (title, description, start_time, end_time, all_day, timezone, source_id,
location_id, location_name, is_virtual, virtual_url, virtual_platform, r_tool_source, r_tool_entity_id) location_id, location_name, is_virtual, virtual_url, virtual_platform, r_tool_source, r_tool_entity_id, created_by)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) RETURNING *`, VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) RETURNING *`,
[title.trim(), description || null, start_time, end_time || null, all_day || false, timezone || "UTC", [title.trim(), description || null, start_time, end_time || null, all_day || false, timezone || "UTC",
source_id || null, location_id || null, location_name || null, is_virtual || false, source_id || null, location_id || null, location_name || null, is_virtual || false,
virtual_url || null, virtual_platform || null, r_tool_source || null, r_tool_entity_id || null] virtual_url || null, virtual_platform || null, r_tool_source || null, r_tool_entity_id || null, claims.sub]
); );
return c.json(rows[0], 201); return c.json(rows[0], 201);
}); });
@ -95,6 +101,11 @@ routes.get("/api/events/:id", async (c) => {
// PATCH /api/events/:id // PATCH /api/events/:id
routes.patch("/api/events/:id", async (c) => { routes.patch("/api/events/:id", 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 id = c.req.param("id"); const id = c.req.param("id");
const body = await c.req.json(); const body = await c.req.json();
@ -148,6 +159,10 @@ routes.get("/api/sources", async (c) => {
}); });
routes.post("/api/sources", async (c) => { routes.post("/api/sources", 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 body = await c.req.json(); const body = await c.req.json();
const rows = await sql.unsafe( const rows = await sql.unsafe(
`INSERT INTO rcal.calendar_sources (name, source_type, url, color, is_active, is_visible) `INSERT INTO rcal.calendar_sources (name, source_type, url, color, is_active, is_visible)

View File

@ -14,6 +14,7 @@ import { renderShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module"; import { getModuleInfoList } from "../../shared/module";
import { depositOrderRevenue } from "./flow"; import { depositOrderRevenue } from "./flow";
import type { RSpaceModule } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module";
import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server";
const routes = new Hono(); const routes = new Hono();
@ -171,6 +172,13 @@ routes.patch("/api/catalog/:id", async (c) => {
// POST /api/orders — Create an order // POST /api/orders — Create an order
routes.post("/api/orders", async (c) => { routes.post("/api/orders", async (c) => {
// Optional auth — set buyer_did from claims if authenticated
const token = extractToken(c.req.raw.headers);
let buyerDid: string | null = null;
if (token) {
try { const claims = await verifyEncryptIDToken(token); buyerDid = claims.sub; } catch {}
}
const body = await c.req.json(); const body = await c.req.json();
const { const {
catalog_entry_id, artifact_id, buyer_id, buyer_location, buyer_contact, catalog_entry_id, artifact_id, buyer_id, buyer_location, buyer_contact,
@ -208,7 +216,7 @@ routes.post("/api/orders", async (c) => {
RETURNING *`, RETURNING *`,
[ [
entry.id, entry.artifact_id, entry.id, entry.artifact_id,
buyer_id || null, buyerDid || buyer_id || null,
buyer_location ? JSON.stringify(buyer_location) : null, buyer_location ? JSON.stringify(buyer_location) : null,
buyer_contact ? JSON.stringify(buyer_contact) : null, buyer_contact ? JSON.stringify(buyer_contact) : null,
provider_id, provider_name || null, provider_distance_km || null, provider_id, provider_name || null, provider_distance_km || null,
@ -228,6 +236,13 @@ routes.post("/api/orders", async (c) => {
// GET /api/orders — List orders // GET /api/orders — List orders
routes.get("/api/orders", async (c) => { routes.get("/api/orders", async (c) => {
// Optional auth — filter by buyer if authenticated
const token = extractToken(c.req.raw.headers);
let authedBuyer: string | null = null;
if (token) {
try { const claims = await verifyEncryptIDToken(token); authedBuyer = claims.sub; } catch {}
}
const { status, provider_id, buyer_id, limit = "50", offset = "0" } = c.req.query(); const { status, provider_id, buyer_id, limit = "50", offset = "0" } = c.req.query();
const conditions: string[] = []; const conditions: string[] = [];
@ -236,7 +251,8 @@ routes.get("/api/orders", async (c) => {
if (status) { conditions.push(`o.status = $${paramIdx}`); params.push(status); paramIdx++; } if (status) { conditions.push(`o.status = $${paramIdx}`); params.push(status); paramIdx++; }
if (provider_id) { conditions.push(`o.provider_id = $${paramIdx}`); params.push(provider_id); paramIdx++; } if (provider_id) { conditions.push(`o.provider_id = $${paramIdx}`); params.push(provider_id); paramIdx++; }
if (buyer_id) { conditions.push(`o.buyer_id = $${paramIdx}`); params.push(buyer_id); paramIdx++; } const effectiveBuyerId = buyer_id || (authedBuyer && !status && !provider_id ? authedBuyer : null);
if (effectiveBuyerId) { conditions.push(`o.buyer_id = $${paramIdx}`); params.push(effectiveBuyerId); paramIdx++; }
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const limitNum = Math.min(parseInt(limit) || 50, 100); const limitNum = Math.min(parseInt(limit) || 50, 100);

View File

@ -13,6 +13,13 @@ import type { RSpaceModule } from "../../shared/module";
const routes = new Hono(); const routes = new Hono();
const UMAMI_URL = process.env.UMAMI_URL || "https://analytics.rspace.online"; const UMAMI_URL = process.env.UMAMI_URL || "https://analytics.rspace.online";
const UMAMI_WEBSITE_ID = process.env.UMAMI_WEBSITE_ID || "292f6ac6-79f8-497b-ba6a-7a51e3b87b9f";
const TRACKED_APPS = [
"rSpace", "rNotes", "rVote", "rFunds", "rCart", "rWallet",
"rPubs", "rChoices", "rCal", "rForum", "rInbox", "rFiles",
"rTrips", "rTube", "rWork", "rNetwork", "rData",
];
// ── API routes ── // ── API routes ──
@ -22,30 +29,94 @@ routes.get("/api/info", (c) => {
module: "data", module: "data",
name: "rData", name: "rData",
umamiUrl: UMAMI_URL, umamiUrl: UMAMI_URL,
umamiConfigured: !!UMAMI_URL,
features: ["privacy-first", "cookieless", "self-hosted"], features: ["privacy-first", "cookieless", "self-hosted"],
trackedApps: 17, trackedApps: TRACKED_APPS.length,
}); });
}); });
// GET /api/health // GET /api/health
routes.get("/api/health", (c) => c.json({ ok: true })); routes.get("/api/health", (c) => c.json({ ok: true }));
// GET /api/stats — summary stats (placeholder until Umami API is wired) // GET /api/stats — proxy to Umami stats API
routes.get("/api/stats", (c) => { routes.get("/api/stats", async (c) => {
const startAt = c.req.query("startAt") || String(Date.now() - 24 * 3600_000);
const endAt = c.req.query("endAt") || String(Date.now());
try {
const res = await fetch(
`${UMAMI_URL}/api/websites/${UMAMI_WEBSITE_ID}/stats?startAt=${startAt}&endAt=${endAt}`,
{ signal: AbortSignal.timeout(5000) }
);
if (res.ok) {
const data = await res.json();
return c.json({
...data as Record<string, unknown>,
trackedApps: TRACKED_APPS.length,
apps: TRACKED_APPS,
selfHosted: true,
dashboardUrl: UMAMI_URL,
});
}
} catch {}
// Fallback when Umami unreachable
return c.json({ return c.json({
trackedApps: 17, trackedApps: TRACKED_APPS.length,
cookiesSet: 0, cookiesSet: 0,
scriptSize: "~2KB", scriptSize: "~2KB",
selfHosted: true, selfHosted: true,
dashboardUrl: UMAMI_URL, dashboardUrl: UMAMI_URL,
apps: [ apps: TRACKED_APPS,
"rSpace", "rNotes", "rVote", "rFunds", "rCart", "rWallet",
"rPubs", "rChoices", "rCal", "rForum", "rInbox", "rFiles",
"rTrips", "rTube", "rWork", "rNetwork", "rData",
],
}); });
}); });
// GET /api/active — proxy to Umami active visitors
routes.get("/api/active", async (c) => {
try {
const res = await fetch(
`${UMAMI_URL}/api/websites/${UMAMI_WEBSITE_ID}/active`,
{ signal: AbortSignal.timeout(5000) }
);
if (res.ok) return c.json(await res.json());
} catch {}
return c.json({ x: 0 });
});
// GET /collect.js — proxy Umami tracker script
routes.get("/collect.js", async (c) => {
try {
const res = await fetch(`${UMAMI_URL}/script.js`, { signal: AbortSignal.timeout(5000) });
if (res.ok) {
const script = await res.text();
return new Response(script, {
headers: {
"Content-Type": "application/javascript",
"Cache-Control": "public, max-age=3600",
},
});
}
} catch {}
return new Response("/* umami unavailable */", {
headers: { "Content-Type": "application/javascript" },
});
});
// POST /api/collect — proxy Umami event collection
routes.post("/api/collect", async (c) => {
try {
const body = await c.req.text();
const res = await fetch(`${UMAMI_URL}/api/send`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
signal: AbortSignal.timeout(5000),
});
if (res.ok) return c.json(await res.json());
} catch {}
return c.json({ ok: true });
});
// ── Page route ── // ── Page route ──
routes.get("/", (c) => { routes.get("/", (c) => {
const space = c.req.param("space") || "demo"; const space = c.req.param("space") || "demo";

View File

@ -12,6 +12,7 @@ import { sql } from "../../shared/db/pool";
import { renderShell } from "../../server/shell"; import { renderShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module"; import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module";
import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server";
const routes = new Hono(); const routes = new Hono();
@ -66,6 +67,11 @@ async function computeFileHash(buffer: ArrayBuffer): Promise<string> {
// ── File upload ── // ── File upload ──
routes.post("/api/files", async (c) => { routes.post("/api/files", 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 formData = await c.req.formData(); const formData = await c.req.formData();
const file = formData.get("file") as File | null; const file = formData.get("file") as File | null;
if (!file) return c.json({ error: "file is required" }, 400); if (!file) return c.json({ error: "file is required" }, 400);
@ -74,7 +80,7 @@ routes.post("/api/files", async (c) => {
const title = formData.get("title")?.toString() || file.name.replace(/\.[^.]+$/, ""); const title = formData.get("title")?.toString() || file.name.replace(/\.[^.]+$/, "");
const description = formData.get("description")?.toString() || ""; const description = formData.get("description")?.toString() || "";
const tags = formData.get("tags")?.toString() || "[]"; const tags = formData.get("tags")?.toString() || "[]";
const uploadedBy = c.req.header("X-User-DID") || ""; const uploadedBy = claims.sub;
const buffer = await file.arrayBuffer(); const buffer = await file.arrayBuffer();
const fileHash = await computeFileHash(buffer); const fileHash = await computeFileHash(buffer);
@ -162,13 +168,19 @@ routes.delete("/api/files/:id", async (c) => {
// ── Create share link ── // ── Create share link ──
routes.post("/api/files/:id/share", async (c) => { routes.post("/api/files/:id/share", async (c) => {
const authToken = extractToken(c.req.raw.headers);
if (!authToken) return c.json({ error: "Authentication required" }, 401);
let claims;
try { claims = await verifyEncryptIDToken(authToken); } catch { return c.json({ error: "Invalid token" }, 401); }
const [file] = await sql.unsafe("SELECT * FROM rfiles.media_files WHERE id = $1", [c.req.param("id")]); const [file] = await sql.unsafe("SELECT * FROM rfiles.media_files WHERE id = $1", [c.req.param("id")]);
if (!file) return c.json({ error: "File not found" }, 404); if (!file) return c.json({ error: "File not found" }, 404);
if (file.uploaded_by && file.uploaded_by !== claims.sub) return c.json({ error: "Not authorized" }, 403);
const body = await c.req.json<{ expires_in_hours?: number; max_downloads?: number; password?: string; note?: string }>(); const body = await c.req.json<{ expires_in_hours?: number; max_downloads?: number; password?: string; note?: string }>();
const token = generateToken(); const token = generateToken();
const expiresAt = body.expires_in_hours ? new Date(Date.now() + body.expires_in_hours * 3600_000).toISOString() : null; const expiresAt = body.expires_in_hours ? new Date(Date.now() + body.expires_in_hours * 3600_000).toISOString() : null;
const createdBy = c.req.header("X-User-DID") || ""; const createdBy = claims.sub;
let passwordHash: string | null = null; let passwordHash: string | null = null;
let isPasswordProtected = false; let isPasswordProtected = false;
@ -202,12 +214,23 @@ routes.get("/api/files/:id/shares", async (c) => {
// ── Revoke share ── // ── Revoke share ──
routes.post("/api/shares/:shareId/revoke", async (c) => { routes.post("/api/shares/:shareId/revoke", async (c) => {
const authToken = extractToken(c.req.raw.headers);
if (!authToken) return c.json({ error: "Authentication required" }, 401);
let claims;
try { claims = await verifyEncryptIDToken(authToken); } catch { return c.json({ error: "Invalid token" }, 401); }
const [share] = await sql.unsafe( const [share] = await sql.unsafe(
"UPDATE rfiles.public_shares SET is_active = FALSE WHERE id = $1 RETURNING *", "SELECT s.*, f.uploaded_by FROM rfiles.public_shares s JOIN rfiles.media_files f ON s.media_file_id = f.id WHERE s.id = $1",
[c.req.param("shareId")] [c.req.param("shareId")]
); );
if (!share) return c.json({ error: "Share not found" }, 404); if (!share) return c.json({ error: "Share not found" }, 404);
return c.json({ message: "Revoked", share }); if (share.uploaded_by && share.uploaded_by !== claims.sub) return c.json({ error: "Not authorized" }, 403);
const [revoked] = await sql.unsafe(
"UPDATE rfiles.public_shares SET is_active = FALSE WHERE id = $1 RETURNING *",
[c.req.param("shareId")]
);
return c.json({ message: "Revoked", share: revoked });
}); });
// ── Public share download ── // ── Public share download ──
@ -278,9 +301,14 @@ routes.get("/s/:token/info", async (c) => {
// ── Memory Cards CRUD ── // ── Memory Cards CRUD ──
routes.post("/api/cards", async (c) => { routes.post("/api/cards", async (c) => {
const authToken = extractToken(c.req.raw.headers);
if (!authToken) return c.json({ error: "Authentication required" }, 401);
let claims;
try { claims = await verifyEncryptIDToken(authToken); } catch { return c.json({ error: "Invalid token" }, 401); }
const body = await c.req.json<{ title: string; body?: string; card_type?: string; tags?: string[]; shared_space?: string }>(); const body = await c.req.json<{ title: string; body?: string; card_type?: string; tags?: string[]; shared_space?: string }>();
const space = c.req.param("space") || body.shared_space || "default"; const space = c.req.param("space") || body.shared_space || "default";
const createdBy = c.req.header("X-User-DID") || ""; const createdBy = claims.sub;
const [card] = await sql.unsafe( const [card] = await sql.unsafe(
`INSERT INTO rfiles.memory_cards (shared_space, title, body, card_type, tags, created_by) `INSERT INTO rfiles.memory_cards (shared_space, title, body, card_type, tags, created_by)

View File

@ -11,6 +11,7 @@ import { renderShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module"; import { getModuleInfoList } from "../../shared/module";
import { provisionInstance, destroyInstance } from "./lib/provisioner"; import { provisionInstance, destroyInstance } from "./lib/provisioner";
import type { RSpaceModule } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module";
import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server";
const routes = new Hono(); const routes = new Hono();
@ -40,10 +41,12 @@ async function getOrCreateUser(did: string): Promise<any> {
// ── API: List instances ── // ── API: List instances ──
routes.get("/api/instances", async (c) => { routes.get("/api/instances", async (c) => {
const did = c.req.header("X-User-DID"); const token = extractToken(c.req.raw.headers);
if (!did) return c.json({ error: "Authentication required" }, 401); 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 user = await getOrCreateUser(did); const user = await getOrCreateUser(claims.sub);
const rows = await sql.unsafe( const rows = await sql.unsafe(
"SELECT * FROM rforum.instances WHERE user_id = $1 AND status != 'destroyed' ORDER BY created_at DESC", "SELECT * FROM rforum.instances WHERE user_id = $1 AND status != 'destroyed' ORDER BY created_at DESC",
[user.id], [user.id],
@ -53,10 +56,12 @@ routes.get("/api/instances", async (c) => {
// ── API: Create instance ── // ── API: Create instance ──
routes.post("/api/instances", async (c) => { routes.post("/api/instances", async (c) => {
const did = c.req.header("X-User-DID"); const token = extractToken(c.req.raw.headers);
if (!did) return c.json({ error: "Authentication required" }, 401); 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 user = await getOrCreateUser(did); const user = await getOrCreateUser(claims.sub);
const body = await c.req.json<{ const body = await c.req.json<{
name: string; name: string;
subdomain: string; subdomain: string;
@ -92,10 +97,12 @@ routes.post("/api/instances", async (c) => {
// ── API: Get instance detail ── // ── API: Get instance detail ──
routes.get("/api/instances/:id", async (c) => { routes.get("/api/instances/:id", async (c) => {
const did = c.req.header("X-User-DID"); const token = extractToken(c.req.raw.headers);
if (!did) return c.json({ error: "Authentication required" }, 401); 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 user = await getOrCreateUser(did); const user = await getOrCreateUser(claims.sub);
const [instance] = await sql.unsafe( const [instance] = await sql.unsafe(
"SELECT * FROM rforum.instances WHERE id = $1 AND user_id = $2", "SELECT * FROM rforum.instances WHERE id = $1 AND user_id = $2",
[c.req.param("id"), user.id], [c.req.param("id"), user.id],
@ -112,10 +119,12 @@ routes.get("/api/instances/:id", async (c) => {
// ── API: Destroy instance ── // ── API: Destroy instance ──
routes.delete("/api/instances/:id", async (c) => { routes.delete("/api/instances/:id", async (c) => {
const did = c.req.header("X-User-DID"); const token = extractToken(c.req.raw.headers);
if (!did) return c.json({ error: "Authentication required" }, 401); 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 user = await getOrCreateUser(did); const user = await getOrCreateUser(claims.sub);
const [instance] = await sql.unsafe( const [instance] = await sql.unsafe(
"SELECT * FROM rforum.instances WHERE id = $1 AND user_id = $2", "SELECT * FROM rforum.instances WHERE id = $1 AND user_id = $2",
[c.req.param("id"), user.id], [c.req.param("id"), user.id],

View File

@ -12,6 +12,7 @@ import { sql } from "../../shared/db/pool";
import { renderShell } from "../../server/shell"; import { renderShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module"; import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module";
import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server";
const routes = new Hono(); const routes = new Hono();
@ -29,6 +30,17 @@ async function initDB() {
initDB(); initDB();
// ── Helper: get or create user by DID ──
async function getOrCreateUser(did: string, username?: string) {
const rows = await sql.unsafe(
`INSERT INTO rinbox.users (did, username) VALUES ($1, $2)
ON CONFLICT (did) DO UPDATE SET username = COALESCE($2, rinbox.users.username)
RETURNING *`,
[did, username || null]
);
return rows[0];
}
// ── Mailboxes API ── // ── Mailboxes API ──
// GET /api/mailboxes — list mailboxes // GET /api/mailboxes — list mailboxes
@ -52,16 +64,21 @@ routes.get("/api/mailboxes", async (c) => {
// POST /api/mailboxes — create mailbox // POST /api/mailboxes — create mailbox
routes.post("/api/mailboxes", async (c) => { routes.post("/api/mailboxes", 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 body = await c.req.json(); const body = await c.req.json();
const { slug, name, email, description, visibility = "members_only" } = body; const { slug, name, email, description, visibility = "members_only", imap_user, imap_password } = body;
if (!slug || !name || !email) return c.json({ error: "slug, name, email required" }, 400); if (!slug || !name || !email) return c.json({ error: "slug, name, email required" }, 400);
if (!/^[a-z0-9-]+$/.test(slug)) return c.json({ error: "Invalid slug" }, 400); if (!/^[a-z0-9-]+$/.test(slug)) return c.json({ error: "Invalid slug" }, 400);
try { try {
const rows = await sql.unsafe( const rows = await sql.unsafe(
`INSERT INTO rinbox.mailboxes (slug, name, email, description, visibility, owner_did) `INSERT INTO rinbox.mailboxes (slug, name, email, description, visibility, owner_did, imap_user)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`, VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`,
[slug, name, email, description || null, visibility, "anonymous"] [slug, name, email, description || null, visibility, claims.sub, imap_user || null]
); );
return c.json(rows[0], 201); return c.json(rows[0], 201);
} catch (e: any) { } catch (e: any) {
@ -172,6 +189,11 @@ routes.patch("/api/threads/:id", async (c) => {
// POST /api/threads/:id/comments — add comment // POST /api/threads/:id/comments — add comment
routes.post("/api/threads/:id/comments", async (c) => { routes.post("/api/threads/:id/comments", 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 threadId = c.req.param("id"); const threadId = c.req.param("id");
const body = await c.req.json(); const body = await c.req.json();
const { text, mentions } = body; const { text, mentions } = body;
@ -181,16 +203,12 @@ routes.post("/api/threads/:id/comments", async (c) => {
const thread = await sql.unsafe("SELECT id FROM rinbox.threads WHERE id = $1", [threadId]); const thread = await sql.unsafe("SELECT id FROM rinbox.threads WHERE id = $1", [threadId]);
if (thread.length === 0) return c.json({ error: "Thread not found" }, 404); if (thread.length === 0) return c.json({ error: "Thread not found" }, 404);
// Get or create anonymous user const user = await getOrCreateUser(claims.sub, claims.username);
const user = await sql.unsafe(
`INSERT INTO rinbox.users (did, username) VALUES ('anonymous', 'Anonymous')
ON CONFLICT (did) DO UPDATE SET username = rinbox.users.username RETURNING id`
);
const rows = await sql.unsafe( const rows = await sql.unsafe(
`INSERT INTO rinbox.comments (thread_id, author_id, body, mentions) `INSERT INTO rinbox.comments (thread_id, author_id, body, mentions)
VALUES ($1, $2, $3, $4) RETURNING *`, VALUES ($1, $2, $3, $4) RETURNING *`,
[threadId, user[0].id, text, mentions || []] [threadId, user.id, text, mentions || []]
); );
return c.json(rows[0], 201); return c.json(rows[0], 201);
}); });
@ -222,6 +240,11 @@ routes.get("/api/approvals", async (c) => {
// POST /api/approvals — create approval draft // POST /api/approvals — create approval draft
routes.post("/api/approvals", async (c) => { routes.post("/api/approvals", 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 body = await c.req.json(); const body = await c.req.json();
const { mailbox_slug, thread_id, subject, body_text, to_addresses } = body; const { mailbox_slug, thread_id, subject, body_text, to_addresses } = body;
if (!mailbox_slug || !subject) return c.json({ error: "mailbox_slug and subject required" }, 400); if (!mailbox_slug || !subject) return c.json({ error: "mailbox_slug and subject required" }, 400);
@ -229,21 +252,23 @@ routes.post("/api/approvals", async (c) => {
const mb = await sql.unsafe("SELECT * FROM rinbox.mailboxes WHERE slug = $1", [mailbox_slug]); const mb = await sql.unsafe("SELECT * FROM rinbox.mailboxes WHERE slug = $1", [mailbox_slug]);
if (mb.length === 0) return c.json({ error: "Mailbox not found" }, 404); if (mb.length === 0) return c.json({ error: "Mailbox not found" }, 404);
const user = await sql.unsafe( const user = await getOrCreateUser(claims.sub, claims.username);
`INSERT INTO rinbox.users (did, username) VALUES ('anonymous', 'Anonymous')
ON CONFLICT (did) DO UPDATE SET username = rinbox.users.username RETURNING id`
);
const rows = await sql.unsafe( const rows = await sql.unsafe(
`INSERT INTO rinbox.approvals (mailbox_id, thread_id, author_id, subject, body_text, to_addresses, required_signatures) `INSERT INTO rinbox.approvals (mailbox_id, thread_id, author_id, subject, body_text, to_addresses, required_signatures)
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`, VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`,
[mb[0].id, thread_id || null, user[0].id, subject, body_text || null, JSON.stringify(to_addresses || []), mb[0].approval_threshold || 1] [mb[0].id, thread_id || null, user.id, subject, body_text || null, JSON.stringify(to_addresses || []), mb[0].approval_threshold || 1]
); );
return c.json(rows[0], 201); return c.json(rows[0], 201);
}); });
// POST /api/approvals/:id/sign — sign an approval // POST /api/approvals/:id/sign — sign an approval
routes.post("/api/approvals/:id/sign", async (c) => { routes.post("/api/approvals/:id/sign", 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 id = c.req.param("id"); const id = c.req.param("id");
const body = await c.req.json(); const body = await c.req.json();
const { vote = "APPROVE" } = body; const { vote = "APPROVE" } = body;
@ -253,16 +278,13 @@ routes.post("/api/approvals/:id/sign", async (c) => {
if (approval.length === 0) return c.json({ error: "Approval not found" }, 404); if (approval.length === 0) return c.json({ error: "Approval not found" }, 404);
if (approval[0].status !== "PENDING") return c.json({ error: "Approval not pending" }, 400); if (approval[0].status !== "PENDING") return c.json({ error: "Approval not pending" }, 400);
const user = await sql.unsafe( const user = await getOrCreateUser(claims.sub, claims.username);
`INSERT INTO rinbox.users (did, username) VALUES ('anonymous', 'Anonymous')
ON CONFLICT (did) DO UPDATE SET username = rinbox.users.username RETURNING id`
);
await sql.unsafe( await sql.unsafe(
`INSERT INTO rinbox.approval_signatures (approval_id, signer_id, vote) `INSERT INTO rinbox.approval_signatures (approval_id, signer_id, vote)
VALUES ($1, $2, $3) VALUES ($1, $2, $3)
ON CONFLICT (approval_id, signer_id) DO UPDATE SET vote = $3, signed_at = NOW()`, ON CONFLICT (approval_id, signer_id) DO UPDATE SET vote = $3, signed_at = NOW()`,
[id, user[0].id, vote] [id, user.id, vote]
); );
// Check if threshold reached // Check if threshold reached
@ -307,6 +329,11 @@ routes.get("/api/workspaces", async (c) => {
// POST /api/workspaces // POST /api/workspaces
routes.post("/api/workspaces", async (c) => { routes.post("/api/workspaces", 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 body = await c.req.json(); const body = await c.req.json();
const { slug, name, description } = body; const { slug, name, description } = body;
if (!slug || !name) return c.json({ error: "slug and name required" }, 400); if (!slug || !name) return c.json({ error: "slug and name required" }, 400);
@ -315,7 +342,7 @@ routes.post("/api/workspaces", async (c) => {
const rows = await sql.unsafe( const rows = await sql.unsafe(
`INSERT INTO rinbox.workspaces (slug, name, description, owner_did) `INSERT INTO rinbox.workspaces (slug, name, description, owner_did)
VALUES ($1, $2, $3, $4) RETURNING *`, VALUES ($1, $2, $3, $4) RETURNING *`,
[slug, name, description || null, "anonymous"] [slug, name, description || null, claims.sub]
); );
return c.json(rows[0], 201); return c.json(rows[0], 201);
} catch (e: any) { } catch (e: any) {
@ -325,7 +352,180 @@ routes.post("/api/workspaces", async (c) => {
}); });
// GET /api/health // GET /api/health
routes.get("/api/health", (c) => c.json({ ok: true })); routes.get("/api/health", (c) => c.json({ ok: true, imapSync: IMAP_HOST !== "" }));
// GET /api/sync-status — show IMAP sync state per mailbox
routes.get("/api/sync-status", async (c) => {
const rows = await sql.unsafe(
`SELECT s.*, m.slug, m.name, m.email
FROM rinbox.sync_state s
JOIN rinbox.mailboxes m ON m.id = s.mailbox_id
ORDER BY s.last_sync_at DESC NULLS LAST`
);
return c.json({ syncStates: rows });
});
// ── IMAP Sync Worker ──
const IMAP_HOST = process.env.IMAP_HOST || "";
const IMAP_PORT = parseInt(process.env.IMAP_PORT || "993");
const IMAP_TLS_REJECT = process.env.IMAP_TLS_REJECT_UNAUTHORIZED !== "false";
const SYNC_INTERVAL = 30_000; // 30 seconds
async function syncMailbox(mailbox: any) {
let ImapFlow: any;
let simpleParser: any;
try {
ImapFlow = (await import("imapflow")).ImapFlow;
simpleParser = (await import("mailparser")).simpleParser;
} catch {
console.error("[Inbox] imapflow/mailparser not installed — skipping IMAP sync");
return;
}
const host = mailbox.imap_host || IMAP_HOST;
const port = mailbox.imap_port || IMAP_PORT;
const user = mailbox.imap_user;
if (!host || !user) return;
// Get or create sync state
let syncState = (await sql.unsafe(
"SELECT * FROM rinbox.sync_state WHERE mailbox_id = $1",
[mailbox.id]
))[0];
if (!syncState) {
const rows = await sql.unsafe(
"INSERT INTO rinbox.sync_state (mailbox_id) VALUES ($1) RETURNING *",
[mailbox.id]
);
syncState = rows[0];
}
const client = new ImapFlow({
host,
port,
secure: port === 993,
auth: { user, pass: process.env[`IMAP_PASS_${mailbox.slug.toUpperCase().replace(/-/g, "_")}`] || "" },
tls: { rejectUnauthorized: IMAP_TLS_REJECT },
logger: false,
});
try {
await client.connect();
const lock = await client.getMailboxLock("INBOX");
try {
const status = client.mailbox;
const uidValidity = status?.uidValidity;
// UID validity changed — need full resync
if (syncState.uid_validity && uidValidity && syncState.uid_validity !== uidValidity) {
await sql.unsafe(
"UPDATE rinbox.sync_state SET last_uid = 0, uid_validity = $1 WHERE mailbox_id = $2",
[uidValidity, mailbox.id]
);
syncState.last_uid = 0;
}
// Fetch messages newer than last synced UID
const lastUid = syncState.last_uid || 0;
const range = lastUid > 0 ? `${lastUid + 1}:*` : "1:*";
let maxUid = lastUid;
let count = 0;
for await (const msg of client.fetch(range, {
envelope: true,
source: true,
uid: true,
})) {
if (msg.uid <= lastUid) continue;
if (count >= 100) break; // Batch limit
try {
const parsed = await simpleParser(msg.source);
const fromAddr = parsed.from?.value?.[0]?.address || msg.envelope?.from?.[0]?.address || "";
const fromName = parsed.from?.value?.[0]?.name || msg.envelope?.from?.[0]?.name || "";
const subject = parsed.subject || msg.envelope?.subject || "(no subject)";
const toAddrs = (parsed.to?.value || []).map((a: any) => ({ address: a.address, name: a.name }));
const ccAddrs = (parsed.cc?.value || []).map((a: any) => ({ address: a.address, name: a.name }));
const messageId = parsed.messageId || msg.envelope?.messageId || null;
const hasAttachments = (parsed.attachments?.length || 0) > 0;
await sql.unsafe(
`INSERT INTO rinbox.threads (mailbox_id, message_id, subject, from_address, from_name, to_addresses, cc_addresses, body_text, body_html, has_attachments, received_at)
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7::jsonb, $8, $9, $10, $11)
ON CONFLICT DO NOTHING`,
[
mailbox.id,
messageId,
subject,
fromAddr,
fromName,
JSON.stringify(toAddrs),
JSON.stringify(ccAddrs),
parsed.text || null,
parsed.html || null,
hasAttachments,
parsed.date || new Date(),
]
);
count++;
} catch (parseErr) {
console.error(`[Inbox] Parse error UID ${msg.uid}:`, parseErr);
}
if (msg.uid > maxUid) maxUid = msg.uid;
}
// Update sync state
await sql.unsafe(
"UPDATE rinbox.sync_state SET last_uid = $1, uid_validity = $2, last_sync_at = NOW(), error = NULL WHERE mailbox_id = $3",
[maxUid, uidValidity || null, mailbox.id]
);
if (count > 0) console.log(`[Inbox] Synced ${count} messages for ${mailbox.email}`);
} finally {
lock.release();
}
await client.logout();
} catch (e: any) {
console.error(`[Inbox] IMAP sync error for ${mailbox.email}:`, e.message);
await sql.unsafe(
"UPDATE rinbox.sync_state SET error = $1, last_sync_at = NOW() WHERE mailbox_id = $2",
[e.message, mailbox.id]
).catch(() => {});
}
}
async function runSyncLoop() {
if (!IMAP_HOST) {
console.log("[Inbox] IMAP_HOST not set — IMAP sync disabled");
return;
}
console.log(`[Inbox] IMAP sync enabled — polling every ${SYNC_INTERVAL / 1000}s`);
const doSync = async () => {
try {
const mailboxes = await sql.unsafe(
"SELECT * FROM rinbox.mailboxes WHERE imap_user IS NOT NULL"
);
for (const mb of mailboxes) {
await syncMailbox(mb);
}
} catch (e) {
console.error("[Inbox] Sync loop error:", e);
}
};
// Initial sync after 5s delay
setTimeout(doSync, 5000);
// Recurring sync
setInterval(doSync, SYNC_INTERVAL);
}
// Start IMAP sync in background
runSyncLoop();
// ── Page route ── // ── Page route ──
routes.get("/", (c) => { routes.get("/", (c) => {

View File

@ -15,6 +15,12 @@ const routes = new Hono();
const SYNC_SERVER = process.env.MAPS_SYNC_URL || "http://localhost:3001"; const SYNC_SERVER = process.env.MAPS_SYNC_URL || "http://localhost:3001";
// ── Sync URL for client-side WebSocket connection ──
routes.get("/api/sync-url", (c) => {
const wsUrl = process.env.MAPS_SYNC_URL || "wss://sync.rmaps.online";
return c.json({ syncUrl: wsUrl });
});
// ── Proxy: sync server health ── // ── Proxy: sync server health ──
routes.get("/api/health", async (c) => { routes.get("/api/health", async (c) => {
try { try {

View File

@ -3,7 +3,7 @@
* *
* Visualizes CRM data as interactive force-directed graphs. * Visualizes CRM data as interactive force-directed graphs.
* Nodes: people, companies, opportunities. Edges: relationships. * Nodes: people, companies, opportunities. Edges: relationships.
* Syncs from Twenty CRM via API proxy. * Syncs from Twenty CRM via GraphQL API proxy.
*/ */
import { Hono } from "hono"; import { Hono } from "hono";
@ -13,27 +13,201 @@ import type { RSpaceModule } from "../../shared/module";
const routes = new Hono(); const routes = new Hono();
// No database — network data lives in Automerge CRDT docs or is proxied from Twenty CRM. const TWENTY_API_URL = process.env.TWENTY_API_URL || "https://rnetwork.online";
// The existing rNetwork-online at /opt/apps/rNetwork-online/ already uses Bun+Hono+Automerge. const TWENTY_API_TOKEN = process.env.TWENTY_API_TOKEN || "";
// ── GraphQL helper ──
async function twentyQuery(query: string, variables?: Record<string, unknown>) {
if (!TWENTY_API_TOKEN) return null;
const res = await fetch(`${TWENTY_API_URL}/api`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${TWENTY_API_TOKEN}`,
},
body: JSON.stringify({ query, variables }),
signal: AbortSignal.timeout(10000),
});
if (!res.ok) return null;
const json = await res.json() as { data?: unknown };
return json.data ?? null;
}
// ── Cache layer (60s TTL) ──
let graphCache: { data: unknown; ts: number } | null = null;
const CACHE_TTL = 60_000;
// ── API: Health ── // ── API: Health ──
routes.get("/api/health", (c) => { routes.get("/api/health", (c) => {
return c.json({ ok: true, module: "network", sync: true }); return c.json({ ok: true, module: "network", twentyConfigured: !!TWENTY_API_TOKEN });
}); });
// ── API: Graph info (placeholder — real data from Automerge) ── // ── API: Info ──
routes.get("/api/info", (c) => { routes.get("/api/info", (c) => {
return c.json({ return c.json({
module: "network", module: "network",
description: "Community relationship graph visualization", description: "Community relationship graph visualization",
entityTypes: ["person", "company", "opportunity"], entityTypes: ["person", "company", "opportunity"],
features: ["force-directed layout", "CRM sync", "real-time collaboration"], features: ["force-directed layout", "CRM sync", "real-time collaboration"],
twentyConfigured: !!TWENTY_API_TOKEN,
}); });
}); });
// ── API: People ──
routes.get("/api/people", async (c) => {
const data = await twentyQuery(`{
people(first: 200) {
edges {
node {
id
name { firstName lastName }
email { primaryEmail }
phone { primaryPhoneNumber }
city
company { id name { firstName lastName } }
createdAt
}
}
}
}`);
if (!data) return c.json({ people: [], error: TWENTY_API_TOKEN ? "Twenty API error" : "Twenty not configured" });
const people = ((data as any).people?.edges || []).map((e: any) => e.node);
c.header("Cache-Control", "public, max-age=60");
return c.json({ people });
});
// ── API: Companies ──
routes.get("/api/companies", async (c) => {
const data = await twentyQuery(`{
companies(first: 200) {
edges {
node {
id
name
domainName { primaryLinkUrl }
employees
address { addressCity addressCountry }
createdAt
}
}
}
}`);
if (!data) return c.json({ companies: [], error: TWENTY_API_TOKEN ? "Twenty API error" : "Twenty not configured" });
const companies = ((data as any).companies?.edges || []).map((e: any) => e.node);
c.header("Cache-Control", "public, max-age=60");
return c.json({ companies });
});
// ── API: Graph — transform entities to node/edge format ──
routes.get("/api/graph", async (c) => {
// Check cache
if (graphCache && Date.now() - graphCache.ts < CACHE_TTL) {
c.header("Cache-Control", "public, max-age=60");
return c.json(graphCache.data);
}
if (!TWENTY_API_TOKEN) {
return c.json({
nodes: [
{ id: "demo-1", label: "Alice", type: "person", data: {} },
{ id: "demo-2", label: "Bob", type: "person", data: {} },
{ id: "demo-3", label: "Acme Corp", type: "company", data: {} },
],
edges: [
{ source: "demo-1", target: "demo-3", type: "works_at" },
{ source: "demo-2", target: "demo-3", type: "works_at" },
{ source: "demo-1", target: "demo-2", type: "contact_of" },
],
demo: true,
});
}
try {
const data = await twentyQuery(`{
people(first: 200) {
edges {
node {
id
name { firstName lastName }
email { primaryEmail }
company { id name { firstName lastName } }
}
}
}
companies(first: 200) {
edges {
node {
id
name
domainName { primaryLinkUrl }
employees
}
}
}
opportunities(first: 200) {
edges {
node {
id
name
stage
amount { amountMicros currencyCode }
company { id name }
pointOfContact { id name { firstName lastName } }
}
}
}
}`);
if (!data) return c.json({ nodes: [], edges: [], error: "Twenty API error" });
const d = data as any;
const nodes: Array<{ id: string; label: string; type: string; data: unknown }> = [];
const edges: Array<{ source: string; target: string; type: string }> = [];
const nodeIds = new Set<string>();
// People → nodes
for (const { node: p } of d.people?.edges || []) {
const label = [p.name?.firstName, p.name?.lastName].filter(Boolean).join(" ") || "Unknown";
nodes.push({ id: p.id, label, type: "person", data: { email: p.email?.primaryEmail } });
nodeIds.add(p.id);
// Person → Company edge
if (p.company?.id) {
edges.push({ source: p.id, target: p.company.id, type: "works_at" });
}
}
// Companies → nodes
for (const { node: co } of d.companies?.edges || []) {
nodes.push({ id: co.id, label: co.name || "Unknown", type: "company", data: { domain: co.domainName?.primaryLinkUrl, employees: co.employees } });
nodeIds.add(co.id);
}
// Opportunities → nodes + edges
for (const { node: opp } of d.opportunities?.edges || []) {
nodes.push({ id: opp.id, label: opp.name || "Opportunity", type: "opportunity", data: { stage: opp.stage, amount: opp.amount } });
nodeIds.add(opp.id);
if (opp.company?.id && nodeIds.has(opp.company.id)) {
edges.push({ source: opp.id, target: opp.company.id, type: "involves" });
}
if (opp.pointOfContact?.id && nodeIds.has(opp.pointOfContact.id)) {
edges.push({ source: opp.pointOfContact.id, target: opp.id, type: "involved_in" });
}
}
const result = { nodes, edges, demo: false };
graphCache = { data: result, ts: Date.now() };
c.header("Cache-Control", "public, max-age=60");
return c.json(result);
} catch (e) {
console.error("[Network] Graph fetch error:", e);
return c.json({ nodes: [], edges: [], error: "Graph fetch failed" });
}
});
// ── API: Workspaces ── // ── API: Workspaces ──
routes.get("/api/workspaces", (c) => { routes.get("/api/workspaces", (c) => {
// In production, this would scan Automerge graph docs
return c.json([ return c.json([
{ slug: "demo", name: "Demo Network", nodeCount: 0, edgeCount: 0 }, { slug: "demo", name: "Demo Network", nodeCount: 0, edgeCount: 0 },
]); ]);

View File

@ -12,6 +12,7 @@ import { sql } from "../../shared/db/pool";
import { renderShell } from "../../server/shell"; import { renderShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module"; import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module";
import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server";
const routes = new Hono(); const routes = new Hono();
@ -56,13 +57,19 @@ routes.get("/api/notebooks", async (c) => {
// POST /api/notebooks — create notebook // POST /api/notebooks — create notebook
routes.post("/api/notebooks", async (c) => { routes.post("/api/notebooks", 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 body = await c.req.json(); const body = await c.req.json();
const { title, description, cover_color } = body; const { title, description, cover_color } = body;
const user = await getOrCreateUser(claims.sub, claims.username);
const rows = await sql.unsafe( const rows = await sql.unsafe(
`INSERT INTO rnotes.notebooks (title, description, cover_color) `INSERT INTO rnotes.notebooks (title, description, cover_color, owner_id)
VALUES ($1, $2, $3) RETURNING *`, VALUES ($1, $2, $3, $4) RETURNING *`,
[title || "Untitled Notebook", description || null, cover_color || "#3b82f6"] [title || "Untitled Notebook", description || null, cover_color || "#3b82f6", user.id]
); );
return c.json(rows[0], 201); return c.json(rows[0], 201);
}); });
@ -88,6 +95,11 @@ routes.get("/api/notebooks/:id", async (c) => {
// PUT /api/notebooks/:id — update notebook // PUT /api/notebooks/:id — update notebook
routes.put("/api/notebooks/:id", async (c) => { routes.put("/api/notebooks/:id", 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 id = c.req.param("id"); const id = c.req.param("id");
const body = await c.req.json(); const body = await c.req.json();
const { title, description, cover_color, is_public } = body; const { title, description, cover_color, is_public } = body;
@ -158,6 +170,11 @@ routes.get("/api/notes", async (c) => {
// POST /api/notes — create note // POST /api/notes — create note
routes.post("/api/notes", async (c) => { routes.post("/api/notes", 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 body = await c.req.json(); const body = await c.req.json();
const { notebook_id, title, content, type, url, language, file_url, mime_type, file_size, duration, tags } = body; const { notebook_id, title, content, type, url, language, file_url, mime_type, file_size, duration, tags } = body;

View File

@ -12,6 +12,7 @@ import { sql } from "../../shared/db/pool";
import { renderShell } from "../../server/shell"; import { renderShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module"; import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module";
import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server";
const routes = new Hono(); const routes = new Hono();
@ -226,6 +227,10 @@ routes.get("/api/providers/:id", async (c) => {
// ── POST /api/providers — Register a new provider ── // ── POST /api/providers — Register a new provider ──
routes.post("/api/providers", async (c) => { routes.post("/api/providers", 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 body = await c.req.json(); const body = await c.req.json();
const { name, description, location, capabilities, substrates, turnaround, pricing, communities, contact, wallet } = body; const { name, description, location, capabilities, substrates, turnaround, pricing, communities, contact, wallet } = body;
@ -259,6 +264,10 @@ routes.post("/api/providers", async (c) => {
// ── PUT /api/providers/:id — Update provider ── // ── PUT /api/providers/:id — Update provider ──
routes.put("/api/providers/:id", async (c) => { routes.put("/api/providers/:id", 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 id = c.req.param("id"); const id = c.req.param("id");
const existing = await sql.unsafe("SELECT id FROM providers.providers WHERE id = $1", [id]); const existing = await sql.unsafe("SELECT id FROM providers.providers WHERE id = $1", [id]);
if (existing.length === 0) return c.json({ error: "Provider not found" }, 404); if (existing.length === 0) return c.json({ error: "Provider not found" }, 404);

View File

@ -12,6 +12,7 @@ import { sql } from "../../shared/db/pool";
import { renderShell } from "../../server/shell"; import { renderShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module"; import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module";
import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server";
const routes = new Hono(); const routes = new Hono();
@ -48,16 +49,21 @@ routes.get("/api/trips", async (c) => {
// POST /api/trips — create trip // POST /api/trips — create trip
routes.post("/api/trips", async (c) => { routes.post("/api/trips", 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 body = await c.req.json(); const body = await c.req.json();
const { title, description, start_date, end_date, budget_total, budget_currency } = body; const { title, description, start_date, end_date, budget_total, budget_currency } = body;
if (!title?.trim()) return c.json({ error: "Title required" }, 400); if (!title?.trim()) return c.json({ error: "Title required" }, 400);
const slug = title.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); const slug = title.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
const rows = await sql.unsafe( const rows = await sql.unsafe(
`INSERT INTO rtrips.trips (title, slug, description, start_date, end_date, budget_total, budget_currency) `INSERT INTO rtrips.trips (title, slug, description, start_date, end_date, budget_total, budget_currency, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`, VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`,
[title.trim(), slug, description || null, start_date || null, end_date || null, [title.trim(), slug, description || null, start_date || null, end_date || null,
budget_total || null, budget_currency || "USD"] budget_total || null, budget_currency || "USD", claims.sub]
); );
return c.json(rows[0], 201); return c.json(rows[0], 201);
}); });
@ -112,6 +118,10 @@ routes.put("/api/trips/:id", async (c) => {
// ── API: Destinations ── // ── API: Destinations ──
routes.post("/api/trips/:id/destinations", async (c) => { routes.post("/api/trips/:id/destinations", 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 body = await c.req.json(); const body = await c.req.json();
const rows = await sql.unsafe( const rows = await sql.unsafe(
`INSERT INTO rtrips.destinations (trip_id, name, country, lat, lng, arrival_date, departure_date, notes, sort_order) `INSERT INTO rtrips.destinations (trip_id, name, country, lat, lng, arrival_date, departure_date, notes, sort_order)
@ -125,6 +135,10 @@ routes.post("/api/trips/:id/destinations", async (c) => {
// ── API: Itinerary ── // ── API: Itinerary ──
routes.post("/api/trips/:id/itinerary", async (c) => { routes.post("/api/trips/:id/itinerary", 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 body = await c.req.json(); const body = await c.req.json();
const rows = await sql.unsafe( const rows = await sql.unsafe(
`INSERT INTO rtrips.itinerary_items (trip_id, destination_id, title, category, date, start_time, end_time, notes, sort_order) `INSERT INTO rtrips.itinerary_items (trip_id, destination_id, title, category, date, start_time, end_time, notes, sort_order)
@ -138,6 +152,10 @@ routes.post("/api/trips/:id/itinerary", async (c) => {
// ── API: Bookings ── // ── API: Bookings ──
routes.post("/api/trips/:id/bookings", async (c) => { routes.post("/api/trips/:id/bookings", 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 body = await c.req.json(); const body = await c.req.json();
const rows = await sql.unsafe( const rows = await sql.unsafe(
`INSERT INTO rtrips.bookings (trip_id, type, provider, confirmation_number, cost, currency, start_date, end_date, notes) `INSERT INTO rtrips.bookings (trip_id, type, provider, confirmation_number, cost, currency, start_date, end_date, notes)
@ -151,6 +169,10 @@ routes.post("/api/trips/:id/bookings", async (c) => {
// ── API: Expenses ── // ── API: Expenses ──
routes.post("/api/trips/:id/expenses", async (c) => { routes.post("/api/trips/:id/expenses", 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 body = await c.req.json(); const body = await c.req.json();
const rows = await sql.unsafe( const rows = await sql.unsafe(
`INSERT INTO rtrips.expenses (trip_id, description, amount, currency, category, date, split_type) `INSERT INTO rtrips.expenses (trip_id, description, amount, currency, category, date, split_type)
@ -172,6 +194,10 @@ routes.get("/api/trips/:id/packing", async (c) => {
}); });
routes.post("/api/trips/:id/packing", async (c) => { routes.post("/api/trips/:id/packing", 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 body = await c.req.json(); const body = await c.req.json();
const rows = await sql.unsafe( const rows = await sql.unsafe(
`INSERT INTO rtrips.packing_items (trip_id, name, category, quantity, sort_order) `INSERT INTO rtrips.packing_items (trip_id, name, category, quantity, sort_order)

View File

@ -9,23 +9,43 @@ import { Hono } from "hono";
import { renderShell } from "../../server/shell"; import { renderShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module"; import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module";
import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server";
import { S3Client, ListObjectsV2Command, GetObjectCommand, HeadObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3";
const routes = new Hono(); const routes = new Hono();
// ── R2 / S3 config ── // ── R2 / S3 config ──
const R2_ENDPOINT = process.env.R2_ENDPOINT || ""; const R2_ENDPOINT = process.env.R2_ENDPOINT || "";
const R2_BUCKET = process.env.R2_BUCKET || "rtube-videos"; const R2_BUCKET = process.env.R2_BUCKET || "rtube-videos";
const R2_ACCESS_KEY = process.env.R2_ACCESS_KEY || ""; const R2_ACCESS_KEY_ID = process.env.R2_ACCESS_KEY_ID || "";
const R2_SECRET_KEY = process.env.R2_SECRET_KEY || ""; const R2_SECRET_ACCESS_KEY = process.env.R2_SECRET_ACCESS_KEY || "";
const R2_PUBLIC_URL = process.env.R2_PUBLIC_URL || ""; const R2_PUBLIC_URL = process.env.R2_PUBLIC_URL || "";
const VIDEO_EXTENSIONS = new Set([ const VIDEO_EXTENSIONS = new Set([
".mp4", ".mkv", ".webm", ".mov", ".avi", ".wmv", ".flv", ".m4v", ".mp4", ".mkv", ".webm", ".mov", ".avi", ".wmv", ".flv", ".m4v",
]); ]);
// ── S3-compatible listing (lightweight, no AWS SDK needed) ── // ── S3 Client (lazy init) ──
async function listVideos(): Promise<Array<{ name: string; size: number }>> { let s3: S3Client | null = null;
if (!R2_ENDPOINT) { function getS3(): S3Client | null {
if (!R2_ENDPOINT) return null;
if (!s3) {
s3 = new S3Client({
region: "auto",
endpoint: R2_ENDPOINT,
credentials: {
accessKeyId: R2_ACCESS_KEY_ID,
secretAccessKey: R2_SECRET_ACCESS_KEY,
},
});
}
return s3;
}
// ── S3-compatible listing ──
async function listVideos(): Promise<Array<{ name: string; size: number; lastModified?: string }>> {
const client = getS3();
if (!client) {
// Return demo data when R2 not configured // Return demo data when R2 not configured
return [ return [
{ name: "welcome-to-rtube.mp4", size: 12_500_000 }, { name: "welcome-to-rtube.mp4", size: 12_500_000 },
@ -35,31 +55,24 @@ async function listVideos(): Promise<Array<{ name: string; size: number }>> {
} }
try { try {
// Use S3 ListObjectsV2 via signed request const command = new ListObjectsV2Command({ Bucket: R2_BUCKET, MaxKeys: 200 });
const url = `${R2_ENDPOINT}/${R2_BUCKET}?list-type=2&max-keys=200`; const response = await client.send(command);
const resp = await fetch(url, { const items: Array<{ name: string; size: number; lastModified?: string }> = [];
headers: {
Authorization: `AWS4-HMAC-SHA256 ...`, // Simplified — real impl uses aws4 signing
},
});
if (!resp.ok) return [];
const text = await resp.text(); for (const obj of response.Contents || []) {
// Parse simple XML response if (!obj.Key) continue;
const items: Array<{ name: string; size: number }> = []; const ext = obj.Key.substring(obj.Key.lastIndexOf(".")).toLowerCase();
const keyMatches = text.matchAll(/<Key>([^<]+)<\/Key>/g);
const sizeMatches = text.matchAll(/<Size>(\d+)<\/Size>/g);
const keys = [...keyMatches].map((m) => m[1]);
const sizes = [...sizeMatches].map((m) => parseInt(m[1]));
for (let i = 0; i < keys.length; i++) {
const ext = keys[i].substring(keys[i].lastIndexOf(".")).toLowerCase();
if (VIDEO_EXTENSIONS.has(ext)) { if (VIDEO_EXTENSIONS.has(ext)) {
items.push({ name: keys[i], size: sizes[i] || 0 }); items.push({
name: obj.Key,
size: obj.Size || 0,
lastModified: obj.LastModified?.toISOString(),
});
} }
} }
return items.sort((a, b) => a.name.localeCompare(b.name)); return items.sort((a, b) => a.name.localeCompare(b.name));
} catch { } catch (e) {
console.error("[Tube] S3 list error:", e);
return []; return [];
} }
} }
@ -69,7 +82,96 @@ async function listVideos(): Promise<Array<{ name: string; size: number }>> {
// GET /api/videos — list videos from bucket // GET /api/videos — list videos from bucket
routes.get("/api/videos", async (c) => { routes.get("/api/videos", async (c) => {
const videos = await listVideos(); const videos = await listVideos();
return c.json({ videos }); return c.json({ videos, r2Configured: !!R2_ENDPOINT });
});
// GET /api/v/:path — video streaming with HTTP range request support
routes.get("/api/v/*", async (c) => {
const client = getS3();
if (!client) return c.json({ error: "R2 not configured" }, 503);
const path = c.req.path.replace(/^.*\/api\/v\//, "");
if (!path) return c.json({ error: "Path required" }, 400);
try {
// Get object metadata for Content-Length
const head = await client.send(new HeadObjectCommand({ Bucket: R2_BUCKET, Key: path }));
const totalSize = head.ContentLength || 0;
const contentType = head.ContentType || "video/mp4";
const rangeHeader = c.req.header("range");
if (rangeHeader) {
// Parse range header
const match = rangeHeader.match(/bytes=(\d+)-(\d*)/);
if (!match) return c.json({ error: "Invalid range" }, 400);
const start = parseInt(match[1]);
const end = match[2] ? parseInt(match[2]) : Math.min(start + 5 * 1024 * 1024 - 1, totalSize - 1);
const chunkSize = end - start + 1;
const obj = await client.send(new GetObjectCommand({
Bucket: R2_BUCKET,
Key: path,
Range: `bytes=${start}-${end}`,
}));
return new Response(obj.Body as ReadableStream, {
status: 206,
headers: {
"Content-Type": contentType,
"Content-Length": String(chunkSize),
"Content-Range": `bytes ${start}-${end}/${totalSize}`,
"Accept-Ranges": "bytes",
"Cache-Control": "public, max-age=86400",
},
});
}
// No range — stream full file
const obj = await client.send(new GetObjectCommand({ Bucket: R2_BUCKET, Key: path }));
return new Response(obj.Body as ReadableStream, {
headers: {
"Content-Type": contentType,
"Content-Length": String(totalSize),
"Accept-Ranges": "bytes",
"Cache-Control": "public, max-age=86400",
},
});
} catch (e: any) {
if (e.name === "NoSuchKey") return c.json({ error: "Video not found" }, 404);
console.error("[Tube] Stream error:", e);
return c.json({ error: "Stream failed" }, 500);
}
});
// POST /api/videos — upload video (auth required)
routes.post("/api/videos", async (c) => {
const authToken = extractToken(c.req.raw.headers);
if (!authToken) return c.json({ error: "Authentication required" }, 401);
try { await verifyEncryptIDToken(authToken); } catch { return c.json({ error: "Invalid token" }, 401); }
const client = getS3();
if (!client) return c.json({ error: "R2 not configured" }, 503);
const formData = await c.req.formData();
const file = formData.get("video") as File | null;
if (!file) return c.json({ error: "video file required" }, 400);
const ext = file.name.substring(file.name.lastIndexOf(".")).toLowerCase();
if (!VIDEO_EXTENSIONS.has(ext)) return c.json({ error: `Unsupported format. Allowed: ${[...VIDEO_EXTENSIONS].join(", ")}` }, 400);
const key = formData.get("path")?.toString() || file.name;
const buffer = Buffer.from(await file.arrayBuffer());
await client.send(new PutObjectCommand({
Bucket: R2_BUCKET,
Key: key,
Body: buffer,
ContentType: file.type || "video/mp4",
ContentLength: buffer.length,
}));
return c.json({ ok: true, key, size: buffer.length, publicUrl: R2_PUBLIC_URL ? `${R2_PUBLIC_URL}/${key}` : undefined }, 201);
}); });
// GET /api/info — module info // GET /api/info — module info

View File

@ -12,6 +12,7 @@ import { sql } from "../../shared/db/pool";
import { renderShell } from "../../server/shell"; import { renderShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module"; import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module";
import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server";
const routes = new Hono(); const routes = new Hono();
@ -79,19 +80,21 @@ routes.get("/api/spaces", async (c) => {
// POST /api/spaces — create space // POST /api/spaces — create space
routes.post("/api/spaces", async (c) => { routes.post("/api/spaces", 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 body = await c.req.json(); const body = await c.req.json();
const { name, slug, description, visibility = "public_read" } = body; const { name, slug, description, visibility = "public_read" } = body;
if (!name || !slug) return c.json({ error: "name and slug required" }, 400); if (!name || !slug) return c.json({ error: "name and slug required" }, 400);
if (!/^[a-z0-9-]+$/.test(slug)) return c.json({ error: "Invalid slug" }, 400); if (!/^[a-z0-9-]+$/.test(slug)) return c.json({ error: "Invalid slug" }, 400);
// TODO: extract DID from auth header
const ownerDid = "anonymous";
try { try {
const rows = await sql.unsafe( const rows = await sql.unsafe(
`INSERT INTO rvote.spaces (slug, name, description, owner_did, visibility) `INSERT INTO rvote.spaces (slug, name, description, owner_did, visibility)
VALUES ($1, $2, $3, $4, $5) RETURNING *`, VALUES ($1, $2, $3, $4, $5) RETURNING *`,
[slug, name, description || null, ownerDid, visibility] [slug, name, description || null, claims.sub, visibility]
); );
return c.json(rows[0], 201); return c.json(rows[0], 201);
} catch (e: any) { } catch (e: any) {
@ -138,6 +141,11 @@ routes.get("/api/proposals", async (c) => {
// POST /api/proposals — create proposal // POST /api/proposals — create proposal
routes.post("/api/proposals", async (c) => { routes.post("/api/proposals", 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 body = await c.req.json(); const body = await c.req.json();
const { space_slug, title, description } = body; const { space_slug, title, description } = body;
if (!space_slug || !title) return c.json({ error: "space_slug and title required" }, 400); if (!space_slug || !title) return c.json({ error: "space_slug and title required" }, 400);
@ -146,10 +154,11 @@ routes.post("/api/proposals", async (c) => {
const space = await sql.unsafe("SELECT slug FROM rvote.spaces WHERE slug = $1", [space_slug]); const space = await sql.unsafe("SELECT slug FROM rvote.spaces WHERE slug = $1", [space_slug]);
if (space.length === 0) return c.json({ error: "Space not found" }, 404); if (space.length === 0) return c.json({ error: "Space not found" }, 404);
const user = await getOrCreateUser(claims.sub, claims.username);
const rows = await sql.unsafe( const rows = await sql.unsafe(
`INSERT INTO rvote.proposals (space_slug, author_id, title, description) `INSERT INTO rvote.proposals (space_slug, author_id, title, description)
VALUES ($1, (SELECT id FROM rvote.users LIMIT 1), $2, $3) RETURNING *`, VALUES ($1, $2, $3, $4) RETURNING *`,
[space_slug, title, description || null] [space_slug, user.id, title, description || null]
); );
return c.json(rows[0], 201); return c.json(rows[0], 201);
}); });
@ -169,6 +178,11 @@ routes.get("/api/proposals/:id", async (c) => {
// POST /api/proposals/:id/vote — cast conviction vote // POST /api/proposals/:id/vote — cast conviction vote
routes.post("/api/proposals/:id/vote", async (c) => { routes.post("/api/proposals/:id/vote", 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 id = c.req.param("id"); const id = c.req.param("id");
const body = await c.req.json(); const body = await c.req.json();
const { weight = 1 } = body; const { weight = 1 } = body;
@ -181,15 +195,16 @@ routes.post("/api/proposals/:id/vote", async (c) => {
if (proposal.length === 0) return c.json({ error: "Proposal not found" }, 404); if (proposal.length === 0) return c.json({ error: "Proposal not found" }, 404);
if (proposal[0].status !== "RANKING") return c.json({ error: "Proposal not in ranking phase" }, 400); if (proposal[0].status !== "RANKING") return c.json({ error: "Proposal not in ranking phase" }, 400);
const user = await getOrCreateUser(claims.sub, claims.username);
const creditCost = weight * weight; // quadratic cost const creditCost = weight * weight; // quadratic cost
// Upsert vote // Upsert vote
await sql.unsafe( await sql.unsafe(
`INSERT INTO rvote.votes (user_id, proposal_id, weight, credit_cost, decays_at) `INSERT INTO rvote.votes (user_id, proposal_id, weight, credit_cost, decays_at)
VALUES ((SELECT id FROM rvote.users LIMIT 1), $1, $2, $3, NOW() + INTERVAL '30 days') VALUES ($1, $2, $3, $4, NOW() + INTERVAL '30 days')
ON CONFLICT (user_id, proposal_id) ON CONFLICT (user_id, proposal_id)
DO UPDATE SET weight = $2, credit_cost = $3, created_at = NOW(), decays_at = NOW() + INTERVAL '30 days'`, DO UPDATE SET weight = $3, credit_cost = $4, created_at = NOW(), decays_at = NOW() + INTERVAL '30 days'`,
[id, weight, creditCost] [user.id, id, weight, creditCost]
); );
// Recalculate score and check for promotion // Recalculate score and check for promotion
@ -213,6 +228,11 @@ routes.post("/api/proposals/:id/vote", async (c) => {
// POST /api/proposals/:id/final-vote — cast binary vote // POST /api/proposals/:id/final-vote — cast binary vote
routes.post("/api/proposals/:id/final-vote", async (c) => { routes.post("/api/proposals/:id/final-vote", 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 id = c.req.param("id"); const id = c.req.param("id");
const body = await c.req.json(); const body = await c.req.json();
const { vote } = body; const { vote } = body;
@ -222,11 +242,12 @@ routes.post("/api/proposals/:id/final-vote", async (c) => {
if (proposal.length === 0) return c.json({ error: "Proposal not found" }, 404); if (proposal.length === 0) return c.json({ error: "Proposal not found" }, 404);
if (proposal[0].status !== "VOTING") return c.json({ error: "Proposal not in voting phase" }, 400); if (proposal[0].status !== "VOTING") return c.json({ error: "Proposal not in voting phase" }, 400);
const user = await getOrCreateUser(claims.sub, claims.username);
await sql.unsafe( await sql.unsafe(
`INSERT INTO rvote.final_votes (user_id, proposal_id, vote) `INSERT INTO rvote.final_votes (user_id, proposal_id, vote)
VALUES ((SELECT id FROM rvote.users LIMIT 1), $1, $2) VALUES ($1, $2, $3)
ON CONFLICT (user_id, proposal_id) DO UPDATE SET vote = $2`, ON CONFLICT (user_id, proposal_id) DO UPDATE SET vote = $3`,
[id, vote] [user.id, id, vote]
); );
// Update counts // Update counts

View File

@ -21,7 +21,9 @@ routes.get("/api/safe/:chainId/:address/balances", async (c) => {
const res = await fetch(`https://safe-transaction-${chainPrefix}.safe.global/api/v1/safes/${address}/balances/usd/?trusted=true&exclude_spam=true`); const res = await fetch(`https://safe-transaction-${chainPrefix}.safe.global/api/v1/safes/${address}/balances/usd/?trusted=true&exclude_spam=true`);
if (!res.ok) return c.json({ error: "Safe API error" }, res.status as any); if (!res.ok) return c.json({ error: "Safe API error" }, res.status as any);
return c.json(await res.json()); const data = await res.json();
c.header("Cache-Control", "public, max-age=30");
return c.json(data);
}); });
routes.get("/api/safe/:chainId/:address/transfers", async (c) => { routes.get("/api/safe/:chainId/:address/transfers", async (c) => {
@ -44,7 +46,9 @@ routes.get("/api/safe/:chainId/:address/info", async (c) => {
const res = await fetch(`https://safe-transaction-${chainPrefix}.safe.global/api/v1/safes/${address}/`); const res = await fetch(`https://safe-transaction-${chainPrefix}.safe.global/api/v1/safes/${address}/`);
if (!res.ok) return c.json({ error: "Safe API error" }, res.status as any); if (!res.ok) return c.json({ error: "Safe API error" }, res.status as any);
return c.json(await res.json()); const data = await res.json();
c.header("Cache-Control", "public, max-age=300");
return c.json(data);
}); });
// Detect which chains have a Safe for this address // Detect which chains have a Safe for this address
@ -79,6 +83,8 @@ const CHAIN_MAP: Record<string, { name: string; prefix: string }> = {
"43114": { name: "Avalanche", prefix: "avalanche" }, "43114": { name: "Avalanche", prefix: "avalanche" },
"56": { name: "BSC", prefix: "bsc" }, "56": { name: "BSC", prefix: "bsc" },
"324": { name: "zkSync", prefix: "zksync" }, "324": { name: "zkSync", prefix: "zksync" },
"11155111": { name: "Sepolia", prefix: "sepolia" },
"84532": { name: "Base Sepolia", prefix: "base-sepolia" },
}; };
function getSafePrefix(chainId: string): string | null { function getSafePrefix(chainId: string): string | null {

View File

@ -12,6 +12,7 @@ import { sql } from "../../shared/db/pool";
import { renderShell } from "../../server/shell"; import { renderShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module"; import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module";
import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server";
const routes = new Hono(); const routes = new Hono();
@ -45,15 +46,20 @@ routes.get("/api/spaces", async (c) => {
// POST /api/spaces — create workspace // POST /api/spaces — create workspace
routes.post("/api/spaces", async (c) => { routes.post("/api/spaces", 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 body = await c.req.json(); const body = await c.req.json();
const { name, description, icon } = body; const { name, description, icon } = body;
if (!name?.trim()) return c.json({ error: "Name required" }, 400); if (!name?.trim()) return c.json({ error: "Name required" }, 400);
const slug = name.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); const slug = name.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
const rows = await sql.unsafe( const rows = await sql.unsafe(
`INSERT INTO rwork.spaces (name, slug, description, icon) `INSERT INTO rwork.spaces (name, slug, description, icon, created_by)
VALUES ($1, $2, $3, $4) RETURNING *`, VALUES ($1, $2, $3, $4, $5) RETURNING *`,
[name.trim(), slug, description || null, icon || null] [name.trim(), slug, description || null, icon || null, claims.sub]
); );
return c.json(rows[0], 201); return c.json(rows[0], 201);
}); });
@ -84,6 +90,11 @@ routes.get("/api/spaces/:slug/tasks", async (c) => {
// POST /api/spaces/:slug/tasks — create task // POST /api/spaces/:slug/tasks — create task
routes.post("/api/spaces/:slug/tasks", async (c) => { routes.post("/api/spaces/:slug/tasks", 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 slug = c.req.param("slug"); const slug = c.req.param("slug");
const body = await c.req.json(); const body = await c.req.json();
const { title, description, status, priority, labels } = body; const { title, description, status, priority, labels } = body;
@ -94,15 +105,22 @@ routes.post("/api/spaces/:slug/tasks", async (c) => {
const taskStatus = status || space[0].statuses?.[0] || "TODO"; const taskStatus = status || space[0].statuses?.[0] || "TODO";
const rows = await sql.unsafe( const rows = await sql.unsafe(
`INSERT INTO rwork.tasks (space_id, title, description, status, priority, labels) `INSERT INTO rwork.tasks (space_id, title, description, status, priority, labels, created_by)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`, VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`,
[space[0].id, title.trim(), description || null, taskStatus, priority || "MEDIUM", labels || []] [space[0].id, title.trim(), description || null, taskStatus, priority || "MEDIUM", labels || [], claims.sub]
); );
return c.json(rows[0], 201); return c.json(rows[0], 201);
}); });
// PATCH /api/tasks/:id — update task (status change, assignment, etc.) // PATCH /api/tasks/:id — update task (status change, assignment, etc.)
routes.patch("/api/tasks/:id", async (c) => { routes.patch("/api/tasks/:id", async (c) => {
// Optional auth — track who updated
const token = extractToken(c.req.raw.headers);
let updatedBy: string | null = null;
if (token) {
try { const claims = await verifyEncryptIDToken(token); updatedBy = claims.sub; } catch {}
}
const id = c.req.param("id"); const id = c.req.param("id");
const body = await c.req.json(); const body = await c.req.json();
const { title, description, status, priority, labels, sort_order, assignee_id } = body; const { title, description, status, priority, labels, sort_order, assignee_id } = body;

4318
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -21,10 +21,14 @@
"nodemailer": "^6.9.0", "nodemailer": "^6.9.0",
"sharp": "^0.33.0", "sharp": "^0.33.0",
"perfect-arrows": "^0.3.7", "perfect-arrows": "^0.3.7",
"perfect-freehand": "^1.2.2" "perfect-freehand": "^1.2.2",
"@aws-sdk/client-s3": "^3.700.0",
"imapflow": "^1.0.170",
"mailparser": "^3.7.2"
}, },
"devDependencies": { "devDependencies": {
"@types/nodemailer": "^6.4.0", "@types/nodemailer": "^6.4.0",
"@types/mailparser": "^3.4.0",
"@types/node": "^22.10.1", "@types/node": "^22.10.1",
"bun-types": "^1.1.38", "bun-types": "^1.1.38",
"typescript": "^5.7.2", "typescript": "^5.7.2",

View File

@ -59,6 +59,7 @@ import { networkModule } from "../modules/network/mod";
import { tubeModule } from "../modules/tube/mod"; import { tubeModule } from "../modules/tube/mod";
import { inboxModule } from "../modules/inbox/mod"; import { inboxModule } from "../modules/inbox/mod";
import { dataModule } from "../modules/data/mod"; import { dataModule } from "../modules/data/mod";
import { conicModule } from "../modules/conic/mod";
import { spaces } from "./spaces"; import { spaces } from "./spaces";
import { renderShell } from "./shell"; import { renderShell } from "./shell";
@ -84,6 +85,7 @@ registerModule(networkModule);
registerModule(tubeModule); registerModule(tubeModule);
registerModule(inboxModule); registerModule(inboxModule);
registerModule(dataModule); registerModule(dataModule);
registerModule(conicModule);
// ── Config ── // ── Config ──
const PORT = Number(process.env.PORT) || 3000; const PORT = Number(process.env.PORT) || 3000;

View File

@ -625,6 +625,40 @@ export default defineConfig({
resolve(__dirname, "modules/data/components/data.css"), resolve(__dirname, "modules/data/components/data.css"),
resolve(__dirname, "dist/modules/data/data.css"), resolve(__dirname, "dist/modules/data/data.css"),
); );
// Build conic module component
await build({
configFile: false,
root: resolve(__dirname, "modules/conic/components"),
resolve: {
alias: {
"../lib/types": resolve(__dirname, "modules/conic/lib/types.ts"),
"../lib/conic-math": resolve(__dirname, "modules/conic/lib/conic-math.ts"),
"../lib/projection": resolve(__dirname, "modules/conic/lib/projection.ts"),
},
},
build: {
emptyOutDir: false,
outDir: resolve(__dirname, "dist/modules/conic"),
lib: {
entry: resolve(__dirname, "modules/conic/components/folk-conic-viewer.ts"),
formats: ["es"],
fileName: () => "folk-conic-viewer.js",
},
rollupOptions: {
output: {
entryFileNames: "folk-conic-viewer.js",
},
},
},
});
// Copy conic CSS
mkdirSync(resolve(__dirname, "dist/modules/conic"), { recursive: true });
copyFileSync(
resolve(__dirname, "modules/conic/components/conic.css"),
resolve(__dirname, "dist/modules/conic/conic.css"),
);
}, },
}, },
}, },