mirror of
https://github.com/AeThex-Corporation/AeThex-OS.git
synced 2026-04-17 22:27:19 +00:00
3329 lines
99 KiB
TypeScript
3329 lines
99 KiB
TypeScript
import type { Express, Request, Response, NextFunction } from "express";
|
|
import { createServer, type Server } from "http";
|
|
import { spawn, type ChildProcessWithoutNullStreams } from "child_process";
|
|
import { randomUUID } from "crypto";
|
|
import { storage } from "./storage.js";
|
|
import { loginSchema, signupSchema } from "../shared/schema.js";
|
|
import { supabase } from "./supabase.js";
|
|
import { getChatResponse } from "./openai.js";
|
|
import { capabilityGuard } from "./capability-guard.js";
|
|
import { startOAuthLinking, handleOAuthCallback } from "./oauth-handlers.js";
|
|
import communityRoutes from "./community-routes.js";
|
|
import messagingRoutes from "./messaging-routes.js";
|
|
import gameforgeRoutes from "./gameforge-routes.js";
|
|
import callRoutes from "./call-routes.js";
|
|
import discordRoutes from "./discord-routes.js";
|
|
import { socketService } from "./socket-service.js";
|
|
import { attachOrgContext, requireOrgMember, assertProjectAccess } from "./org-middleware.js";
|
|
import { orgScoped, orgEq, getOrgIdOrThrow } from "./org-storage.js";
|
|
|
|
// Extend session type
|
|
declare module 'express-session' {
|
|
interface SessionData {
|
|
userId?: string;
|
|
isAdmin?: boolean;
|
|
accessToken?: string;
|
|
}
|
|
}
|
|
|
|
// Auth middleware - requires any authenticated user
|
|
function requireAuth(req: Request, res: Response, next: NextFunction) {
|
|
if (!req.session.userId) {
|
|
return res.status(401).json({ error: "Unauthorized" });
|
|
}
|
|
next();
|
|
}
|
|
|
|
// Admin middleware - requires authenticated admin user
|
|
function requireAdmin(req: Request, res: Response, next: NextFunction) {
|
|
if (!req.session.userId) {
|
|
return res.status(401).json({ error: "Unauthorized" });
|
|
}
|
|
if (!req.session.isAdmin) {
|
|
return res.status(403).json({ error: "Admin access required" });
|
|
}
|
|
next();
|
|
}
|
|
|
|
// Project access middleware - requires project access with minimum role
|
|
function requireProjectAccess(minRole: 'owner' | 'admin' | 'contributor' | 'viewer' = 'viewer') {
|
|
return async (req: Request, res: Response, next: NextFunction) => {
|
|
const projectId = req.params.id || req.params.projectId || req.body.project_id;
|
|
if (!projectId) {
|
|
return res.status(400).json({ error: "Project ID required" });
|
|
}
|
|
|
|
const userId = req.session.userId;
|
|
if (!userId) {
|
|
return res.status(401).json({ error: "Unauthorized" });
|
|
}
|
|
|
|
const accessCheck = await assertProjectAccess(projectId, userId, minRole);
|
|
|
|
if (!accessCheck.hasAccess) {
|
|
return res.status(403).json({
|
|
error: "Access denied",
|
|
message: accessCheck.reason || "You do not have permission to access this project"
|
|
});
|
|
}
|
|
|
|
// Attach project to request for later use
|
|
(req as any).project = accessCheck.project;
|
|
next();
|
|
};
|
|
}
|
|
|
|
export async function registerRoutes(
|
|
httpServer: Server,
|
|
app: Express
|
|
): Promise<Server> {
|
|
|
|
// Initialize Socket.io for real-time messaging
|
|
socketService.initialize(httpServer);
|
|
|
|
// ===== Admin CLI process registry =====
|
|
const CLI_ALLOWLIST: Record<string, { cmd: string; args: string[]; label: string }> = {
|
|
build: { cmd: "npm", args: ["run", "build"], label: "npm run build" },
|
|
"migrate-status": { cmd: "npx", args: ["drizzle-kit", "status"], label: "drizzle status" },
|
|
migrate: { cmd: "npx", args: ["drizzle-kit", "migrate:push"], label: "drizzle migrate" },
|
|
"migrate-os": { cmd: "npx", args: ["ts-node", "script/run-os-migration.ts"], label: "os kernel migrate" },
|
|
seed: { cmd: "npx", args: ["ts-node", "script/seed.ts"], label: "seed" },
|
|
test: { cmd: "bash", args: ["./test-implementation.sh"], label: "test-implementation" },
|
|
};
|
|
|
|
const cliProcesses = new Map<string, { proc: ChildProcessWithoutNullStreams; status: "running" | "exited" | "error" }>();
|
|
|
|
// Apply capability guard to Hub and OS routes
|
|
app.use("/api/hub/*", capabilityGuard);
|
|
app.use("/api/os/entitlements/*", capabilityGuard);
|
|
app.use("/api/os/link/*", capabilityGuard);
|
|
|
|
// Mount community routes (events, opportunities, messages)
|
|
app.use("/api", communityRoutes);
|
|
|
|
// Mount messaging routes (conversations, direct messages)
|
|
app.use("/api/conversations", messagingRoutes);
|
|
|
|
// Mount GameForge routes (projects, team, builds, sprints, tasks)
|
|
app.use("/api/gameforge", gameforgeRoutes);
|
|
|
|
// Mount call routes (voice/video calls)
|
|
app.use("/api/calls", callRoutes);
|
|
|
|
// Mount Discord integration routes
|
|
app.use("/api/discord", discordRoutes);
|
|
|
|
// ========== ADMIN CLI ROUTES ==========
|
|
|
|
// Start a CLI command
|
|
app.post("/api/admin/cli/start", requireAdmin, async (req, res) => {
|
|
try {
|
|
const { command } = req.body;
|
|
|
|
if (!command || !CLI_ALLOWLIST[command]) {
|
|
return res.status(400).json({
|
|
error: "Invalid command",
|
|
allowed: Object.keys(CLI_ALLOWLIST).map(k => ({ key: k, label: CLI_ALLOWLIST[k].label }))
|
|
});
|
|
}
|
|
|
|
const config = CLI_ALLOWLIST[command];
|
|
const processId = randomUUID();
|
|
|
|
// Spawn the process
|
|
const proc = spawn(config.cmd, config.args, {
|
|
cwd: process.cwd(),
|
|
shell: true,
|
|
env: { ...process.env }
|
|
});
|
|
|
|
cliProcesses.set(processId, { proc, status: "running" });
|
|
|
|
// Handle process exit
|
|
proc.on("close", (code) => {
|
|
const entry = cliProcesses.get(processId);
|
|
if (entry) {
|
|
entry.status = code === 0 ? "exited" : "error";
|
|
}
|
|
});
|
|
|
|
proc.on("error", (err) => {
|
|
const entry = cliProcesses.get(processId);
|
|
if (entry) {
|
|
entry.status = "error";
|
|
}
|
|
console.error(`[CLI ${processId}] Error:`, err);
|
|
});
|
|
|
|
res.json({
|
|
success: true,
|
|
processId,
|
|
command: config.label
|
|
});
|
|
} catch (err: any) {
|
|
console.error("[CLI Start]", err);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Stream output from a CLI process
|
|
app.get("/api/admin/cli/stream/:id", requireAdmin, async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
const entry = cliProcesses.get(id);
|
|
|
|
if (!entry) {
|
|
return res.status(404).json({ error: "Process not found" });
|
|
}
|
|
|
|
// Set up Server-Sent Events
|
|
res.setHeader("Content-Type", "text/event-stream");
|
|
res.setHeader("Cache-Control", "no-cache");
|
|
res.setHeader("Connection", "keep-alive");
|
|
res.flushHeaders();
|
|
|
|
const { proc, status } = entry;
|
|
|
|
// If already finished, send status and close
|
|
if (status !== "running") {
|
|
res.write(`data: ${JSON.stringify({ type: "status", status })}\n\n`);
|
|
res.write(`data: ${JSON.stringify({ type: "end" })}\n\n`);
|
|
return res.end();
|
|
}
|
|
|
|
// Stream stdout
|
|
proc.stdout.on("data", (data: Buffer) => {
|
|
res.write(`data: ${JSON.stringify({ type: "stdout", data: data.toString() })}\n\n`);
|
|
});
|
|
|
|
// Stream stderr
|
|
proc.stderr.on("data", (data: Buffer) => {
|
|
res.write(`data: ${JSON.stringify({ type: "stderr", data: data.toString() })}\n\n`);
|
|
});
|
|
|
|
// Handle process close
|
|
proc.on("close", (code) => {
|
|
res.write(`data: ${JSON.stringify({ type: "exit", code })}\n\n`);
|
|
res.write(`data: ${JSON.stringify({ type: "end" })}\n\n`);
|
|
res.end();
|
|
});
|
|
|
|
// Handle client disconnect
|
|
req.on("close", () => {
|
|
// Don't kill the process, just stop streaming
|
|
});
|
|
} catch (err: any) {
|
|
console.error("[CLI Stream]", err);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Kill a running CLI process
|
|
app.post("/api/admin/cli/kill/:id", requireAdmin, async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
const entry = cliProcesses.get(id);
|
|
|
|
if (!entry) {
|
|
return res.status(404).json({ error: "Process not found" });
|
|
}
|
|
|
|
if (entry.status === "running") {
|
|
entry.proc.kill("SIGTERM");
|
|
entry.status = "exited";
|
|
}
|
|
|
|
res.json({ success: true });
|
|
} catch (err: any) {
|
|
console.error("[CLI Kill]", err);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// List available CLI commands
|
|
app.get("/api/admin/cli/commands", requireAdmin, async (req, res) => {
|
|
res.json({
|
|
commands: Object.entries(CLI_ALLOWLIST).map(([key, config]) => ({
|
|
key,
|
|
label: config.label
|
|
}))
|
|
});
|
|
});
|
|
|
|
// ========== OAUTH ROUTES ==========
|
|
|
|
// Start OAuth linking flow (get authorization URL)
|
|
app.post("/api/oauth/link/:provider", requireAuth, startOAuthLinking);
|
|
|
|
// OAuth callback (provider redirects here with code)
|
|
app.get("/api/oauth/callback/:provider", handleOAuthCallback);
|
|
|
|
// ========== AETHEX COMPILER AND APP ROUTES ==========
|
|
|
|
// Compile AeThex code
|
|
app.post("/api/aethex/compile", requireAuth, async (req, res) => {
|
|
try {
|
|
const { code, target = "javascript" } = req.body;
|
|
|
|
if (!code) {
|
|
return res.status(400).json({ error: "Code is required" });
|
|
}
|
|
|
|
if (!["javascript", "roblox", "uefn", "unity"].includes(target)) {
|
|
return res.status(400).json({ error: "Invalid target. Must be: javascript, roblox, uefn, or unity" });
|
|
}
|
|
|
|
// Write source to temp file
|
|
const crypto = await import("crypto");
|
|
const fs = await import("fs");
|
|
const path = await import("path");
|
|
const os = await import("os");
|
|
|
|
const tempId = crypto.randomUUID();
|
|
const tempDir = path.join(os.tmpdir(), "aethex-compile");
|
|
await fs.promises.mkdir(tempDir, { recursive: true });
|
|
|
|
const sourceFile = path.join(tempDir, `${tempId}.aethex`);
|
|
await fs.promises.writeFile(sourceFile, code, "utf-8");
|
|
|
|
// Compile using the AeThex CLI
|
|
const { spawn } = await import("child_process");
|
|
const compilerPath = path.join(process.cwd(), "packages/aethex-cli/bin/aethex.js");
|
|
|
|
return new Promise((resolve) => {
|
|
const proc = spawn("node", [compilerPath, "compile", sourceFile, "-t", target], {
|
|
cwd: process.cwd(),
|
|
});
|
|
|
|
let stdout = "";
|
|
let stderr = "";
|
|
|
|
proc.stdout.on("data", (data) => {
|
|
stdout += data.toString();
|
|
});
|
|
|
|
proc.stderr.on("data", (data) => {
|
|
stderr += data.toString();
|
|
});
|
|
|
|
proc.on("close", async (code) => {
|
|
try {
|
|
// Read compiled output
|
|
const ext = target === "roblox" ? "lua" : target === "javascript" ? "js" : target;
|
|
const outputFile = sourceFile.replace(".aethex", `.${ext}`);
|
|
|
|
if (code === 0 && await fs.promises.access(outputFile).then(() => true).catch(() => false)) {
|
|
const output = await fs.promises.readFile(outputFile, "utf-8");
|
|
|
|
// Cleanup
|
|
await fs.promises.unlink(sourceFile).catch(() => {});
|
|
await fs.promises.unlink(outputFile).catch(() => {});
|
|
|
|
res.json({
|
|
success: true,
|
|
output,
|
|
target,
|
|
message: "Compilation successful"
|
|
});
|
|
} else {
|
|
// Cleanup on error
|
|
await fs.promises.unlink(sourceFile).catch(() => {});
|
|
|
|
res.status(400).json({
|
|
success: false,
|
|
error: "Compilation failed",
|
|
details: stderr || stdout || "Unknown error"
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error("Compile error:", error);
|
|
res.status(500).json({ error: "Failed to read compilation output" });
|
|
}
|
|
resolve(undefined);
|
|
});
|
|
});
|
|
} catch (error) {
|
|
console.error("Compilation error:", error);
|
|
res.status(500).json({ error: "Compilation failed" });
|
|
}
|
|
});
|
|
|
|
// Create or update an AeThex app
|
|
app.post("/api/aethex/apps", requireAuth, async (req, res) => {
|
|
try {
|
|
const { name, description, source_code, icon_url, category, is_public, tags, targets } = req.body;
|
|
const userId = req.session.userId!;
|
|
|
|
if (!name || !source_code) {
|
|
return res.status(400).json({ error: "Name and source_code are required" });
|
|
}
|
|
|
|
// Compile the code to JavaScript by default
|
|
const compileRes = await fetch(`http://localhost:${process.env.PORT || 5000}/api/aethex/compile`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"Cookie": req.headers.cookie || "",
|
|
},
|
|
body: JSON.stringify({ code: source_code, target: "javascript" }),
|
|
});
|
|
|
|
const compileData = await compileRes.json();
|
|
|
|
if (!compileData.success) {
|
|
return res.status(400).json({
|
|
error: "App code failed to compile",
|
|
details: compileData.details
|
|
});
|
|
}
|
|
|
|
const { data: app, error } = await supabase
|
|
.from("aethex_apps")
|
|
.insert({
|
|
owner_id: userId,
|
|
name,
|
|
description,
|
|
source_code,
|
|
compiled_js: compileData.output,
|
|
icon_url,
|
|
category: category || "utility",
|
|
is_public: is_public || false,
|
|
tags: tags || [],
|
|
targets: targets || ["javascript"],
|
|
})
|
|
.select()
|
|
.single();
|
|
|
|
if (error) throw error;
|
|
|
|
res.json({ success: true, app });
|
|
} catch (error) {
|
|
console.error("App creation error:", error);
|
|
res.status(500).json({ error: "Failed to create app" });
|
|
}
|
|
});
|
|
|
|
// Get all public apps (App Store)
|
|
app.get("/api/aethex/apps", async (req, res) => {
|
|
try {
|
|
const { category, featured, search } = req.query;
|
|
|
|
let query = supabase.from("aethex_apps").select("*").eq("is_public", true);
|
|
|
|
if (category) {
|
|
query = query.eq("category", category);
|
|
}
|
|
|
|
if (featured === "true") {
|
|
query = query.eq("is_featured", true);
|
|
}
|
|
|
|
if (search) {
|
|
query = query.or(`name.ilike.%${search}%,description.ilike.%${search}%`);
|
|
}
|
|
|
|
const { data: apps, error } = await query.order("install_count", { ascending: false }).limit(50);
|
|
|
|
if (error) throw error;
|
|
|
|
res.json({ apps });
|
|
} catch (error) {
|
|
console.error("Apps fetch error:", error);
|
|
res.status(500).json({ error: "Failed to fetch apps" });
|
|
}
|
|
});
|
|
|
|
// Get user's own apps
|
|
app.get("/api/aethex/apps/my", requireAuth, async (req, res) => {
|
|
try {
|
|
const userId = req.session.userId!;
|
|
|
|
const { data: apps, error } = await supabase
|
|
.from("aethex_apps")
|
|
.select("*")
|
|
.eq("owner_id", userId)
|
|
.order("updated_at", { ascending: false });
|
|
|
|
if (error) throw error;
|
|
|
|
res.json({ apps });
|
|
} catch (error) {
|
|
console.error("My apps fetch error:", error);
|
|
res.status(500).json({ error: "Failed to fetch your apps" });
|
|
}
|
|
});
|
|
|
|
// Get a specific app
|
|
app.get("/api/aethex/apps/:id", async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
|
|
const { data: app, error } = await supabase
|
|
.from("aethex_apps")
|
|
.select("*")
|
|
.eq("id", id)
|
|
.single();
|
|
|
|
if (error || !app) {
|
|
return res.status(404).json({ error: "App not found" });
|
|
}
|
|
|
|
// Only return source code if user is the owner or app is public
|
|
if (!app.is_public && app.owner_id !== req.session.userId) {
|
|
return res.status(403).json({ error: "Access denied" });
|
|
}
|
|
|
|
res.json({ app });
|
|
} catch (error) {
|
|
console.error("App fetch error:", error);
|
|
res.status(500).json({ error: "Failed to fetch app" });
|
|
}
|
|
});
|
|
|
|
// Install an app
|
|
app.post("/api/aethex/apps/:id/install", requireAuth, async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
const userId = req.session.userId!;
|
|
|
|
// Check if app exists
|
|
const { data: app, error: appError } = await supabase
|
|
.from("aethex_apps")
|
|
.select("*")
|
|
.eq("id", id)
|
|
.eq("is_public", true)
|
|
.single();
|
|
|
|
if (appError || !app) {
|
|
return res.status(404).json({ error: "App not found or not public" });
|
|
}
|
|
|
|
// Check if already installed
|
|
const { data: existing } = await supabase
|
|
.from("aethex_app_installations")
|
|
.select("*")
|
|
.eq("app_id", id)
|
|
.eq("user_id", userId)
|
|
.single();
|
|
|
|
if (existing) {
|
|
return res.json({ success: true, message: "App already installed", installation: existing });
|
|
}
|
|
|
|
// Install the app
|
|
const { data: installation, error } = await supabase
|
|
.from("aethex_app_installations")
|
|
.insert({
|
|
app_id: id,
|
|
user_id: userId,
|
|
})
|
|
.select()
|
|
.single();
|
|
|
|
if (error) throw error;
|
|
|
|
// Increment install count
|
|
await supabase
|
|
.from("aethex_apps")
|
|
.update({ install_count: (app.install_count || 0) + 1 })
|
|
.eq("id", id);
|
|
|
|
res.json({ success: true, installation });
|
|
} catch (error) {
|
|
console.error("App installation error:", error);
|
|
res.status(500).json({ error: "Failed to install app" });
|
|
}
|
|
});
|
|
|
|
// Get user's installed apps
|
|
app.get("/api/aethex/apps/installed/my", requireAuth, async (req, res) => {
|
|
try {
|
|
const userId = req.session.userId!;
|
|
|
|
const { data: installations, error } = await supabase
|
|
.from("aethex_app_installations")
|
|
.select(`
|
|
*,
|
|
app:aethex_apps(*)
|
|
`)
|
|
.eq("user_id", userId)
|
|
.order("installed_at", { ascending: false });
|
|
|
|
if (error) throw error;
|
|
|
|
res.json({ installations });
|
|
} catch (error) {
|
|
console.error("Installed apps fetch error:", error);
|
|
res.status(500).json({ error: "Failed to fetch installed apps" });
|
|
}
|
|
});
|
|
|
|
// Run an installed app (get compiled code)
|
|
app.post("/api/aethex/apps/:id/run", requireAuth, async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
const userId = req.session.userId!;
|
|
|
|
// Check if user has installed the app
|
|
const { data: installation } = await supabase
|
|
.from("aethex_app_installations")
|
|
.select(`
|
|
*,
|
|
app:aethex_apps(*)
|
|
`)
|
|
.eq("app_id", id)
|
|
.eq("user_id", userId)
|
|
.single();
|
|
|
|
if (!installation) {
|
|
return res.status(403).json({ error: "App not installed" });
|
|
}
|
|
|
|
// Update last_used_at
|
|
await supabase
|
|
.from("aethex_app_installations")
|
|
.update({ last_used_at: new Date().toISOString() })
|
|
.eq("id", installation.id);
|
|
|
|
res.json({
|
|
success: true,
|
|
app: installation.app,
|
|
compiled_code: (installation.app as any).compiled_js
|
|
});
|
|
} catch (error) {
|
|
console.error("App run error:", error);
|
|
res.status(500).json({ error: "Failed to run app" });
|
|
}
|
|
});
|
|
|
|
// ========== MODE MANAGEMENT ROUTES ==========
|
|
|
|
// Get user mode preference
|
|
app.get("/api/user/mode-preference", requireAuth, async (req, res) => {
|
|
try {
|
|
const { data, error } = await supabase
|
|
.from("aethex_user_mode_preference")
|
|
.select("mode")
|
|
.eq("user_id", req.session.userId)
|
|
.single();
|
|
|
|
if (error && error.code !== "PGRST116") {
|
|
throw error;
|
|
}
|
|
|
|
res.json({ mode: data?.mode || "foundation" });
|
|
} catch (error) {
|
|
console.error("Mode fetch error:", error);
|
|
res.status(500).json({ error: "Failed to fetch mode preference" });
|
|
}
|
|
});
|
|
|
|
// Update user mode preference
|
|
app.put("/api/user/mode-preference", requireAuth, async (req, res) => {
|
|
try {
|
|
const { mode } = req.body;
|
|
|
|
if (!mode || !["foundation", "corporation"].includes(mode)) {
|
|
return res.status(400).json({ error: "Invalid mode" });
|
|
}
|
|
|
|
const { error } = await supabase
|
|
.from("aethex_user_mode_preference")
|
|
.upsert({
|
|
user_id: req.session.userId,
|
|
mode,
|
|
updated_at: new Date().toISOString(),
|
|
});
|
|
|
|
if (error) throw error;
|
|
|
|
res.json({ success: true, mode });
|
|
} catch (error) {
|
|
console.error("Mode update error:", error);
|
|
res.status(500).json({ error: "Failed to update mode preference" });
|
|
}
|
|
});
|
|
|
|
// Get workspace policy
|
|
app.get("/api/workspace/policy", requireAuth, async (req, res) => {
|
|
try {
|
|
// For now, use a default workspace
|
|
const workspaceId = "default";
|
|
|
|
const { data, error } = await supabase
|
|
.from("aethex_workspace_policy")
|
|
.select("*")
|
|
.eq("workspace_id", workspaceId)
|
|
.single();
|
|
|
|
if (error && error.code !== "PGRST116") {
|
|
throw error;
|
|
}
|
|
|
|
res.json(data || {});
|
|
} catch (error) {
|
|
console.error("Policy fetch error:", error);
|
|
res.status(500).json({ error: "Failed to fetch workspace policy" });
|
|
}
|
|
});
|
|
|
|
// ========== ORGANIZATION ROUTES (Multi-tenancy) ==========
|
|
|
|
// Apply org context middleware to all org-scoped routes
|
|
app.use("/api/orgs", requireAuth, attachOrgContext);
|
|
app.use("/api/projects", attachOrgContext);
|
|
app.use("/api/files", attachOrgContext);
|
|
app.use("/api/marketplace", attachOrgContext);
|
|
|
|
// Get user's organizations
|
|
app.get("/api/orgs", async (req, res) => {
|
|
try {
|
|
const { data: memberships, error } = await supabase
|
|
.from("organization_members")
|
|
.select("organization_id, role, organizations(*)")
|
|
.eq("user_id", req.session.userId);
|
|
|
|
if (error) throw error;
|
|
|
|
const orgs = memberships?.map(m => ({
|
|
...m.organizations,
|
|
userRole: m.role,
|
|
})) || [];
|
|
|
|
res.json({ organizations: orgs });
|
|
} catch (error: any) {
|
|
console.error("Fetch orgs error:", error);
|
|
res.status(500).json({ error: "Failed to fetch organizations" });
|
|
}
|
|
});
|
|
|
|
// Create new organization
|
|
app.post("/api/orgs", async (req, res) => {
|
|
try {
|
|
const { name, slug } = req.body;
|
|
|
|
if (!name || !slug) {
|
|
return res.status(400).json({ error: "Name and slug are required" });
|
|
}
|
|
|
|
// Check slug uniqueness
|
|
const { data: existing } = await supabase
|
|
.from("organizations")
|
|
.select("id")
|
|
.eq("slug", slug)
|
|
.single();
|
|
|
|
if (existing) {
|
|
return res.status(400).json({ error: "Slug already taken" });
|
|
}
|
|
|
|
// Create organization
|
|
const { data: org, error: orgError } = await supabase
|
|
.from("organizations")
|
|
.insert({
|
|
name,
|
|
slug,
|
|
owner_user_id: req.session.userId,
|
|
plan: "free",
|
|
})
|
|
.select()
|
|
.single();
|
|
|
|
if (orgError) throw orgError;
|
|
|
|
// Add creator as owner member
|
|
const { error: memberError } = await supabase
|
|
.from("organization_members")
|
|
.insert({
|
|
organization_id: org.id,
|
|
user_id: req.session.userId,
|
|
role: "owner",
|
|
});
|
|
|
|
if (memberError) throw memberError;
|
|
|
|
res.status(201).json({ organization: org });
|
|
} catch (error: any) {
|
|
console.error("Create org error:", error);
|
|
res.status(500).json({ error: error.message || "Failed to create organization" });
|
|
}
|
|
});
|
|
|
|
// Get organization by slug
|
|
app.get("/api/orgs/:slug", async (req, res) => {
|
|
try {
|
|
const { data: org, error } = await supabase
|
|
.from("organizations")
|
|
.select("*")
|
|
.eq("slug", req.params.slug)
|
|
.single();
|
|
|
|
if (error || !org) {
|
|
return res.status(404).json({ error: "Organization not found" });
|
|
}
|
|
|
|
// Check if user is member
|
|
const { data: membership } = await supabase
|
|
.from("organization_members")
|
|
.select("role")
|
|
.eq("organization_id", org.id)
|
|
.eq("user_id", req.session.userId)
|
|
.single();
|
|
|
|
if (!membership) {
|
|
return res.status(403).json({ error: "Not a member of this organization" });
|
|
}
|
|
|
|
res.json({ organization: { ...org, userRole: membership.role } });
|
|
} catch (error: any) {
|
|
console.error("Fetch org error:", error);
|
|
res.status(500).json({ error: "Failed to fetch organization" });
|
|
}
|
|
});
|
|
|
|
// Get organization members
|
|
app.get("/api/orgs/:slug/members", async (req, res) => {
|
|
try {
|
|
// Get org
|
|
const { data: org, error: orgError } = await supabase
|
|
.from("organizations")
|
|
.select("id")
|
|
.eq("slug", req.params.slug)
|
|
.single();
|
|
|
|
if (orgError || !org) {
|
|
return res.status(404).json({ error: "Organization not found" });
|
|
}
|
|
|
|
// Check if user is member
|
|
const { data: userMembership } = await supabase
|
|
.from("organization_members")
|
|
.select("role")
|
|
.eq("organization_id", org.id)
|
|
.eq("user_id", req.session.userId)
|
|
.single();
|
|
|
|
if (!userMembership) {
|
|
return res.status(403).json({ error: "Not a member of this organization" });
|
|
}
|
|
|
|
// Get all members
|
|
const { data: members, error: membersError } = await supabase
|
|
.from("organization_members")
|
|
.select("id, user_id, role, created_at, profiles(username, full_name, avatar_url, email)")
|
|
.eq("organization_id", org.id);
|
|
|
|
if (membersError) throw membersError;
|
|
|
|
res.json({ members });
|
|
} catch (error: any) {
|
|
console.error("Fetch members error:", error);
|
|
res.status(500).json({ error: "Failed to fetch members" });
|
|
}
|
|
});
|
|
|
|
// ========== AUTH ROUTES (Supabase Auth) ==========
|
|
|
|
// Login via Supabase Auth
|
|
app.post("/api/auth/login", async (req, res) => {
|
|
try {
|
|
const result = loginSchema.safeParse(req.body);
|
|
if (!result.success) {
|
|
return res.status(400).json({ error: "Invalid email or password format" });
|
|
}
|
|
|
|
const { email, password } = result.data;
|
|
|
|
// Authenticate with Supabase
|
|
const { data, error } = await supabase.auth.signInWithPassword({
|
|
email,
|
|
password,
|
|
});
|
|
|
|
if (error || !data.user) {
|
|
return res.status(401).json({ error: error?.message || "Invalid credentials" });
|
|
}
|
|
|
|
// Get user profile from public.profiles
|
|
const profile = await storage.getProfile(data.user.id);
|
|
|
|
// Check if user is admin (based on profile role or email)
|
|
const isAdmin = ['admin', 'oversee', 'employee'].includes(profile?.role || '') || email.includes('admin');
|
|
|
|
// Set express session
|
|
req.session.regenerate((err) => {
|
|
if (err) {
|
|
return res.status(500).json({ error: "Session error" });
|
|
}
|
|
|
|
req.session.userId = data.user.id;
|
|
req.session.isAdmin = isAdmin;
|
|
req.session.accessToken = data.session?.access_token;
|
|
|
|
req.session.save((saveErr) => {
|
|
if (saveErr) {
|
|
return res.status(500).json({ error: "Session save error" });
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
user: {
|
|
id: data.user.id,
|
|
email: data.user.email,
|
|
username: profile?.username || data.user.email?.split('@')[0],
|
|
isAdmin
|
|
}
|
|
});
|
|
});
|
|
});
|
|
} catch (err: any) {
|
|
console.error('Login error:', err);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Signup via Supabase Auth
|
|
app.post("/api/auth/signup", async (req, res) => {
|
|
try {
|
|
const result = signupSchema.safeParse(req.body);
|
|
if (!result.success) {
|
|
return res.status(400).json({ error: result.error.errors[0].message });
|
|
}
|
|
|
|
const { email, password, username } = result.data;
|
|
|
|
// Create user in Supabase Auth
|
|
const { data, error } = await supabase.auth.signUp({
|
|
email,
|
|
password,
|
|
options: {
|
|
data: { username: username || email.split('@')[0] }
|
|
}
|
|
});
|
|
|
|
if (error || !data.user) {
|
|
return res.status(400).json({ error: error?.message || "Signup failed" });
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
message: data.session ? "Account created successfully" : "Please check your email to confirm your account",
|
|
user: {
|
|
id: data.user.id,
|
|
email: data.user.email
|
|
}
|
|
});
|
|
} catch (err: any) {
|
|
console.error('Signup error:', err);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Logout
|
|
app.post("/api/auth/logout", async (req, res) => {
|
|
try {
|
|
// Sign out from Supabase
|
|
await supabase.auth.signOut();
|
|
|
|
req.session.destroy((err) => {
|
|
if (err) {
|
|
return res.status(500).json({ error: "Logout failed" });
|
|
}
|
|
res.clearCookie('connect.sid');
|
|
res.json({ success: true });
|
|
});
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Get current session
|
|
app.get("/api/auth/session", async (req, res) => {
|
|
if (!req.session.userId) {
|
|
return res.json({ authenticated: false });
|
|
}
|
|
|
|
// Get profile from storage
|
|
const profile = await storage.getProfile(req.session.userId);
|
|
|
|
res.json({
|
|
authenticated: true,
|
|
user: {
|
|
id: req.session.userId,
|
|
username: profile?.username || 'User',
|
|
email: profile?.email,
|
|
isAdmin: req.session.isAdmin
|
|
}
|
|
});
|
|
});
|
|
|
|
// ========== AUTHENTICATED USER ROUTES ==========
|
|
|
|
// Get current user's profile (for Passport app)
|
|
app.get("/api/me/profile", requireAuth, async (req, res) => {
|
|
try {
|
|
const profile = await storage.getProfile(req.session.userId!);
|
|
if (!profile) {
|
|
return res.status(404).json({ error: "Profile not found" });
|
|
}
|
|
res.json(profile);
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Get current user's achievements
|
|
app.get("/api/me/achievements", requireAuth, async (req, res) => {
|
|
try {
|
|
const userAchievements = await storage.getUserAchievements(req.session.userId!);
|
|
res.json(userAchievements);
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Get current user's passport
|
|
app.get("/api/me/passport", requireAuth, async (req, res) => {
|
|
try {
|
|
const passport = await storage.getUserPassport(req.session.userId!);
|
|
if (!passport) {
|
|
return res.status(404).json({ error: "Passport not found" });
|
|
}
|
|
res.json(passport);
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Create passport for current user
|
|
app.post("/api/me/passport", requireAuth, async (req, res) => {
|
|
try {
|
|
const passport = await storage.createUserPassport(req.session.userId!);
|
|
res.json(passport);
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// ========== PUBLIC API ROUTES ==========
|
|
|
|
// Get ecosystem metrics (public - for dashboard)
|
|
app.get("/api/metrics", async (req, res) => {
|
|
try {
|
|
const metrics = await storage.getMetrics();
|
|
res.json(metrics);
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Minimal tracking endpoint for upgrade clicks
|
|
app.post("/api/track/upgrade-click", async (req, res) => {
|
|
try {
|
|
const { source, timestamp } = req.body || {};
|
|
await storage.logFunnelEvent({
|
|
user_id: req.session.userId,
|
|
event_type: 'upgrade_click',
|
|
source: source || 'unknown',
|
|
created_at: timestamp,
|
|
});
|
|
res.json({ ok: true });
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Generic funnel event tracking
|
|
app.post("/api/track/event", async (req, res) => {
|
|
try {
|
|
const { event_type, source, payload, timestamp } = req.body || {};
|
|
if (!event_type) return res.status(400).json({ error: 'event_type is required' });
|
|
await storage.logFunnelEvent({
|
|
user_id: req.session.userId,
|
|
event_type,
|
|
source,
|
|
payload,
|
|
created_at: timestamp,
|
|
});
|
|
res.json({ ok: true });
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// ========== PAYMENTS ==========
|
|
// Create Stripe Checkout Session
|
|
app.post("/api/payments/create-checkout-session", async (req, res) => {
|
|
try {
|
|
const secret = process.env.STRIPE_SECRET_KEY;
|
|
if (!secret) {
|
|
return res.status(400).json({ error: "Stripe not configured" });
|
|
}
|
|
const priceId = process.env.STRIPE_PRICE_ID; // optional
|
|
const successUrl = process.env.STRIPE_SUCCESS_URL || `${req.headers.origin || "https://aethex.network"}/success`;
|
|
const cancelUrl = process.env.STRIPE_CANCEL_URL || `${req.headers.origin || "https://aethex.network"}/cancel`;
|
|
|
|
const body = new URLSearchParams();
|
|
body.set("mode", "payment");
|
|
body.set("success_url", successUrl);
|
|
body.set("cancel_url", cancelUrl);
|
|
body.set("client_reference_id", req.session.userId || "guest");
|
|
|
|
if (priceId) {
|
|
body.set("line_items[0][price]", priceId);
|
|
body.set("line_items[0][quantity]", "1");
|
|
} else {
|
|
body.set("line_items[0][price_data][currency]", "usd");
|
|
body.set("line_items[0][price_data][product_data][name]", "Architect Access");
|
|
body.set("line_items[0][price_data][unit_amount]", String(50000)); // $500.00
|
|
body.set("line_items[0][quantity]", "1");
|
|
}
|
|
|
|
const resp = await fetch("https://api.stripe.com/v1/checkout/sessions", {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${secret}`,
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
},
|
|
body,
|
|
});
|
|
|
|
const json = await resp.json();
|
|
if (!resp.ok) {
|
|
return res.status(400).json({ error: json.error?.message || "Stripe error" });
|
|
}
|
|
res.json({ url: json.url, id: json.id });
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// ========== PUBLIC DIRECTORY ROUTES ==========
|
|
|
|
// Get public directory of founding architects only
|
|
app.get("/api/directory/architects", async (req, res) => {
|
|
try {
|
|
const profiles = await storage.getProfiles();
|
|
// Only show the founding team members with leadership roles
|
|
const LEADERSHIP_ROLES = ['oversee', 'admin'];
|
|
const publicProfiles = profiles
|
|
.filter(p => {
|
|
const role = (p.role || '').toLowerCase();
|
|
return LEADERSHIP_ROLES.includes(role);
|
|
})
|
|
.map((p, index) => ({
|
|
id: String(index + 1).padStart(3, '0'),
|
|
name: p.full_name || p.username || p.email?.split('@')[0] || 'Architect',
|
|
role: p.role || 'member',
|
|
bio: p.bio,
|
|
level: p.level,
|
|
xp: p.total_xp,
|
|
passportId: p.aethex_passport_id,
|
|
skills: p.skills,
|
|
username: p.username,
|
|
}));
|
|
res.json(publicProfiles);
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Get public directory of projects
|
|
app.get("/api/directory/projects", async (req, res) => {
|
|
try {
|
|
const projects = await storage.getProjects();
|
|
// Map to public-safe fields
|
|
const publicProjects = projects.map(p => ({
|
|
id: p.id,
|
|
name: p.title,
|
|
description: p.description,
|
|
techStack: p.technologies,
|
|
status: p.status,
|
|
}));
|
|
res.json(publicProjects);
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Get single architect profile by username/slug
|
|
app.get("/api/directory/architects/:slug", async (req, res) => {
|
|
try {
|
|
const { slug } = req.params;
|
|
const profiles = await storage.getProfiles();
|
|
const profile = profiles.find(p =>
|
|
p.aethex_passport_id?.toLowerCase() === slug.toLowerCase() ||
|
|
p.full_name?.toLowerCase() === slug.toLowerCase() ||
|
|
p.username?.toLowerCase() === slug.toLowerCase() ||
|
|
p.email?.split('@')[0].toLowerCase() === slug.toLowerCase()
|
|
);
|
|
|
|
if (!profile) {
|
|
return res.status(404).json({ error: "Architect not found" });
|
|
}
|
|
|
|
// Return public-safe fields only
|
|
const socialLinks = profile.social_links || {};
|
|
res.json({
|
|
id: profile.id,
|
|
name: profile.full_name || profile.username || profile.email?.split('@')[0] || 'Architect',
|
|
role: profile.role,
|
|
bio: profile.bio,
|
|
level: profile.level,
|
|
xp: profile.total_xp,
|
|
passportId: profile.aethex_passport_id,
|
|
skills: profile.skills,
|
|
isVerified: profile.is_verified,
|
|
avatarUrl: profile.avatar_url,
|
|
github: socialLinks.github,
|
|
twitter: socialLinks.twitter,
|
|
website: socialLinks.website,
|
|
});
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// ========== ADMIN-PROTECTED API ROUTES ==========
|
|
|
|
// Get all profiles (admin only)
|
|
app.get("/api/profiles", requireAdmin, async (req, res) => {
|
|
try {
|
|
const profiles = await storage.getProfiles();
|
|
res.json(profiles);
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Get single profile (admin only)
|
|
app.get("/api/profiles/:id", requireAdmin, async (req, res) => {
|
|
try {
|
|
const profile = await storage.getProfile(req.params.id);
|
|
if (!profile) {
|
|
return res.status(404).json({ error: "Profile not found" });
|
|
}
|
|
res.json(profile);
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Update profile (self-update OR org admin)
|
|
app.patch("/api/profiles/:id", requireAuth, attachOrgContext, async (req, res) => {
|
|
try {
|
|
const targetProfileId = req.params.id;
|
|
const requesterId = req.session.userId!;
|
|
|
|
// Check authorization: self-update OR org admin/owner
|
|
const isSelfUpdate = requesterId === targetProfileId;
|
|
const isOrgAdmin = req.orgRole && ['admin', 'owner'].includes(req.orgRole);
|
|
|
|
if (!isSelfUpdate && !isOrgAdmin) {
|
|
return res.status(403).json({
|
|
error: "Forbidden",
|
|
message: "You can only update your own profile or must be an org admin/owner"
|
|
});
|
|
}
|
|
|
|
// Log org admin updates for audit trail
|
|
if (!isSelfUpdate && isOrgAdmin && req.orgId) {
|
|
console.log(`[AUDIT] Org ${req.orgRole} ${requesterId} updating profile ${targetProfileId} (org: ${req.orgId})`);
|
|
}
|
|
|
|
const profile = await storage.updateProfile(targetProfileId, req.body);
|
|
if (!profile) {
|
|
return res.status(404).json({ error: "Profile not found" });
|
|
}
|
|
res.json(profile);
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Get all projects (admin only OR org-scoped for user)
|
|
app.get("/api/projects", requireAuth, async (req, res) => {
|
|
try {
|
|
// Admin sees all
|
|
if (req.session.isAdmin) {
|
|
const projects = await storage.getProjects();
|
|
return res.json(projects);
|
|
}
|
|
|
|
// Regular user: filter by org if available
|
|
if (req.orgId) {
|
|
const { data, error } = await supabase
|
|
.from("projects")
|
|
.select("*")
|
|
.eq("organization_id", req.orgId);
|
|
|
|
if (error) throw error;
|
|
return res.json(data || []);
|
|
}
|
|
|
|
// Fallback: user's own projects
|
|
const { data, error } = await supabase
|
|
.from("projects")
|
|
.select("*")
|
|
.or(`owner_user_id.eq.${req.session.userId},user_id.eq.${req.session.userId}`);
|
|
|
|
if (error) throw error;
|
|
res.json(data || []);
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Get single project
|
|
app.get("/api/projects/:id", requireAuth, requireProjectAccess('viewer'), async (req, res) => {
|
|
try {
|
|
res.json((req as any).project);
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Get project collaborators
|
|
app.get("/api/projects/:id/collaborators", requireAuth, requireProjectAccess('contributor'), async (req, res) => {
|
|
try {
|
|
const { data, error } = await supabase
|
|
.from("project_collaborators")
|
|
.select("id, user_id, role, permissions, created_at, profiles(username, full_name, avatar_url, email)")
|
|
.eq("project_id", req.params.id);
|
|
|
|
if (error) throw error;
|
|
|
|
res.json({ collaborators: data || [] });
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Add project collaborator
|
|
app.post("/api/projects/:id/collaborators", requireAuth, async (req, res) => {
|
|
try {
|
|
const accessCheck = await assertProjectAccess(
|
|
req.params.id,
|
|
req.session.userId!,
|
|
'admin'
|
|
);
|
|
|
|
if (!accessCheck.hasAccess) {
|
|
return res.status(403).json({ error: "Only project owners/admins can add collaborators" });
|
|
}
|
|
|
|
const { user_id, role = 'contributor' } = req.body;
|
|
|
|
if (!user_id) {
|
|
return res.status(400).json({ error: "user_id is required" });
|
|
}
|
|
|
|
// Check if user exists
|
|
const { data: userExists } = await supabase
|
|
.from("profiles")
|
|
.select("id")
|
|
.eq("id", user_id)
|
|
.single();
|
|
|
|
if (!userExists) {
|
|
return res.status(404).json({ error: "User not found" });
|
|
}
|
|
|
|
// Add collaborator
|
|
const { data, error } = await supabase
|
|
.from("project_collaborators")
|
|
.insert({
|
|
project_id: req.params.id,
|
|
user_id,
|
|
role,
|
|
})
|
|
.select()
|
|
.single();
|
|
|
|
if (error) {
|
|
if (error.code === '23505') { // Unique violation
|
|
return res.status(400).json({ error: "User is already a collaborator" });
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
res.status(201).json({ collaborator: data });
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Update collaborator role/permissions
|
|
app.patch("/api/projects/:id/collaborators/:collabId", requireAuth, async (req, res) => {
|
|
try {
|
|
const accessCheck = await assertProjectAccess(
|
|
req.params.id,
|
|
req.session.userId!,
|
|
'admin'
|
|
);
|
|
|
|
if (!accessCheck.hasAccess) {
|
|
return res.status(403).json({ error: "Only project owners/admins can modify collaborators" });
|
|
}
|
|
|
|
const { role, permissions } = req.body;
|
|
const updates: any = {};
|
|
|
|
if (role) updates.role = role;
|
|
if (permissions !== undefined) updates.permissions = permissions;
|
|
|
|
const { data, error } = await supabase
|
|
.from("project_collaborators")
|
|
.update(updates)
|
|
.eq("id", req.params.collabId)
|
|
.eq("project_id", req.params.id)
|
|
.select()
|
|
.single();
|
|
|
|
if (error) throw error;
|
|
|
|
if (!data) {
|
|
return res.status(404).json({ error: "Collaborator not found" });
|
|
}
|
|
|
|
res.json({ collaborator: data });
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Remove collaborator
|
|
app.delete("/api/projects/:id/collaborators/:collabId", requireAuth, async (req, res) => {
|
|
try {
|
|
const accessCheck = await assertProjectAccess(
|
|
req.params.id,
|
|
req.session.userId!,
|
|
'admin'
|
|
);
|
|
|
|
if (!accessCheck.hasAccess) {
|
|
return res.status(403).json({ error: "Only project owners/admins can remove collaborators" });
|
|
}
|
|
|
|
const { error } = await supabase
|
|
.from("project_collaborators")
|
|
.delete()
|
|
.eq("id", req.params.collabId)
|
|
.eq("project_id", req.params.id);
|
|
|
|
if (error) throw error;
|
|
|
|
res.json({ success: true });
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// ========== NEW ADMIN ROUTES ==========
|
|
|
|
// Get all aethex sites (admin only)
|
|
// List all sites
|
|
app.get("/api/sites", requireAuth, attachOrgContext, requireOrgMember, async (req, res) => {
|
|
try {
|
|
const { data, error } = await orgScoped('aethex_sites', req)
|
|
.select('*')
|
|
.order('last_check', { ascending: false });
|
|
|
|
if (error) throw error;
|
|
res.json(data || []);
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Create a new site
|
|
app.post("/api/sites", requireAuth, attachOrgContext, requireOrgMember, async (req, res) => {
|
|
try {
|
|
const orgId = getOrgIdOrThrow(req);
|
|
const { data, error } = await supabase
|
|
.from('aethex_sites')
|
|
.insert({ ...req.body, organization_id: orgId })
|
|
.select()
|
|
.single();
|
|
|
|
if (error) throw error;
|
|
res.status(201).json(data);
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Update a site
|
|
app.patch("/api/sites/:id", requireAuth, attachOrgContext, requireOrgMember, async (req, res) => {
|
|
try {
|
|
const orgId = getOrgIdOrThrow(req);
|
|
const { data, error } = await supabase
|
|
.from('aethex_sites')
|
|
.update(req.body)
|
|
.eq('id', req.params.id)
|
|
.eq('organization_id', orgId)
|
|
.select()
|
|
.single();
|
|
|
|
if (error) throw error;
|
|
if (!data) {
|
|
return res.status(404).json({ error: "Site not found or access denied" });
|
|
}
|
|
res.json(data);
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Delete a site
|
|
app.delete("/api/sites/:id", requireAuth, attachOrgContext, requireOrgMember, async (req, res) => {
|
|
try {
|
|
const orgId = getOrgIdOrThrow(req);
|
|
const { error, count } = await supabase
|
|
.from('aethex_sites')
|
|
.delete({ count: 'exact' })
|
|
.eq('id', req.params.id)
|
|
.eq('organization_id', orgId);
|
|
|
|
if (error) throw error;
|
|
if ((count ?? 0) === 0) {
|
|
return res.status(404).json({ error: "Site not found or access denied" });
|
|
}
|
|
res.json({ success: true });
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Get auth logs (admin only)
|
|
app.get("/api/auth-logs", requireAdmin, async (req, res) => {
|
|
try {
|
|
const logs = await storage.getAuthLogs();
|
|
res.json(logs);
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Get all achievements (public - shows what achievements exist)
|
|
app.get("/api/achievements", async (req, res) => {
|
|
try {
|
|
const achievements = await storage.getAchievements();
|
|
res.json(achievements);
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Get applications (admin only)
|
|
app.get("/api/applications", requireAdmin, async (req, res) => {
|
|
try {
|
|
const applications = await storage.getApplications();
|
|
res.json(applications);
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Get alerts for Aegis (admin only)
|
|
app.get("/api/alerts", requireAdmin, async (req, res) => {
|
|
try {
|
|
const alerts = await storage.getAlerts();
|
|
res.json(alerts);
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Resolve alert (admin only)
|
|
app.patch("/api/alerts/:id", requireAdmin, async (req, res) => {
|
|
try {
|
|
const alert = await storage.updateAlert(req.params.id, req.body);
|
|
if (!alert) {
|
|
return res.status(404).json({ error: "Alert not found" });
|
|
}
|
|
res.json(alert);
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Update application status (admin only)
|
|
app.patch("/api/applications/:id", requireAdmin, async (req, res) => {
|
|
try {
|
|
const application = await storage.updateApplication(req.params.id, req.body);
|
|
if (!application) {
|
|
return res.status(404).json({ error: "Application not found" });
|
|
}
|
|
res.json(application);
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// ========== PUBLIC OS API ROUTES ==========
|
|
|
|
// Get public project summaries for OS (limited data, no auth required)
|
|
app.get("/api/os/projects", async (req, res) => {
|
|
try {
|
|
const projects = await storage.getProjects();
|
|
const summaries = projects.slice(0, 10).map(p => ({
|
|
id: p.id,
|
|
title: p.title,
|
|
status: p.status,
|
|
engine: p.engine,
|
|
}));
|
|
res.json(summaries);
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Get public architect summaries for OS (limited data, no auth required)
|
|
app.get("/api/os/architects", async (req, res) => {
|
|
try {
|
|
const profiles = await storage.getProfiles();
|
|
const summaries = profiles.slice(0, 10).map(p => ({
|
|
id: p.id,
|
|
username: p.username,
|
|
level: p.level || 1,
|
|
xp: p.total_xp || 0,
|
|
verified: p.is_verified || false,
|
|
}));
|
|
res.json(summaries);
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Get achievements list for OS (public)
|
|
app.get("/api/os/achievements", async (req, res) => {
|
|
try {
|
|
const achievements = await storage.getAchievements();
|
|
res.json(achievements.slice(0, 20));
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Get recent activity/notifications for OS (public summary)
|
|
app.get("/api/os/notifications", async (req, res) => {
|
|
try {
|
|
const metrics = await storage.getMetrics();
|
|
const notifications = [
|
|
{ id: 1, message: `${metrics.totalProfiles} architects in network`, type: 'info' },
|
|
{ id: 2, message: `${metrics.totalProjects} active projects`, type: 'info' },
|
|
{ id: 3, message: 'Aegis security active', type: 'success' },
|
|
];
|
|
res.json(notifications);
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// ========== CHATBOT API (Rate limited) ==========
|
|
|
|
const chatRateLimits = new Map<string, { count: number; resetTime: number }>();
|
|
|
|
// Get chat history
|
|
app.get("/api/chat/history", requireAuth, async (req, res) => {
|
|
try {
|
|
const userId = req.session.userId!;
|
|
const history = await storage.getChatHistory(userId, 20);
|
|
res.json({ history });
|
|
} catch (err: any) {
|
|
console.error("Get chat history error:", err);
|
|
res.status(500).json({ error: "Failed to get chat history" });
|
|
}
|
|
});
|
|
|
|
app.post("/api/chat", async (req, res) => {
|
|
try {
|
|
const userId = req.session?.userId;
|
|
const clientIP = req.ip || req.socket.remoteAddress || 'unknown';
|
|
const rateLimitKey = userId ? `user:${userId}` : `ip:${clientIP}`;
|
|
const maxRequests = userId ? 30 : 10;
|
|
|
|
const now = Date.now();
|
|
const rateLimit = chatRateLimits.get(rateLimitKey);
|
|
|
|
if (rateLimit) {
|
|
if (now < rateLimit.resetTime) {
|
|
if (rateLimit.count >= maxRequests) {
|
|
return res.status(429).json({ error: "Rate limit exceeded. Please wait before sending more messages." });
|
|
}
|
|
rateLimit.count++;
|
|
} else {
|
|
chatRateLimits.set(rateLimitKey, { count: 1, resetTime: now + 60000 });
|
|
}
|
|
} else {
|
|
chatRateLimits.set(rateLimitKey, { count: 1, resetTime: now + 60000 });
|
|
}
|
|
|
|
const { message, history } = req.body;
|
|
if (!message || typeof message !== "string") {
|
|
return res.status(400).json({ error: "Message is required" });
|
|
}
|
|
|
|
// Save user message if user is authenticated
|
|
if (userId) {
|
|
const messageId = `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
await storage.saveChatMessage(messageId, userId, 'user', message);
|
|
}
|
|
|
|
// Get full chat history for context if user is authenticated
|
|
let fullHistory = history || [];
|
|
if (userId) {
|
|
const savedHistory = await storage.getChatHistory(userId, 20);
|
|
fullHistory = savedHistory.map(msg => ({ role: msg.role, content: msg.content }));
|
|
}
|
|
|
|
const response = await getChatResponse(message, fullHistory, userId);
|
|
|
|
// Save assistant response if user is authenticated
|
|
if (userId) {
|
|
const responseId = `assistant_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
await storage.saveChatMessage(responseId, userId, 'assistant', response);
|
|
}
|
|
|
|
res.json({ response });
|
|
} catch (err: any) {
|
|
console.error("Chat error:", err);
|
|
res.status(500).json({ error: "Failed to get response" });
|
|
}
|
|
});
|
|
|
|
// ========== AXIOM OPPORTUNITIES ROUTES ==========
|
|
|
|
// Get all opportunities (public)
|
|
app.get("/api/opportunities", async (req, res) => {
|
|
try {
|
|
let query = supabase
|
|
.from('aethex_opportunities')
|
|
.select('*')
|
|
.order('created_at', { ascending: false });
|
|
|
|
// Optional org filter
|
|
if (req.query.org_id) {
|
|
query = query.eq('organization_id', req.query.org_id as string);
|
|
}
|
|
|
|
const { data, error } = await query;
|
|
if (error) throw error;
|
|
res.json(data || []);
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Get single opportunity
|
|
// PUBLIC: Opportunities are publicly viewable for discovery
|
|
app.get("/api/opportunities/:id", async (req, res) => {
|
|
const IS_PUBLIC = true; // Intentionally public for marketplace discovery
|
|
try {
|
|
const opportunity = await storage.getOpportunity(req.params.id);
|
|
if (!opportunity) {
|
|
return res.status(404).json({ error: "Opportunity not found" });
|
|
}
|
|
res.json(opportunity);
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Create opportunity (admin only)
|
|
app.post("/api/opportunities", requireAuth, attachOrgContext, requireOrgMember, async (req, res) => {
|
|
try {
|
|
const orgId = getOrgIdOrThrow(req);
|
|
const { data, error } = await supabase
|
|
.from('aethex_opportunities')
|
|
.insert({ ...req.body, organization_id: orgId })
|
|
.select()
|
|
.single();
|
|
|
|
if (error) throw error;
|
|
res.status(201).json(data);
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Update opportunity (admin only)
|
|
app.patch("/api/opportunities/:id", requireAuth, attachOrgContext, requireOrgMember, async (req, res) => {
|
|
try {
|
|
const orgId = getOrgIdOrThrow(req);
|
|
const { data, error } = await supabase
|
|
.from('aethex_opportunities')
|
|
.update({ ...req.body, updated_at: new Date().toISOString() })
|
|
.eq('id', req.params.id)
|
|
.eq('organization_id', orgId)
|
|
.select()
|
|
.single();
|
|
|
|
if (error) throw error;
|
|
if (!data) {
|
|
return res.status(404).json({ error: "Opportunity not found or access denied" });
|
|
}
|
|
res.json(data);
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Delete opportunity (admin only)
|
|
app.delete("/api/opportunities/:id", requireAuth, attachOrgContext, requireOrgMember, async (req, res) => {
|
|
try {
|
|
const orgId = getOrgIdOrThrow(req);
|
|
const { error, count } = await supabase
|
|
.from('aethex_opportunities')
|
|
.delete({ count: 'exact' })
|
|
.eq('id', req.params.id)
|
|
.eq('organization_id', orgId);
|
|
|
|
if (error) throw error;
|
|
if ((count ?? 0) === 0) {
|
|
return res.status(404).json({ error: "Opportunity not found or access denied" });
|
|
}
|
|
res.json({ success: true });
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// ========== AXIOM EVENTS ROUTES ==========
|
|
|
|
// Get all events (public)
|
|
// PUBLIC: Events are publicly viewable for community discovery, with optional org filtering
|
|
app.get("/api/events", async (req, res) => {
|
|
const IS_PUBLIC = true; // Intentionally public for community calendar
|
|
try {
|
|
let query = supabase
|
|
.from('aethex_events')
|
|
.select('*')
|
|
.order('date', { ascending: true });
|
|
|
|
// Optional org filter
|
|
if (req.query.org_id) {
|
|
query = query.eq('organization_id', req.query.org_id as string);
|
|
}
|
|
|
|
const { data, error } = await query;
|
|
if (error) throw error;
|
|
res.json(data || []);
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Get single event
|
|
// PUBLIC: Events are publicly viewable for sharing/discovery
|
|
app.get("/api/events/:id", async (req, res) => {
|
|
const IS_PUBLIC = true; // Intentionally public for event sharing
|
|
try {
|
|
const event = await storage.getEvent(req.params.id);
|
|
if (!event) {
|
|
return res.status(404).json({ error: "Event not found" });
|
|
}
|
|
res.json(event);
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Create event (admin only)
|
|
app.post("/api/events", requireAuth, attachOrgContext, requireOrgMember, async (req, res) => {
|
|
try {
|
|
const orgId = getOrgIdOrThrow(req);
|
|
const { data, error } = await supabase
|
|
.from('aethex_events')
|
|
.insert({ ...req.body, organization_id: orgId })
|
|
.select()
|
|
.single();
|
|
|
|
if (error) throw error;
|
|
res.status(201).json(data);
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Update event (admin only)
|
|
app.patch("/api/events/:id", requireAuth, attachOrgContext, requireOrgMember, async (req, res) => {
|
|
try {
|
|
const orgId = getOrgIdOrThrow(req);
|
|
const { data, error } = await supabase
|
|
.from('aethex_events')
|
|
.update({ ...req.body, updated_at: new Date().toISOString() })
|
|
.eq('id', req.params.id)
|
|
.eq('organization_id', orgId)
|
|
.select()
|
|
.single();
|
|
|
|
if (error) throw error;
|
|
if (!data) {
|
|
return res.status(404).json({ error: "Event not found or access denied" });
|
|
}
|
|
res.json(data);
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Delete event (admin only)
|
|
app.delete("/api/events/:id", requireAuth, attachOrgContext, requireOrgMember, async (req, res) => {
|
|
try {
|
|
const orgId = getOrgIdOrThrow(req);
|
|
const { error, count } = await supabase
|
|
.from('aethex_events')
|
|
.delete({ count: 'exact' })
|
|
.eq('id', req.params.id)
|
|
.eq('organization_id', orgId);
|
|
|
|
if (error) throw error;
|
|
if ((count ?? 0) === 0) {
|
|
return res.status(404).json({ error: "Event not found or access denied" });
|
|
}
|
|
res.json({ success: true });
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// ========== MARKETPLACE ROUTES (LEDGER-3) ==========
|
|
// Purchase marketplace listing
|
|
app.post("/api/marketplace/purchase", requireAuth, async (req, res) => {
|
|
try {
|
|
const { listing_id } = req.body;
|
|
const buyer_id = req.session.userId!;
|
|
|
|
if (!listing_id) {
|
|
return res.status(400).json({ error: "listing_id is required" });
|
|
}
|
|
|
|
// Fetch listing details
|
|
const { data: listing, error: listingError } = await supabase
|
|
.from("marketplace_listings")
|
|
.select("*")
|
|
.eq("id", listing_id)
|
|
.single();
|
|
|
|
if (listingError || !listing) {
|
|
return res.status(404).json({ error: "Listing not found" });
|
|
}
|
|
|
|
// Prevent self-purchase
|
|
if (listing.seller_id === buyer_id) {
|
|
return res.status(400).json({ error: "Cannot purchase your own listing" });
|
|
}
|
|
|
|
// Create transaction
|
|
const transactionId = randomUUID();
|
|
const { error: transError } = await supabase
|
|
.from("marketplace_transactions")
|
|
.insert({
|
|
id: transactionId,
|
|
buyer_id,
|
|
seller_id: listing.seller_id,
|
|
listing_id,
|
|
amount: listing.price,
|
|
status: "completed",
|
|
});
|
|
|
|
if (transError) throw transError;
|
|
|
|
// Emit revenue event (LEDGER-3)
|
|
const { recordRevenueEvent } = await import("./revenue.js");
|
|
const revResult = await recordRevenueEvent({
|
|
source_type: "marketplace",
|
|
source_id: transactionId,
|
|
gross_amount: listing.price,
|
|
platform_fee: 0, // Can be configured per transaction or org policy
|
|
currency: "POINTS",
|
|
project_id: (listing as any).project_id || null,
|
|
metadata: {
|
|
listing_id,
|
|
buyer_id,
|
|
seller_id: listing.seller_id,
|
|
title: listing.title,
|
|
category: listing.category,
|
|
},
|
|
});
|
|
|
|
if (revResult.success && revResult.id && (listing as any).project_id) {
|
|
// Compute and record splits if project_id exists (SPLITS-1)
|
|
const { computeRevenueSplits, recordSplitAllocations } = await import(
|
|
"./splits.js"
|
|
);
|
|
const splitsResult = await computeRevenueSplits(
|
|
(listing as any).project_id,
|
|
listing.price
|
|
);
|
|
if (splitsResult.success && splitsResult.allocations) {
|
|
await recordSplitAllocations(
|
|
revResult.id,
|
|
(listing as any).project_id,
|
|
splitsResult.allocations,
|
|
splitsResult.splitVersion || 1
|
|
);
|
|
}
|
|
}
|
|
|
|
// Update listing purchase count
|
|
await supabase
|
|
.from("marketplace_listings")
|
|
.update({ purchase_count: (listing.purchase_count || 0) + 1 })
|
|
.eq("id", listing_id);
|
|
|
|
res.status(201).json({
|
|
success: true,
|
|
transaction_id: transactionId,
|
|
message: "Purchase completed",
|
|
});
|
|
} catch (err: any) {
|
|
console.error("Marketplace purchase error:", err);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Get organization revenue summary by month (LEDGER-4)
|
|
app.get("/api/revenue/summary", requireAuth, async (req, res) => {
|
|
try {
|
|
const org_id = (req.headers["x-org-id"] as string) || req.session.userId; // Org context from header or user
|
|
const monthsParam = parseInt(req.query.months as string) || 6;
|
|
const months = Math.min(monthsParam, 24); // Cap at 24 months
|
|
|
|
if (!org_id) {
|
|
return res.status(400).json({ error: "Org context required" });
|
|
}
|
|
|
|
// Query revenue events for this org, past N months
|
|
const startDate = new Date();
|
|
startDate.setMonth(startDate.getMonth() - months);
|
|
|
|
const { data: events, error } = await supabase
|
|
.from("revenue_events")
|
|
.select("*")
|
|
.eq("org_id", org_id)
|
|
.gte("created_at", startDate.toISOString())
|
|
.order("created_at", { ascending: true });
|
|
|
|
if (error) throw error;
|
|
|
|
// Aggregate by month
|
|
const byMonth: Record<
|
|
string,
|
|
{ gross: number; fees: number; net: number }
|
|
> = {};
|
|
|
|
(events || []).forEach((event: any) => {
|
|
const date = new Date(event.created_at);
|
|
const monthKey = date.toISOString().substring(0, 7); // "2026-01"
|
|
if (!byMonth[monthKey]) {
|
|
byMonth[monthKey] = { gross: 0, fees: 0, net: 0 };
|
|
}
|
|
byMonth[monthKey].gross += parseFloat(event.gross_amount || "0");
|
|
byMonth[monthKey].fees += parseFloat(event.platform_fee || "0");
|
|
byMonth[monthKey].net += parseFloat(event.net_amount || "0");
|
|
});
|
|
|
|
// Format response
|
|
const summary = Object.entries(byMonth)
|
|
.map(([month, { gross, fees, net }]) => ({
|
|
month,
|
|
gross: gross.toFixed(2),
|
|
fees: fees.toFixed(2),
|
|
net: net.toFixed(2),
|
|
}))
|
|
.sort();
|
|
|
|
res.json(summary);
|
|
} catch (err: any) {
|
|
console.error("Revenue summary error:", err);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Get revenue splits for a project (SPLITS-1)
|
|
app.get("/api/revenue/splits/:projectId", requireAuth, async (req, res) => {
|
|
try {
|
|
const { projectId } = req.params;
|
|
|
|
// Fetch the currently active split rule
|
|
const { data: splits, error: splitsError } = await supabase
|
|
.from("revenue_splits")
|
|
.select("*")
|
|
.eq("project_id", projectId)
|
|
.is("active_until", null) // Only active rules
|
|
.order("split_version", { ascending: false })
|
|
.limit(1);
|
|
|
|
if (splitsError) throw splitsError;
|
|
|
|
if (!splits || splits.length === 0) {
|
|
return res.json({
|
|
split_version: 0,
|
|
rule: {},
|
|
allocations: [],
|
|
});
|
|
}
|
|
|
|
const split = splits[0];
|
|
|
|
// Fetch all allocations for this project (for reporting)
|
|
const { data: allocations, error: allocError } = await supabase
|
|
.from("split_allocations")
|
|
.select("*")
|
|
.eq("project_id", projectId)
|
|
.order("created_at", { ascending: false })
|
|
.limit(100);
|
|
|
|
if (allocError) throw allocError;
|
|
|
|
// Aggregate allocations by user
|
|
const byUser: Record<
|
|
string,
|
|
{
|
|
user_id: string;
|
|
total_allocated: number;
|
|
allocation_count: number;
|
|
}
|
|
> = {};
|
|
|
|
(allocations || []).forEach((alloc: any) => {
|
|
if (!byUser[alloc.user_id]) {
|
|
byUser[alloc.user_id] = {
|
|
user_id: alloc.user_id,
|
|
total_allocated: 0,
|
|
allocation_count: 0,
|
|
};
|
|
}
|
|
byUser[alloc.user_id].total_allocated += parseFloat(
|
|
alloc.allocated_amount || "0"
|
|
);
|
|
byUser[alloc.user_id].allocation_count += 1;
|
|
});
|
|
|
|
res.json({
|
|
split_version: split.split_version,
|
|
rule: split.rule,
|
|
active_from: split.active_from,
|
|
allocations_summary: Object.values(byUser),
|
|
});
|
|
} catch (err: any) {
|
|
console.error("Revenue splits fetch error:", err);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Get split rule history for a project (SPLITS-HISTORY)
|
|
app.get("/api/revenue/splits/:projectId/history", requireAuth, async (req, res) => {
|
|
try {
|
|
const { projectId } = req.params;
|
|
|
|
// Fetch all split versions for this project, ordered by version desc
|
|
const { data: splitHistory, error: historyError } = await supabase
|
|
.from("revenue_splits")
|
|
.select("*")
|
|
.eq("project_id", projectId)
|
|
.order("split_version", { ascending: false });
|
|
|
|
if (historyError) throw historyError;
|
|
|
|
if (!splitHistory || splitHistory.length === 0) {
|
|
return res.json({
|
|
project_id: projectId,
|
|
total_versions: 0,
|
|
history: [],
|
|
});
|
|
}
|
|
|
|
// Enrich history with allocation counts per version
|
|
const enriched = await Promise.all(
|
|
splitHistory.map(async (split: any) => {
|
|
const { count, error: countError } = await supabase
|
|
.from("split_allocations")
|
|
.select("id", { count: "exact" })
|
|
.eq("project_id", projectId)
|
|
.eq("split_version", split.split_version);
|
|
|
|
if (countError) console.error("Count error:", countError);
|
|
|
|
return {
|
|
split_version: split.split_version,
|
|
rule: split.rule,
|
|
active_from: split.active_from,
|
|
active_until: split.active_until,
|
|
is_active: !split.active_until,
|
|
created_by: split.created_by,
|
|
created_at: split.created_at,
|
|
allocations_count: count || 0,
|
|
};
|
|
})
|
|
);
|
|
|
|
res.json({
|
|
project_id: projectId,
|
|
total_versions: enriched.length,
|
|
history: enriched,
|
|
});
|
|
} catch (err: any) {
|
|
console.error("Split history fetch error:", err);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// ========== GOVERNANCE: SPLIT VOTING SYSTEM ==========
|
|
// Import voting functions
|
|
const { createSplitProposal, castVote, evaluateProposal, getProposalWithVotes } = await import(
|
|
"./votes.js"
|
|
);
|
|
|
|
// Create a proposal to change split rules (SPLITS-VOTING-1)
|
|
app.post("/api/revenue/splits/:projectId/propose", requireAuth, async (req, res) => {
|
|
try {
|
|
const { projectId } = req.params;
|
|
const { proposed_rule, voting_rule, description, expires_at } = req.body;
|
|
const userId = req.session.userId;
|
|
|
|
if (!proposed_rule || !voting_rule) {
|
|
return res
|
|
.status(400)
|
|
.json({ error: "Missing proposed_rule or voting_rule" });
|
|
}
|
|
|
|
if (voting_rule !== "unanimous" && voting_rule !== "majority") {
|
|
return res
|
|
.status(400)
|
|
.json({ error: "voting_rule must be 'unanimous' or 'majority'" });
|
|
}
|
|
|
|
const result = await createSplitProposal({
|
|
project_id: projectId,
|
|
proposed_by: userId,
|
|
proposed_rule,
|
|
voting_rule,
|
|
description,
|
|
expires_at: expires_at ? new Date(expires_at) : undefined,
|
|
});
|
|
|
|
if (!result.success) {
|
|
return res.status(400).json({ error: result.error });
|
|
}
|
|
|
|
res.status(201).json({
|
|
success: true,
|
|
proposal_id: result.proposal_id,
|
|
message: "Proposal created successfully",
|
|
});
|
|
} catch (err: any) {
|
|
console.error("Create proposal error:", err);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Cast a vote on a proposal (SPLITS-VOTING-2)
|
|
app.post("/api/revenue/splits/proposals/:proposalId/vote", requireAuth, async (req, res) => {
|
|
try {
|
|
const { proposalId } = req.params;
|
|
const { vote, reason } = req.body;
|
|
const userId = req.session.userId;
|
|
|
|
if (!vote || (vote !== "approve" && vote !== "reject")) {
|
|
return res.status(400).json({ error: "vote must be 'approve' or 'reject'" });
|
|
}
|
|
|
|
const result = await castVote({
|
|
proposal_id: proposalId,
|
|
voter_id: userId,
|
|
vote,
|
|
reason,
|
|
});
|
|
|
|
if (!result.success) {
|
|
return res.status(400).json({ error: result.error });
|
|
}
|
|
|
|
res.status(201).json({
|
|
success: true,
|
|
vote_id: result.vote_id,
|
|
message: "Vote recorded successfully",
|
|
});
|
|
} catch (err: any) {
|
|
console.error("Cast vote error:", err);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Get proposal details with vote counts (SPLITS-VOTING-3)
|
|
app.get(
|
|
"/api/revenue/splits/proposals/:proposalId",
|
|
requireAuth,
|
|
async (req, res) => {
|
|
try {
|
|
const { proposalId } = req.params;
|
|
|
|
const result = await getProposalWithVotes(proposalId);
|
|
|
|
if (!result.success) {
|
|
return res.status(404).json({ error: result.error });
|
|
}
|
|
|
|
res.json({
|
|
proposal: result.proposal,
|
|
votes: result.votes,
|
|
stats: result.stats,
|
|
});
|
|
} catch (err: any) {
|
|
console.error("Get proposal error:", err);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
}
|
|
);
|
|
|
|
// Evaluate proposal consensus and apply if approved (SPLITS-VOTING-4)
|
|
app.post(
|
|
"/api/revenue/splits/proposals/:proposalId/evaluate",
|
|
requireAuth,
|
|
async (req, res) => {
|
|
try {
|
|
const { proposalId } = req.params;
|
|
const userId = req.session.userId;
|
|
|
|
const result = await evaluateProposal(proposalId, userId);
|
|
|
|
if (!result.success) {
|
|
return res.status(400).json({ error: result.error });
|
|
}
|
|
|
|
res.json({
|
|
success: result.success,
|
|
approved: result.approved,
|
|
stats: {
|
|
approve_count: result.approve_count,
|
|
reject_count: result.reject_count,
|
|
total_votes: result.total_votes,
|
|
},
|
|
message: result.message,
|
|
});
|
|
} catch (err: any) {
|
|
console.error("Evaluate proposal error:", err);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
}
|
|
);
|
|
|
|
// List all proposals for a project (SPLITS-VOTING-5)
|
|
app.get(
|
|
"/api/revenue/splits/:projectId/proposals",
|
|
requireAuth,
|
|
async (req, res) => {
|
|
try {
|
|
const { projectId } = req.params;
|
|
|
|
const { data: proposals, error } = await supabase
|
|
.from("split_proposals")
|
|
.select("*")
|
|
.eq("project_id", projectId)
|
|
.order("created_at", { ascending: false });
|
|
|
|
if (error) throw error;
|
|
|
|
res.json({
|
|
project_id: projectId,
|
|
proposals: proposals || [],
|
|
count: proposals?.length || 0,
|
|
});
|
|
} catch (err: any) {
|
|
console.error("List proposals error:", err);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
}
|
|
);
|
|
|
|
// ========== SETTLEMENT: ESCROW & PAYOUT SYSTEM ==========
|
|
// Import settlement functions
|
|
const {
|
|
getEscrowBalance,
|
|
depositToEscrow,
|
|
createPayoutRequest,
|
|
reviewPayoutRequest,
|
|
registerPayoutMethod,
|
|
processPayout,
|
|
completePayout,
|
|
failPayout,
|
|
getPayoutHistory,
|
|
} = await import("./settlement.js");
|
|
|
|
// Get escrow balance for user on a project (SETTLEMENT-1)
|
|
app.get(
|
|
"/api/settlement/escrow/:projectId",
|
|
requireAuth,
|
|
async (req, res) => {
|
|
try {
|
|
const { projectId } = req.params;
|
|
const userId = req.session.userId;
|
|
|
|
const result = await getEscrowBalance(userId, projectId);
|
|
|
|
if (!result.success) {
|
|
return res.status(400).json({ error: result.error });
|
|
}
|
|
|
|
res.json({
|
|
user_id: userId,
|
|
project_id: projectId,
|
|
balance: result.balance,
|
|
held_amount: result.held,
|
|
released_amount: result.released,
|
|
});
|
|
} catch (err: any) {
|
|
console.error("Get escrow balance error:", err);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
}
|
|
);
|
|
|
|
// Create a payout request (SETTLEMENT-2)
|
|
app.post("/api/settlement/payout-request", requireAuth, async (req, res) => {
|
|
try {
|
|
const { escrow_account_id, request_amount, reason } = req.body;
|
|
const userId = req.session.userId;
|
|
|
|
if (!escrow_account_id || !request_amount) {
|
|
return res
|
|
.status(400)
|
|
.json({ error: "Missing escrow_account_id or request_amount" });
|
|
}
|
|
|
|
const result = await createPayoutRequest({
|
|
user_id: userId,
|
|
escrow_account_id,
|
|
request_amount,
|
|
reason,
|
|
});
|
|
|
|
if (!result.success) {
|
|
return res.status(400).json({ error: result.error });
|
|
}
|
|
|
|
res.status(201).json({
|
|
success: true,
|
|
request_id: result.request_id,
|
|
message: "Payout request created successfully",
|
|
});
|
|
} catch (err: any) {
|
|
console.error("Create payout request error:", err);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Get user's payout requests (SETTLEMENT-3)
|
|
app.get("/api/settlement/payout-requests", requireAuth, async (req, res) => {
|
|
try {
|
|
const userId = req.session.userId;
|
|
|
|
const { data: requests, error } = await supabase
|
|
.from("payout_requests")
|
|
.select("*")
|
|
.eq("user_id", userId)
|
|
.order("requested_at", { ascending: false });
|
|
|
|
if (error) throw error;
|
|
|
|
res.json({
|
|
user_id: userId,
|
|
payout_requests: requests || [],
|
|
count: requests?.length || 0,
|
|
});
|
|
} catch (err: any) {
|
|
console.error("Get payout requests error:", err);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Register a payout method (SETTLEMENT-4)
|
|
app.post("/api/settlement/payout-methods", requireAuth, async (req, res) => {
|
|
try {
|
|
const { method_type, metadata, is_primary } = req.body;
|
|
const userId = req.session.userId;
|
|
|
|
if (!method_type || !metadata) {
|
|
return res
|
|
.status(400)
|
|
.json({ error: "Missing method_type or metadata" });
|
|
}
|
|
|
|
const result = await registerPayoutMethod({
|
|
user_id: userId,
|
|
method_type,
|
|
metadata,
|
|
is_primary,
|
|
});
|
|
|
|
if (!result.success) {
|
|
return res.status(400).json({ error: result.error });
|
|
}
|
|
|
|
res.status(201).json({
|
|
success: true,
|
|
method_id: result.method_id,
|
|
message: "Payout method registered successfully",
|
|
});
|
|
} catch (err: any) {
|
|
console.error("Register payout method error:", err);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Get user's payout methods (SETTLEMENT-5)
|
|
app.get(
|
|
"/api/settlement/payout-methods",
|
|
requireAuth,
|
|
async (req, res) => {
|
|
try {
|
|
const userId = req.session.userId;
|
|
|
|
const { data: methods, error } = await supabase
|
|
.from("payout_methods")
|
|
.select("*")
|
|
.eq("user_id", userId)
|
|
.order("created_at", { ascending: false });
|
|
|
|
if (error) throw error;
|
|
|
|
res.json({
|
|
user_id: userId,
|
|
payout_methods: methods || [],
|
|
count: methods?.length || 0,
|
|
});
|
|
} catch (err: any) {
|
|
console.error("Get payout methods error:", err);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
}
|
|
);
|
|
|
|
// Process a payout (admin/system) (SETTLEMENT-6)
|
|
app.post(
|
|
"/api/settlement/payouts/process",
|
|
requireAuth,
|
|
async (req, res) => {
|
|
try {
|
|
const {
|
|
payout_request_id,
|
|
escrow_account_id,
|
|
payout_method_id,
|
|
amount,
|
|
} = req.body;
|
|
const userId = req.session.userId;
|
|
|
|
if (
|
|
!escrow_account_id ||
|
|
!payout_method_id ||
|
|
!amount
|
|
) {
|
|
return res.status(400).json({
|
|
error:
|
|
"Missing escrow_account_id, payout_method_id, or amount",
|
|
});
|
|
}
|
|
|
|
const result = await processPayout({
|
|
payout_request_id,
|
|
user_id: userId,
|
|
escrow_account_id,
|
|
payout_method_id,
|
|
amount,
|
|
});
|
|
|
|
if (!result.success) {
|
|
return res.status(400).json({ error: result.error });
|
|
}
|
|
|
|
res.status(201).json({
|
|
success: true,
|
|
payout_id: result.payout_id,
|
|
message: "Payout processing started",
|
|
});
|
|
} catch (err: any) {
|
|
console.error("Process payout error:", err);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
}
|
|
);
|
|
|
|
// Complete a payout (SETTLEMENT-7)
|
|
app.post(
|
|
"/api/settlement/payouts/:payoutId/complete",
|
|
requireAuth,
|
|
async (req, res) => {
|
|
try {
|
|
const { payoutId } = req.params;
|
|
const { external_transaction_id } = req.body;
|
|
|
|
const result = await completePayout(payoutId, external_transaction_id);
|
|
|
|
if (!result.success) {
|
|
return res.status(400).json({ error: result.error });
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
message: "Payout completed successfully",
|
|
});
|
|
} catch (err: any) {
|
|
console.error("Complete payout error:", err);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
}
|
|
);
|
|
|
|
// Fail a payout (SETTLEMENT-8)
|
|
app.post(
|
|
"/api/settlement/payouts/:payoutId/fail",
|
|
requireAuth,
|
|
async (req, res) => {
|
|
try {
|
|
const { payoutId } = req.params;
|
|
const { failure_reason } = req.body;
|
|
|
|
const result = await failPayout(payoutId, failure_reason);
|
|
|
|
if (!result.success) {
|
|
return res.status(400).json({ error: result.error });
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
message: "Payout marked as failed, funds restored to escrow",
|
|
});
|
|
} catch (err: any) {
|
|
console.error("Fail payout error:", err);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
}
|
|
);
|
|
|
|
// Get user's payout history (SETTLEMENT-9)
|
|
app.get("/api/settlement/payouts", requireAuth, async (req, res) => {
|
|
try {
|
|
const userId = req.session.userId;
|
|
const limit = parseInt(req.query.limit as string) || 50;
|
|
|
|
const result = await getPayoutHistory(userId, limit);
|
|
|
|
if (!result.success) {
|
|
return res.status(400).json({ error: result.error });
|
|
}
|
|
|
|
res.json({
|
|
user_id: userId,
|
|
payouts: result.payouts,
|
|
count: result.count,
|
|
});
|
|
} catch (err: any) {
|
|
console.error("Get payout history error:", err);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// ========== CONTRIBUTOR DASHBOARD: EARNINGS VIEW ==========
|
|
// Import dashboard functions
|
|
const {
|
|
getUserEarnings,
|
|
getProjectEarnings,
|
|
getEarningsSummary,
|
|
getProjectLeaderboard,
|
|
} = await import("./dashboard.js");
|
|
|
|
// Get all earnings for authenticated user (DASHBOARD-1)
|
|
app.get("/api/dashboard/earnings", requireAuth, async (req, res) => {
|
|
try {
|
|
const userId = req.session.userId;
|
|
const result = await getUserEarnings(userId);
|
|
|
|
if (!result.success) {
|
|
return res.status(400).json({ error: result.error });
|
|
}
|
|
|
|
res.json(result.data);
|
|
} catch (err: any) {
|
|
console.error("Get user earnings error:", err);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Get earnings for a specific project (DASHBOARD-2)
|
|
app.get("/api/dashboard/earnings/:projectId", requireAuth, async (req, res) => {
|
|
try {
|
|
const { projectId } = req.params;
|
|
const userId = req.session.userId;
|
|
|
|
const result = await getProjectEarnings(userId, projectId);
|
|
|
|
if (!result.success) {
|
|
return res.status(400).json({ error: result.error });
|
|
}
|
|
|
|
res.json(result.data);
|
|
} catch (err: any) {
|
|
console.error("Get project earnings error:", err);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Get earnings summary for user (DASHBOARD-3)
|
|
app.get("/api/dashboard/summary", requireAuth, async (req, res) => {
|
|
try {
|
|
const userId = req.session.userId;
|
|
const result = await getEarningsSummary(userId);
|
|
|
|
if (!result.success) {
|
|
return res.status(400).json({ error: result.error });
|
|
}
|
|
|
|
res.json(result.data);
|
|
} catch (err: any) {
|
|
console.error("Get earnings summary error:", err);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Get leaderboard for a project (DASHBOARD-4)
|
|
app.get(
|
|
"/api/dashboard/leaderboard/:projectId",
|
|
async (req, res) => {
|
|
try {
|
|
const { projectId } = req.params;
|
|
const limit = parseInt(req.query.limit as string) || 20;
|
|
|
|
const result = await getProjectLeaderboard(projectId, limit);
|
|
|
|
if (!result.success) {
|
|
return res.status(400).json({ error: result.error });
|
|
}
|
|
|
|
res.json(result.data);
|
|
} catch (err: any) {
|
|
console.error("Get leaderboard error:", err);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
}
|
|
);
|
|
|
|
// ========== API-TRIGGERED REVENUE ==========
|
|
// Record custom revenue event (API trigger) (API-REVENUE-1)
|
|
app.post("/api/revenue/trigger", requireAuth, async (req, res) => {
|
|
try {
|
|
const {
|
|
source_type,
|
|
project_id,
|
|
gross_amount,
|
|
platform_fee,
|
|
metadata,
|
|
} = req.body;
|
|
const userId = req.session.userId;
|
|
|
|
if (!source_type || !project_id || !gross_amount) {
|
|
return res.status(400).json({
|
|
error: "Missing source_type, project_id, or gross_amount",
|
|
});
|
|
}
|
|
|
|
if (!["api", "subscription", "donation"].includes(source_type)) {
|
|
return res.status(400).json({
|
|
error: "source_type must be 'api', 'subscription', or 'donation'",
|
|
});
|
|
}
|
|
|
|
// Record revenue event
|
|
const eventResult = await recordRevenueEvent({
|
|
source_type,
|
|
source_id: `api-${Date.now()}-${Math.random().toString(36).substring(7)}`,
|
|
gross_amount: parseFloat(gross_amount),
|
|
platform_fee: platform_fee ? parseFloat(platform_fee) : 0,
|
|
currency: "USD",
|
|
project_id,
|
|
org_id: null,
|
|
metadata,
|
|
requester_org_id: userId,
|
|
});
|
|
|
|
if (!eventResult.success) {
|
|
return res.status(400).json({ error: eventResult.error });
|
|
}
|
|
|
|
// Compute and record splits
|
|
const splitsResult = await computeRevenueSplits(
|
|
project_id,
|
|
(parseFloat(gross_amount) - (platform_fee ? parseFloat(platform_fee) : 0)).toFixed(2),
|
|
new Date()
|
|
);
|
|
|
|
if (!splitsResult.success) {
|
|
return res.status(400).json({
|
|
error: `Failed to compute splits: ${splitsResult.error}`,
|
|
});
|
|
}
|
|
|
|
// Record allocations
|
|
const allocResult = await recordSplitAllocations(
|
|
eventResult.id,
|
|
project_id,
|
|
splitsResult.allocations,
|
|
splitsResult.split_version
|
|
);
|
|
|
|
if (!allocResult.success) {
|
|
return res.status(400).json({
|
|
error: `Failed to record allocations: ${allocResult.error}`,
|
|
});
|
|
}
|
|
|
|
// Deposit to escrow for each contributor
|
|
for (const [userId, allocation] of Object.entries(
|
|
splitsResult.allocations || {}
|
|
)) {
|
|
const allocationData = allocation as any;
|
|
await depositToEscrow(
|
|
userId,
|
|
project_id,
|
|
allocationData.allocated_amount
|
|
);
|
|
}
|
|
|
|
res.status(201).json({
|
|
success: true,
|
|
revenue_event_id: eventResult.id,
|
|
allocations: splitsResult.allocations,
|
|
message: "Revenue recorded and splits computed",
|
|
});
|
|
} catch (err: any) {
|
|
console.error("API revenue trigger error:", err);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Get API revenue events for a project (API-REVENUE-2)
|
|
app.get(
|
|
"/api/revenue/api-events/:projectId",
|
|
requireAuth,
|
|
async (req, res) => {
|
|
try {
|
|
const { projectId } = req.params;
|
|
|
|
const { data: events, error } = await supabase
|
|
.from("revenue_events")
|
|
.select("*")
|
|
.eq("project_id", projectId)
|
|
.eq("source_type", "api")
|
|
.order("created_at", { ascending: false });
|
|
|
|
if (error) throw error;
|
|
|
|
res.json({
|
|
project_id: projectId,
|
|
api_events: events || [],
|
|
count: events?.length || 0,
|
|
});
|
|
} catch (err: any) {
|
|
console.error("Get API revenue events error:", err);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
}
|
|
);
|
|
|
|
// ========== OS KERNEL ROUTES ==========
|
|
// Identity Linking
|
|
app.post("/api/os/link/start", async (req, res) => {
|
|
try {
|
|
const { provider } = req.body;
|
|
const userId = (req.headers["x-user-id"] as string) || req.session.userId;
|
|
|
|
if (!provider || !userId) {
|
|
return res.status(400).json({ error: "Missing provider or user" });
|
|
}
|
|
|
|
const linkingSession = {
|
|
id: `link_${Date.now()}`,
|
|
state: Math.random().toString(36).substring(7),
|
|
expires_at: new Date(Date.now() + 10 * 60 * 1000),
|
|
};
|
|
|
|
res.json({
|
|
linking_session_id: linkingSession.id,
|
|
state: linkingSession.state,
|
|
redirect_url: `/os/link/redirect?provider=${provider}&state=${linkingSession.state}`,
|
|
});
|
|
} catch (error) {
|
|
console.error("Link start error:", error);
|
|
res.status(500).json({ error: "Failed to start linking" });
|
|
}
|
|
});
|
|
|
|
app.post("/api/os/link/complete", async (req, res) => {
|
|
try {
|
|
const { provider, external_id, external_username } = req.body;
|
|
const userId = (req.headers["x-user-id"] as string) || req.session.userId;
|
|
|
|
if (!provider || !external_id || !userId) {
|
|
return res.status(400).json({ error: "Missing required fields" });
|
|
}
|
|
|
|
const { data, error } = await supabase
|
|
.from("aethex_subject_identities")
|
|
.upsert(
|
|
{
|
|
provider,
|
|
external_id,
|
|
external_username,
|
|
verified_at: new Date().toISOString(),
|
|
},
|
|
{
|
|
onConflict: "provider,external_id",
|
|
}
|
|
)
|
|
.select();
|
|
|
|
if (error) throw error;
|
|
|
|
await supabase.from("aethex_audit_log").insert({
|
|
action: "link_identity",
|
|
actor_id: userId,
|
|
actor_type: "user",
|
|
resource_type: "subject_identity",
|
|
resource_id: data?.[0]?.id || "unknown",
|
|
changes: { provider, external_id },
|
|
status: "success",
|
|
});
|
|
|
|
res.json({
|
|
success: true,
|
|
identity: {
|
|
provider,
|
|
external_id,
|
|
verified_at: new Date().toISOString(),
|
|
},
|
|
});
|
|
} catch (error) {
|
|
console.error("Link complete error:", error);
|
|
res.status(500).json({ error: "Failed to complete linking" });
|
|
}
|
|
});
|
|
|
|
app.post("/api/os/link/unlink", async (req, res) => {
|
|
try {
|
|
const { provider, external_id } = req.body;
|
|
const userId = (req.headers["x-user-id"] as string) || req.session.userId;
|
|
|
|
if (!provider || !external_id) {
|
|
return res.status(400).json({ error: "Missing provider or external_id" });
|
|
}
|
|
|
|
const { data, error } = await supabase
|
|
.from("aethex_subject_identities")
|
|
.update({ revoked_at: new Date().toISOString() })
|
|
.match({ provider, external_id })
|
|
.select();
|
|
|
|
if (error) throw error;
|
|
|
|
await supabase.from("aethex_audit_log").insert({
|
|
action: "unlink_identity",
|
|
actor_id: userId,
|
|
actor_type: "user",
|
|
resource_type: "subject_identity",
|
|
resource_id: data?.[0]?.id || "unknown",
|
|
changes: { revoked: true },
|
|
status: "success",
|
|
});
|
|
|
|
res.json({ success: true, message: "Identity unlinked" });
|
|
} catch (error) {
|
|
console.error("Unlink error:", error);
|
|
res.status(500).json({ error: "Failed to unlink identity" });
|
|
}
|
|
});
|
|
|
|
// Entitlements
|
|
app.post("/api/os/entitlements/issue", async (req, res) => {
|
|
try {
|
|
const issuerId = req.headers["x-issuer-id"] as string;
|
|
const {
|
|
subject_id,
|
|
external_subject_ref,
|
|
entitlement_type,
|
|
scope,
|
|
data,
|
|
expires_at,
|
|
} = req.body;
|
|
|
|
if (!issuerId || (!subject_id && !external_subject_ref)) {
|
|
return res
|
|
.status(400)
|
|
.json({ error: "Missing issuer_id or subject reference" });
|
|
}
|
|
|
|
const { data: entitlement, error } = await supabase
|
|
.from("aethex_entitlements")
|
|
.insert({
|
|
issuer_id: issuerId,
|
|
subject_id: subject_id || null,
|
|
external_subject_ref: external_subject_ref || null,
|
|
entitlement_type,
|
|
scope,
|
|
data: data || {},
|
|
status: "active",
|
|
expires_at: expires_at || null,
|
|
})
|
|
.select()
|
|
.single();
|
|
|
|
if (error) throw error;
|
|
|
|
await supabase.from("aethex_audit_log").insert({
|
|
action: "issue_entitlement",
|
|
actor_id: issuerId,
|
|
actor_type: "issuer",
|
|
resource_type: "entitlement",
|
|
resource_id: entitlement?.id || "unknown",
|
|
changes: { entitlement_type, scope },
|
|
status: "success",
|
|
});
|
|
|
|
res.json({
|
|
success: true,
|
|
entitlement: {
|
|
id: entitlement?.id,
|
|
type: entitlement_type,
|
|
scope,
|
|
created_at: entitlement?.created_at,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
console.error("Issue error:", error);
|
|
res.status(500).json({ error: "Failed to issue entitlement" });
|
|
}
|
|
});
|
|
|
|
app.post("/api/os/entitlements/verify", async (req, res) => {
|
|
try {
|
|
const { entitlement_id } = req.body;
|
|
|
|
if (!entitlement_id) {
|
|
return res.status(400).json({ error: "Missing entitlement_id" });
|
|
}
|
|
|
|
const { data: entitlement, error } = await supabase
|
|
.from("aethex_entitlements")
|
|
.select("*, issuer:aethex_issuers(*)")
|
|
.eq("id", entitlement_id)
|
|
.single();
|
|
|
|
if (error || !entitlement) {
|
|
return res
|
|
.status(404)
|
|
.json({ valid: false, reason: "Entitlement not found" });
|
|
}
|
|
|
|
if (entitlement.status === "revoked") {
|
|
return res.json({
|
|
valid: false,
|
|
reason: "revoked",
|
|
revoked_at: entitlement.revoked_at,
|
|
revocation_reason: entitlement.revocation_reason,
|
|
});
|
|
}
|
|
|
|
if (
|
|
entitlement.status === "expired" ||
|
|
(entitlement.expires_at && new Date() > new Date(entitlement.expires_at))
|
|
) {
|
|
return res.json({
|
|
valid: false,
|
|
reason: "expired",
|
|
expires_at: entitlement.expires_at,
|
|
});
|
|
}
|
|
|
|
await supabase.from("aethex_entitlement_events").insert({
|
|
entitlement_id,
|
|
event_type: "verified",
|
|
actor_type: "system",
|
|
reason: "API verification",
|
|
});
|
|
|
|
res.json({
|
|
valid: true,
|
|
entitlement: {
|
|
id: entitlement.id,
|
|
type: entitlement.entitlement_type,
|
|
scope: entitlement.scope,
|
|
data: entitlement.data,
|
|
issuer: {
|
|
id: entitlement.issuer?.id,
|
|
name: entitlement.issuer?.name,
|
|
class: entitlement.issuer?.issuer_class,
|
|
},
|
|
issued_at: entitlement.created_at,
|
|
expires_at: entitlement.expires_at,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
console.error("Verify error:", error);
|
|
res.status(500).json({ error: "Failed to verify entitlement" });
|
|
}
|
|
});
|
|
|
|
app.get("/api/os/entitlements/resolve", async (req, res) => {
|
|
try {
|
|
const { platform, id, subject_id } = req.query;
|
|
|
|
let entitlements: any[] = [];
|
|
|
|
if (subject_id) {
|
|
const { data, error } = await supabase
|
|
.from("aethex_entitlements")
|
|
.select("*, issuer:aethex_issuers(*)")
|
|
.eq("subject_id", subject_id as string)
|
|
.eq("status", "active");
|
|
|
|
if (error) throw error;
|
|
entitlements = data || [];
|
|
} else if (platform && id) {
|
|
const externalRef = `${platform}:${id}`;
|
|
const { data, error } = await supabase
|
|
.from("aethex_entitlements")
|
|
.select("*, issuer:aethex_issuers(*)")
|
|
.eq("external_subject_ref", externalRef)
|
|
.eq("status", "active");
|
|
|
|
if (error) throw error;
|
|
entitlements = data || [];
|
|
} else {
|
|
return res.status(400).json({ error: "Missing platform/id or subject_id" });
|
|
}
|
|
|
|
res.json({
|
|
entitlements: entitlements.map((e) => ({
|
|
id: e.id,
|
|
type: e.entitlement_type,
|
|
scope: e.scope,
|
|
data: e.data,
|
|
issuer: {
|
|
name: e.issuer?.name,
|
|
class: e.issuer?.issuer_class,
|
|
},
|
|
issued_at: e.created_at,
|
|
expires_at: e.expires_at,
|
|
})),
|
|
});
|
|
} catch (error) {
|
|
console.error("Resolve error:", error);
|
|
res.status(500).json({ error: "Failed to resolve entitlements" });
|
|
}
|
|
});
|
|
|
|
app.post("/api/os/entitlements/revoke", async (req, res) => {
|
|
try {
|
|
const issuerId = req.headers["x-issuer-id"] as string;
|
|
const { entitlement_id, reason } = req.body;
|
|
|
|
if (!entitlement_id || !reason) {
|
|
return res
|
|
.status(400)
|
|
.json({ error: "Missing entitlement_id or reason" });
|
|
}
|
|
|
|
const { data, error } = await supabase
|
|
.from("aethex_entitlements")
|
|
.update({
|
|
status: "revoked",
|
|
revoked_at: new Date().toISOString(),
|
|
revocation_reason: reason,
|
|
})
|
|
.eq("id", entitlement_id)
|
|
.select();
|
|
|
|
if (error) throw error;
|
|
|
|
await supabase.from("aethex_entitlement_events").insert({
|
|
entitlement_id,
|
|
event_type: "revoked",
|
|
actor_id: issuerId,
|
|
actor_type: "issuer",
|
|
reason,
|
|
});
|
|
|
|
await supabase.from("aethex_audit_log").insert({
|
|
action: "revoke_entitlement",
|
|
actor_id: issuerId,
|
|
actor_type: "issuer",
|
|
resource_type: "entitlement",
|
|
resource_id: entitlement_id,
|
|
changes: { status: "revoked", reason },
|
|
status: "success",
|
|
});
|
|
|
|
res.json({ success: true, message: "Entitlement revoked" });
|
|
} catch (error) {
|
|
console.error("Revoke error:", error);
|
|
res.status(500).json({ error: "Failed to revoke entitlement" });
|
|
}
|
|
});
|
|
|
|
// Simple in-memory file storage (per-user, per-org, session-based)
|
|
const fileStore = new Map<string, any[]>();
|
|
|
|
app.get("/api/files", requireAuth, attachOrgContext, requireOrgMember, async (req, res) => {
|
|
try {
|
|
const userId = req.session.userId;
|
|
const orgId = getOrgIdOrThrow(req);
|
|
if (!userId) return res.status(401).json({ error: "Unauthorized" });
|
|
|
|
const key = `${userId}:${orgId}`;
|
|
const files = fileStore.get(key) || [];
|
|
const { path } = req.query;
|
|
|
|
// Filter by path
|
|
const filtered = path
|
|
? files.filter(f => f.path.startsWith(`${path}/`) || f.path === path)
|
|
: files.filter(f => f.path === '/');
|
|
|
|
res.json({ files: filtered });
|
|
} catch (error) {
|
|
console.error("File list error:", error);
|
|
res.status(500).json({ error: "Failed to fetch files" });
|
|
}
|
|
});
|
|
|
|
app.post("/api/files", requireAuth, attachOrgContext, requireOrgMember, async (req, res) => {
|
|
try {
|
|
const userId = req.session.userId;
|
|
const orgId = getOrgIdOrThrow(req);
|
|
if (!userId) return res.status(401).json({ error: "Unauthorized" });
|
|
|
|
const { name, type, path, content, language, project_id } = req.body;
|
|
if (!name || !type || !path) {
|
|
return res.status(400).json({ error: "Missing required fields" });
|
|
}
|
|
|
|
const fileId = randomUUID();
|
|
const newFile = {
|
|
id: fileId,
|
|
user_id: userId,
|
|
organization_id: orgId,
|
|
project_id: project_id || null,
|
|
name,
|
|
type,
|
|
path,
|
|
content: content || '',
|
|
language: language || null,
|
|
size: content?.length || 0,
|
|
mime_type: null,
|
|
parent_id: null,
|
|
created_at: new Date().toISOString(),
|
|
updated_at: new Date().toISOString(),
|
|
};
|
|
|
|
const key = `${userId}:${orgId}`;
|
|
const files = fileStore.get(key) || [];
|
|
files.push(newFile);
|
|
fileStore.set(key, files);
|
|
|
|
res.json(newFile);
|
|
} catch (error) {
|
|
console.error("File creation error:", error);
|
|
res.status(500).json({ error: "Failed to create file" });
|
|
}
|
|
});
|
|
|
|
app.patch("/api/files/:id", requireAuth, attachOrgContext, requireOrgMember, async (req, res) => {
|
|
try {
|
|
const userId = req.session.userId;
|
|
const orgId = getOrgIdOrThrow(req);
|
|
if (!userId) return res.status(401).json({ error: "Unauthorized" });
|
|
|
|
const { id } = req.params;
|
|
const { name, content } = req.body;
|
|
|
|
const key = `${userId}:${orgId}`;
|
|
const files = fileStore.get(key) || [];
|
|
const file = files.find(f => f.id === id);
|
|
|
|
if (!file) {
|
|
return res.status(404).json({ error: "File not found" });
|
|
}
|
|
|
|
if (name) file.name = name;
|
|
if (content !== undefined) file.content = content;
|
|
file.updated_at = new Date().toISOString();
|
|
|
|
res.json(file);
|
|
} catch (error) {
|
|
console.error("File update error:", error);
|
|
res.status(500).json({ error: "Failed to update file" });
|
|
}
|
|
});
|
|
|
|
app.delete("/api/files/:id", requireAuth, attachOrgContext, requireOrgMember, async (req, res) => {
|
|
try {
|
|
const userId = req.session.userId;
|
|
const orgId = getOrgIdOrThrow(req);
|
|
if (!userId) return res.status(401).json({ error: "Unauthorized" });
|
|
|
|
const { id } = req.params;
|
|
const key = `${userId}:${orgId}`;
|
|
let files = fileStore.get(key) || [];
|
|
const fileToDelete = files.find(f => f.id === id);
|
|
|
|
if (!fileToDelete) {
|
|
return res.status(404).json({ error: "File not found" });
|
|
}
|
|
|
|
// If folder, delete all files inside
|
|
if (fileToDelete.type === 'folder') {
|
|
files = files.filter(f => !f.path.startsWith(fileToDelete.path + '/') && f.id !== id);
|
|
} else {
|
|
files = files.filter(f => f.id !== id);
|
|
}
|
|
|
|
fileStore.set(key, files);
|
|
res.json({ id, deleted: true });
|
|
} catch (error) {
|
|
console.error("File delete error:", error);
|
|
res.status(500).json({ error: "Failed to delete file" });
|
|
}
|
|
});
|
|
|
|
app.get("/api/os/issuers/:id", async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
|
|
const { data: issuer, error } = await supabase
|
|
.from("aethex_issuers")
|
|
.select("*")
|
|
.eq("id", id)
|
|
.single();
|
|
|
|
if (error || !issuer) {
|
|
return res.status(404).json({ error: "Issuer not found" });
|
|
}
|
|
|
|
res.json({
|
|
id: issuer.id,
|
|
name: issuer.name,
|
|
class: issuer.issuer_class,
|
|
scopes: issuer.scopes,
|
|
public_key: issuer.public_key,
|
|
is_active: issuer.is_active,
|
|
metadata: issuer.metadata,
|
|
});
|
|
} catch (error) {
|
|
console.error("Issuer fetch error:", error);
|
|
res.status(500).json({ error: "Failed to fetch issuer" });
|
|
}
|
|
});
|
|
|
|
return httpServer;
|
|
}
|