This repository has been archived on 2026-03-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
token-gated-economy/artifacts/api-server/src/routes/sessions.ts
alexpaynex 5b3d7edf6a Add streaming capabilities and improve API stability and security
Introduce streaming for AI job execution, implement rate limiting for API endpoints, enhance CORS configuration, and refactor event handling.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 418bf6f8-212b-4bb0-a7a5-8231a061da4e
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 2967540c-7b01-4168-87be-fde774e32494
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/9f85e954-647c-46a5-90a7-396e495a805a/418bf6f8-212b-4bb0-a7a5-8231a061da4e/Q83Uqvu
Replit-Helium-Checkpoint-Created: true
2026-03-18 22:17:10 +00:00

441 lines
15 KiB
TypeScript

import { Router, type Request, type Response } from "express";
import { randomBytes, randomUUID } from "crypto";
import { db, sessions, sessionRequests, type Session } from "@workspace/db";
import { eq, and } from "drizzle-orm";
import { lnbitsService } from "../lib/lnbits.js";
import { sessionsLimiter } from "../lib/rate-limiter.js";
import { eventBus } from "../lib/event-bus.js";
import { agentService } from "../lib/agent.js";
import { pricingService } from "../lib/pricing.js";
import { getBtcPriceUsd, usdToSats } from "../lib/btc-oracle.js";
const router = Router();
// ── Env-var config ─────────────────────────────────────────────────────────────
function envInt(name: string, fallback: number): number {
const raw = parseInt(process.env[name] ?? "", 10);
return Number.isFinite(raw) && raw > 0 ? raw : fallback;
}
const MIN_DEPOSIT_SATS = envInt("SESSION_MIN_DEPOSIT_SATS", 100);
const MAX_DEPOSIT_SATS = envInt("SESSION_MAX_DEPOSIT_SATS", 10_000);
const MIN_BALANCE_SATS = envInt("SESSION_MIN_BALANCE_SATS", 50);
const EXPIRY_HOURS = envInt("SESSION_EXPIRY_HOURS", 24);
const EXPIRY_MS = EXPIRY_HOURS * 60 * 60 * 1000;
// ── Helpers ────────────────────────────────────────────────────────────────────
async function getSessionById(id: string): Promise<Session | null> {
const rows = await db.select().from(sessions).where(eq(sessions.id, id)).limit(1);
return rows[0] ?? null;
}
function checkExpired(session: Session): boolean {
return session.expiresAt !== null && new Date() > session.expiresAt;
}
function extractMacaroon(req: Request): string | null {
const auth = req.headers.authorization ?? "";
if (auth.startsWith("Bearer ")) return auth.slice(7).trim();
return null;
}
function sessionView(session: Session, includeInvoice = false) {
const base = {
sessionId: session.id,
state: session.state,
balanceSats: session.balanceSats,
expiresAt: session.expiresAt?.toISOString() ?? null,
minimumBalanceSats: MIN_BALANCE_SATS,
...(session.macaroon && (session.state === "active" || session.state === "paused")
? { macaroon: session.macaroon }
: {}),
};
if (includeInvoice && session.state === "awaiting_payment") {
return {
...base,
invoice: {
paymentRequest: session.depositPaymentRequest,
amountSats: session.depositAmountSats,
...(lnbitsService.stubMode ? { paymentHash: session.depositPaymentHash } : {}),
},
};
}
if (session.topupPaymentHash && !session.topupPaid) {
return {
...base,
pendingTopup: {
paymentRequest: session.topupPaymentRequest,
amountSats: session.topupAmountSats,
...(lnbitsService.stubMode ? { paymentHash: session.topupPaymentHash } : {}),
},
};
}
return base;
}
// ── Auto-advance: awaiting_payment → active ────────────────────────────────────
async function advanceSessionPayment(session: Session): Promise<Session> {
if (session.state !== "awaiting_payment" || session.depositPaid) return session;
const paid = await lnbitsService.checkInvoicePaid(session.depositPaymentHash);
if (!paid) return session;
const macaroon = randomBytes(32).toString("hex");
const expiresAt = new Date(Date.now() + EXPIRY_MS);
const updated = await db
.update(sessions)
.set({
state: "active",
depositPaid: true,
balanceSats: session.depositAmountSats,
macaroon,
expiresAt,
updatedAt: new Date(),
})
.where(and(eq(sessions.id, session.id), eq(sessions.state, "awaiting_payment")))
.returning();
return updated[0] ?? session;
}
// ── Auto-advance: pending topup paid → credit balance ─────────────────────────
async function advanceTopup(session: Session): Promise<Session> {
if (!session.topupPaymentHash || session.topupPaid) return session;
const paid = await lnbitsService.checkInvoicePaid(session.topupPaymentHash);
if (!paid) return session;
const newBalance = session.balanceSats + (session.topupAmountSats ?? 0);
const newState =
session.state === "paused" && newBalance >= MIN_BALANCE_SATS ? "active" : session.state;
const expiresAt = new Date(Date.now() + EXPIRY_MS);
const updated = await db
.update(sessions)
.set({
state: newState,
topupPaid: true,
balanceSats: newBalance,
expiresAt,
updatedAt: new Date(),
})
.where(eq(sessions.id, session.id))
.returning();
return updated[0] ?? session;
}
// ── POST /sessions ─────────────────────────────────────────────────────────────
router.post("/sessions", sessionsLimiter, async (req: Request, res: Response) => {
const rawAmount = req.body?.amount_sats;
const amountSats = parseInt(String(rawAmount ?? ""), 10);
if (!Number.isFinite(amountSats) || amountSats < MIN_DEPOSIT_SATS || amountSats > MAX_DEPOSIT_SATS) {
res.status(400).json({
error: `amount_sats must be an integer between ${MIN_DEPOSIT_SATS} and ${MAX_DEPOSIT_SATS}`,
});
return;
}
try {
const sessionId = randomUUID();
const invoice = await lnbitsService.createInvoice(amountSats, `Session deposit ${sessionId}`);
await db.insert(sessions).values({
id: sessionId,
state: "awaiting_payment",
balanceSats: 0,
depositAmountSats: amountSats,
depositPaymentHash: invoice.paymentHash,
depositPaymentRequest: invoice.paymentRequest,
depositPaid: false,
expiresAt: new Date(Date.now() + EXPIRY_MS),
});
res.status(201).json({
sessionId,
state: "awaiting_payment",
invoice: {
paymentRequest: invoice.paymentRequest,
amountSats,
...(lnbitsService.stubMode ? { paymentHash: invoice.paymentHash } : {}),
},
});
} catch (err) {
res.status(500).json({ error: err instanceof Error ? err.message : "Failed to create session" });
}
});
// ── GET /sessions/:id ─────────────────────────────────────────────────────────
router.get("/sessions/:id", async (req: Request, res: Response) => {
const id = req.params.id as string;
try {
let session = await getSessionById(id);
if (!session) { res.status(404).json({ error: "Session not found" }); return; }
// Mark expired sessions
if (checkExpired(session) && session.state !== "expired") {
await db
.update(sessions)
.set({ state: "expired", updatedAt: new Date() })
.where(eq(sessions.id, id));
session = (await getSessionById(id))!;
}
// Auto-advance deposit payment
if (session.state === "awaiting_payment") {
session = await advanceSessionPayment(session);
}
// Auto-advance topup payment
if (session.topupPaymentHash && !session.topupPaid) {
session = await advanceTopup(session);
}
res.json(sessionView(session, true));
} catch (err) {
res.status(500).json({ error: err instanceof Error ? err.message : "Failed to fetch session" });
}
});
// ── POST /sessions/:id/request ────────────────────────────────────────────────
router.post("/sessions/:id/request", async (req: Request, res: Response) => {
const id = req.params.id as string;
const macaroon = extractMacaroon(req);
const requestText = typeof req.body?.request === "string" ? req.body.request.trim() : "";
if (!requestText) {
res.status(400).json({ error: "Body must include 'request' string" });
return;
}
try {
let session = await getSessionById(id);
if (!session) { res.status(404).json({ error: "Session not found" }); return; }
// Auth
if (!macaroon || macaroon !== session.macaroon) {
res.status(401).json({ error: "Invalid or missing macaroon. Include 'Authorization: Bearer <macaroon>' header." });
return;
}
// State checks
if (checkExpired(session) || session.state === "expired") {
res.status(410).json({ error: "Session has expired" });
return;
}
if (session.state === "paused") {
res.status(402).json({
error: "Insufficient balance",
balance: session.balanceSats,
minimumRequired: MIN_BALANCE_SATS,
});
return;
}
if (session.state !== "active") {
res.status(409).json({ error: `Session is in state '${session.state}'` });
return;
}
if (session.balanceSats < MIN_BALANCE_SATS) {
// Mark as paused before returning
await db
.update(sessions)
.set({ state: "paused", updatedAt: new Date() })
.where(eq(sessions.id, id));
res.status(402).json({
error: "Insufficient balance",
balance: session.balanceSats,
minimumRequired: MIN_BALANCE_SATS,
});
return;
}
// ── Run the request ───────────────────────────────────────────────────────
const requestId = randomUUID();
const btcPriceUsd = await getBtcPriceUsd();
// Eval phase
const evalResult = await agentService.evaluateRequest(requestText);
const evalCostUsd = pricingService.calculateActualCostUsd(
evalResult.inputTokens,
evalResult.outputTokens,
agentService.evalModel,
);
let workInputTokens = 0;
let workOutputTokens = 0;
let workCostUsd = 0;
let result: string | null = null;
let finalState: "complete" | "rejected" | "failed" = "rejected";
let reason: string | null = null;
let errorMessage: string | null = null;
if (evalResult.accepted) {
try {
const workResult = await agentService.executeWork(requestText);
workInputTokens = workResult.inputTokens;
workOutputTokens = workResult.outputTokens;
workCostUsd = pricingService.calculateActualCostUsd(
workResult.inputTokens,
workResult.outputTokens,
agentService.workModel,
);
result = workResult.result;
finalState = "complete";
} catch (err) {
errorMessage = err instanceof Error ? err.message : "Execution error";
finalState = "failed";
}
} else {
reason = evalResult.reason;
}
// ── Honest accounting ────────────────────────────────────────────────────
const totalTokenCostUsd = evalCostUsd + workCostUsd;
const chargeUsd = pricingService.calculateActualChargeUsd(totalTokenCostUsd);
const debitedSats = usdToSats(chargeUsd, btcPriceUsd);
const newBalance = session.balanceSats - debitedSats;
const newSessionState = newBalance < MIN_BALANCE_SATS ? "paused" : "active";
const expiresAt = new Date(Date.now() + EXPIRY_MS);
// Persist session request + update session balance atomically
await db.transaction(async (tx) => {
await tx.insert(sessionRequests).values({
id: requestId,
sessionId: id,
request: requestText,
state: finalState,
result,
reason,
errorMessage,
evalInputTokens: evalResult.inputTokens,
evalOutputTokens: evalResult.outputTokens,
workInputTokens: workInputTokens || null,
workOutputTokens: workOutputTokens || null,
debitedSats,
balanceAfterSats: newBalance,
btcPriceUsd,
});
await tx
.update(sessions)
.set({
balanceSats: newBalance,
state: newSessionState,
expiresAt,
updatedAt: new Date(),
})
.where(eq(sessions.id, id));
});
res.json({
requestId,
state: finalState,
...(result ? { result } : {}),
...(reason ? { reason } : {}),
...(errorMessage ? { errorMessage } : {}),
debitedSats,
balanceRemaining: newBalance,
cost: {
evalSats: usdToSats(
pricingService.calculateActualChargeUsd(evalCostUsd),
btcPriceUsd,
),
workSats: workCostUsd > 0
? usdToSats(pricingService.calculateActualChargeUsd(workCostUsd), btcPriceUsd)
: 0,
totalSats: debitedSats,
btcPriceUsd,
},
});
} catch (err) {
res.status(500).json({ error: err instanceof Error ? err.message : "Request failed" });
}
});
// ── POST /sessions/:id/topup ──────────────────────────────────────────────────
router.post("/sessions/:id/topup", async (req: Request, res: Response) => {
const id = req.params.id as string;
const macaroon = extractMacaroon(req);
const rawAmount = req.body?.amount_sats;
const amountSats = parseInt(String(rawAmount ?? ""), 10);
if (!Number.isFinite(amountSats) || amountSats < MIN_DEPOSIT_SATS || amountSats > MAX_DEPOSIT_SATS) {
res.status(400).json({
error: `amount_sats must be an integer between ${MIN_DEPOSIT_SATS} and ${MAX_DEPOSIT_SATS}`,
});
return;
}
try {
const session = await getSessionById(id);
if (!session) { res.status(404).json({ error: "Session not found" }); return; }
if (!macaroon || macaroon !== session.macaroon) {
res.status(401).json({ error: "Invalid or missing macaroon" });
return;
}
if (session.state !== "active" && session.state !== "paused") {
res.status(409).json({ error: `Cannot top up a session in state '${session.state}'` });
return;
}
if (session.topupPaymentHash && !session.topupPaid) {
res.status(409).json({
error: "A topup invoice is already pending. Pay it first or poll GET /sessions/:id.",
pendingTopup: {
paymentRequest: session.topupPaymentRequest,
amountSats: session.topupAmountSats,
...(lnbitsService.stubMode ? { paymentHash: session.topupPaymentHash } : {}),
},
});
return;
}
const invoice = await lnbitsService.createInvoice(amountSats, `Session topup ${id}`);
await db
.update(sessions)
.set({
topupAmountSats: amountSats,
topupPaymentHash: invoice.paymentHash,
topupPaymentRequest: invoice.paymentRequest,
topupPaid: false,
updatedAt: new Date(),
})
.where(eq(sessions.id, id));
res.json({
sessionId: id,
topup: {
paymentRequest: invoice.paymentRequest,
amountSats,
...(lnbitsService.stubMode ? { paymentHash: invoice.paymentHash } : {}),
},
});
} catch (err) {
res.status(500).json({ error: err instanceof Error ? err.message : "Topup failed" });
}
});
export default router;