import { Router, type Request, type Response } from "express"; import { randomUUID } from "crypto"; import { db, bootstrapJobs, invoices, type BootstrapJob } from "@workspace/db"; import { eq, and } from "drizzle-orm"; import { lnbitsService } from "../lib/lnbits.js"; import { pricingService } from "../lib/pricing.js"; import { provisionerService } from "../lib/provisioner.js"; import { makeLogger } from "../lib/logger.js"; // Assuming a Zod schema for request body and params will be created // import { CreateBootstrapJobBody, GetBootstrapJobParams } from "@workspace/api-zod"; const logger = makeLogger("bootstrap-routes"); const router = Router(); async function getBootstrapJobById(id: string): Promise { const rows = await db.select().from(bootstrapJobs).where(eq(bootstrapJobs.id, id)).limit(1); return rows[0] ?? null; } async function getInvoiceById(id: string) { const rows = await db.select().from(invoices).where(eq(invoices.id, id)).limit(1); return rows[0] ?? null; } /** * Runs the node provisioning in a background task so HTTP polls return fast. */ async function runProvisioningInBackground(jobId: string): Promise { try { logger.info("starting node provisioning", { jobId }); await db.update(bootstrapJobs).set({ state: "provisioning", updatedAt: new Date() }).where(eq(bootstrapJobs.id, jobId)); const provisionResult = await provisionerService.provisionNode(jobId); await db .update(bootstrapJobs) .set({ state: "ready", dropletId: provisionResult.dropletId, nodeIp: provisionResult.nodeIp, tailscaleHostname: provisionResult.tailscaleHostname, lnbitsUrl: provisionResult.lnbitsUrl, sshPrivateKey: provisionResult.sshPrivateKey, // Stored once, cleared after delivery updatedAt: new Date(), }) .where(eq(bootstrapJobs.id, jobId)); logger.info("node provisioning complete", { jobId, dropletId: provisionResult.dropletId }); } catch (err) { const message = err instanceof Error ? err.message : "Node provisioning error"; logger.error("node provisioning failed", { jobId, error: message }); await db .update(bootstrapJobs) .set({ state: "failed", errorMessage: message, updatedAt: new Date() }) .where(eq(bootstrapJobs.id, jobId)); } } /** * Checks whether the bootstrap invoice has been paid and, if so, * advances the state machine. */ async function advanceBootstrapJob(job: BootstrapJob): Promise { if (job.state === "awaiting_payment") { // Assuming invoice details are directly on the bootstrapJob, not a separate invoice table // If a separate invoice entry is needed, uncomment the invoice related logic from jobs.ts const isPaid = await lnbitsService.checkInvoicePaid(job.paymentHash); if (!isPaid) return job; const advanced = await db.transaction(async (tx) => { // For now, we update the bootstrap job directly. If we had a separate `invoices` table // linked to bootstrap jobs, we would update that too. const updated = await tx .update(bootstrapJobs) .set({ state: "provisioning", updatedAt: new Date() }) .where(and(eq(bootstrapJobs.id, job.id), eq(bootstrapJobs.state, "awaiting_payment"))) .returning(); return updated.length > 0; }); if (!advanced) return getBootstrapJobById(job.id); logger.info("bootstrap invoice paid", { bootstrapJobId: job.id, paymentHash: job.paymentHash }); // Fire provisioning in background — poll returns immediately with "provisioning" setImmediate(() => { void runProvisioningInBackground(job.id); }); return getBootstrapJobById(job.id); } return job; } // ── POST /api/bootstrap ────────────────────────────────────────────────────── router.post("/bootstrap", async (req: Request, res: Response) => { // No request body for now, just trigger bootstrap try { const bootstrapFeeSats = pricingService.calculateBootstrapFeeSats(); const jobId = randomUUID(); const createdAt = new Date(); const lnbitsInvoice = await lnbitsService.createInvoice(bootstrapFeeSats, `Node bootstrap fee for job ${jobId}`); await db.insert(bootstrapJobs).values({ id: jobId, state: "awaiting_payment", amountSats: bootstrapFeeSats, paymentHash: lnbitsInvoice.paymentHash, paymentRequest: lnbitsInvoice.paymentRequest, createdAt, updatedAt: createdAt, }); logger.info("bootstrap job created", { jobId, amountSats: bootstrapFeeSats, stubMode: lnbitsService.stubMode, }); res.status(201).json({ jobId, createdAt: createdAt.toISOString(), bootstrapInvoice: { paymentRequest: lnbitsInvoice.paymentRequest, amountSats: bootstrapFeeSats, paymentHash: lnbitsInvoice.paymentHash, }, }); } catch (err) { const message = err instanceof Error ? err.message : "Failed to create bootstrap job"; logger.error("bootstrap job creation failed", { error: message }); res.status(500).json({ error: message }); } }); // ── GET /api/bootstrap/:id ─────────────────────────────────────────────────── router.get("/bootstrap/:id", async (req: Request, res: Response) => { const { id } = req.params; // Assuming ID is always valid, add Zod validation later try { let job = await getBootstrapJobById(id); if (!job) { res.status(404).json({ error: "Bootstrap job not found" }); return; } const advanced = await advanceBootstrapJob(job); if (advanced) job = advanced; // Remove SSH private key from response if it has been delivered const sshPrivateKey = job.sshPrivateKey && !job.sshKeyDelivered ? job.sshPrivateKey : undefined; res.json({ jobId: job.id, state: job.state, createdAt: job.createdAt.toISOString(), updatedAt: job.updatedAt.toISOString(), amountSats: job.amountSats, ...(job.state === "awaiting_payment" ? { bootstrapInvoice: { paymentRequest: job.paymentRequest, amountSats: job.amountSats, paymentHash: job.paymentHash, }, } : {}), ...(job.state === "ready" ? { dropletId: job.dropletId, nodeIp: job.nodeIp, tailscaleHostname: job.tailscaleHostname, lnbitsUrl: job.lnbitsUrl, sshPrivateKey: sshPrivateKey, // Only return if not yet delivered sshKeyDelivered: job.sshKeyDelivered, } : {}), ...(job.state === "failed" ? { errorMessage: job.errorMessage } : {}), }); // Mark SSH key as delivered after it's returned to the user once if (job.sshPrivateKey && !job.sshKeyDelivered && job.state === "ready") { await db.update(bootstrapJobs).set({ sshKeyDelivered: true, updatedAt: new Date() }).where(eq(bootstrapJobs.id, id)); logger.info("SSH private key marked as delivered", { jobId: job.id }); } } catch (err) { const message = err instanceof Error ? err.message : "Failed to fetch bootstrap job"; logger.error("bootstrap job fetch failed", { error: message }); res.status(500).json({ error: message }); } }); export default router;