// Recap Relay — operator-side credit-metered proxy in front of Gemini // (and optionally a co-located Parakeet+Gemma setup). // // Two public endpoints: // POST /relay/transcribe — audio → text (Gemini File API) // POST /relay/analyze — text → topic sections JSON (Gemini Pro) // Plus admin endpoints under /admin/* gated by an HTTP session cookie. import express from "express"; import cors from "cors"; import cookieParser from "cookie-parser"; import path from "path"; import { fileURLToPath } from "url"; import { initConfig } from "./config.js"; import { initCredits } from "./credits.js"; import { initAuditLog } from "./audit-log.js"; import { initJobCredits } from "./job-credits.js"; import { initOutputStore } from "./output-store.js"; import { setupAdminAuthMiddleware, setupAdminAuthRoutes, } from "./admin-auth.js"; import { transcribeRouter } from "./routes/transcribe.js"; import { transcribeUrlRouter } from "./routes/transcribe-url.js"; import { summarizeUrlRouter } from "./routes/summarize-url.js"; import { analyzeRouter } from "./routes/analyze.js"; import { ttsRouter } from "./routes/tts.js"; import { userTierRouter } from "./routes/user-tier.js"; import { healthRouter } from "./routes/health.js"; import { balanceRouter } from "./routes/balance.js"; import { policyRouter } from "./routes/policy.js"; import { capabilitiesRouter } from "./routes/capabilities.js"; import { creditsRouter } from "./routes/credits.js"; import { zapriteWebhookRouter } from "./routes/zaprite-webhook.js"; import { adminRouter } from "./routes/admin.js"; import { internalMeetingsRouter } from "./routes/internal-meetings.js"; import { adminTestRunRouter } from "./routes/admin-test-run.js"; import { btcpaySetupRouter } from "./routes/btcpay-setup.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // DATA_DIR is /data on StartOS, project root in dev. const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, ".."); const PORT = parseInt(process.env.PORT || "3002", 10); await initConfig({ dataDir: DATA_DIR }); await initCredits({ dataDir: DATA_DIR }); await initJobCredits({ dataDir: DATA_DIR }); await initAuditLog({ dataDir: DATA_DIR }); await initOutputStore({ dataDir: DATA_DIR }); const app = express(); app.use(cors()); app.use(cookieParser()); // Admin auth must run BEFORE the admin routes register so the cookie // check applies to /admin/usage, /admin/config, etc. /admin/login and // /admin/status are explicitly exempted inside the middleware. setupAdminAuthMiddleware(app); setupAdminAuthRoutes(app); // Public relay endpoints. No app-level auth — each route handler // authenticates per-call via headers (X-Recap-Install-Id required, // Authorization optional). app.use("/relay", healthRouter()); app.use("/relay", policyRouter()); app.use("/relay", capabilitiesRouter()); app.use("/relay", balanceRouter()); app.use("/relay", transcribeRouter()); app.use("/relay", transcribeUrlRouter()); app.use("/relay", summarizeUrlRouter()); app.use("/relay", analyzeRouter()); app.use("/relay", ttsRouter()); app.use("/relay", userTierRouter()); app.use("/relay", creditsRouter()); app.use("/relay", zapriteWebhookRouter()); // Admin dashboard endpoints (cookie-gated). app.use("/admin", adminRouter({ dataDir: DATA_DIR })); app.use("/admin", adminTestRunRouter()); // One-click BTCPay setup wizard (uses admin auth for the start + // stores + finalize endpoints; callback is admin-exempt and uses a // state token instead — see btcpay-setup.js for the design notes). app.use("/admin/btcpay", btcpaySetupRouter({ dataDir: DATA_DIR })); // Internal team meeting upload + analysis (Path 2A — operator-only, // admin-auth gated by the parent /admin prefix's middleware). app.use( "/admin/internal-meetings", internalMeetingsRouter({ dataDir: DATA_DIR }) ); // Static admin UI (v0.2 will flesh out public/admin.html). For v0.1 // the dashboard is JSON-only; serve any static assets dropped into // public/ but don't error if the directory is empty. app.use(express.static(path.join(__dirname, "..", "public"))); // Root: send operators straight to the dashboard so the StartOS // "Launch UI" button (which points at `/`) lands on something useful. // The dashboard's JS handles the admin-auth gate — first hit shows the // login form when an admin password is configured, then the dashboard // itself. Recap clients only ever hit /relay/* paths, so this redirect // doesn't affect them. app.get("/", (_req, res) => { res.redirect("/dashboard.html"); }); // Error handler (must be last). Without it, body-parser parse failures // and any other propagated error fall through to Express's default // handler, which renders an HTML page including the local-filesystem // stack trace — an info leak. Return clean JSON instead. app.use((err, _req, res, next) => { if (res.headersSent) return next(err); // mid-stream (SSE) — can't rewrite if (err?.type === "entity.parse.failed") { return res.status(400).json({ error: "invalid JSON body" }); } if (err?.type === "entity.too.large") { return res.status(413).json({ error: "request body too large" }); } console.error("[relay] unhandled error:", err?.message || err); return res.status(500).json({ error: "internal error" }); }); const HOSTNAME = process.env.HOSTNAME || "0.0.0.0"; app.listen(PORT, HOSTNAME, () => { console.log(`[relay] listening on http://${HOSTNAME}:${PORT}`); console.log(`[relay] data directory: ${DATA_DIR}`); });