Add session mode for pre-funded request processing

Implement session-based API endpoints for creating, managing, and interacting with pre-funded sessions, including deposit and top-up invoice generation, macaroon authentication, and per-request debiting of compute costs.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 418bf6f8-212b-4bb0-a7a5-8231a061da4e
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 2dc3847e-7186-4a22-9c7e-16cd31bca8d9
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/9f85e954-647c-46a5-90a7-396e495a805a/418bf6f8-212b-4bb0-a7a5-8231a061da4e/sPDHkg8
Replit-Helium-Checkpoint-Created: true
This commit is contained in:
alexpaynex
2026-03-18 20:00:24 +00:00
parent dfc9ecdc7b
commit ab2cc06a79
29 changed files with 1075 additions and 978 deletions

View File

@@ -0,0 +1,438 @@
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 { 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", 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;