Files
recap-relay/server/index.js
T
Keysat 238689ddcc Persist payment-webhook dedup; declare BTCPay required; scope CORS
Replace the in-memory dedup Sets in the BTCPay and Zaprite webhook
handlers (and the BTCPay rescan path) with a persistent JSON-backed
store (server/webhook-dedup.js). The in-memory sets were cleared on
restart, so a duplicate webhook delivery straddling a relay restart
could double-credit (BTCPay) or double-extend a subscription (Zaprite).
The store atomically writes /data/processed-webhooks.json, namespaces
keys per rail (storeId|invoiceId vs zaprite:orderId), and prunes
entries older than 180 days (safely beyond any retry window).

Also:
- BTCPay is a required running dependency (operator decision). Config
  was already optional:false/kind:'running'; corrected the contradictory
  "optional" comment in the manifest to match.
- Scope cors() to /relay/* only — off /admin/* and the same-origin
  dashboard, which don't need permissive CORS.
- Add money-path unit tests (commitCredit/refundCredit/applyTierPromotion)
  and webhook-dedup tests (incl. the survives-a-restart guarantee).
- Fix two AGENTS.md auth-doc drifts; refresh Current state.

Version 0.2.125 -> 0.2.126.
2026-06-15 18:15:00 -05:00

135 lines
5.8 KiB
JavaScript

// 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 { initWebhookDedup } from "./webhook-dedup.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 });
await initWebhookDedup({ dataDir: DATA_DIR });
const app = express();
// CORS only on /relay/* — those are the cross-origin clients (the Recaps
// app + cloud server). /admin/* and the dashboard are served same-origin
// to the operator's browser, so they don't need (and shouldn't get) the
// permissive Access-Control-Allow-Origin a global cors() would apply.
app.use("/relay", 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}`);
});