import { Router, type Request, type Response } from "express"; import { randomUUID } from "crypto"; import { db, bootstrapJobs, type BootstrapJob } from "@workspace/db"; import { eq } from "drizzle-orm"; import { lnbitsService } from "../lib/lnbits.js"; import { pricingService } from "../lib/pricing.js"; import { provisionerService } from "../lib/provisioner.js"; 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; } /** * Advances the bootstrap job state machine on each poll. * * awaiting_payment → (payment confirmed) → provisioning * (provisioner runs async and writes ready/failed to DB) * * Returns the refreshed job, or null if a DB read is needed. */ async function advanceBootstrapJob(job: BootstrapJob): Promise { if (job.state !== "awaiting_payment") return job; const isPaid = await lnbitsService.checkInvoicePaid(job.paymentHash); if (!isPaid) return job; const updated = await db .update(bootstrapJobs) .set({ state: "provisioning", updatedAt: new Date() }) .where(eq(bootstrapJobs.id, job.id)) .returning(); if (updated.length === 0) return getBootstrapJobById(job.id); console.log(`[bootstrap] Payment confirmed for ${job.id} — starting provisioning`); // Fire-and-forget: provisioner updates DB when done void provisionerService.provision(job.id); return { ...job, state: "provisioning" }; } /** * POST /api/bootstrap * * Creates a bootstrap job and returns the Lightning invoice. */ router.post("/bootstrap", async (req: Request, res: Response) => { try { const fee = pricingService.calculateBootstrapFeeSats(); const jobId = randomUUID(); const invoice = await lnbitsService.createInvoice( fee, `Node bootstrap fee — job ${jobId}`, ); await db.insert(bootstrapJobs).values({ id: jobId, state: "awaiting_payment", amountSats: fee, paymentHash: invoice.paymentHash, paymentRequest: invoice.paymentRequest, }); res.status(201).json({ bootstrapJobId: jobId, invoice: { paymentRequest: invoice.paymentRequest, amountSats: fee, ...(lnbitsService.stubMode ? { paymentHash: invoice.paymentHash } : {}), }, stubMode: lnbitsService.stubMode || provisionerService.stubMode, message: lnbitsService.stubMode ? `Stub mode: simulate payment with POST /api/dev/stub/pay/${invoice.paymentHash} then poll GET /api/bootstrap/:id` : "Pay the invoice, then poll GET /api/bootstrap/:id for status", }); } catch (err) { const message = err instanceof Error ? err.message : "Failed to create bootstrap job"; res.status(500).json({ error: message }); } }); /** * GET /api/bootstrap/:id * * Polls status. Triggers provisioning once payment is confirmed. * Returns credentials (SSH key delivered once, then cleared) when ready. */ router.get("/bootstrap/:id", async (req: Request, res: Response) => { const { id } = req.params; if (!id || typeof id !== "string") { res.status(400).json({ error: "Invalid bootstrap job id" }); return; } 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; const base = { bootstrapJobId: job.id, state: job.state, amountSats: job.amountSats, createdAt: job.createdAt, }; switch (job.state) { case "awaiting_payment": res.json({ ...base, invoice: { paymentRequest: job.paymentRequest, amountSats: job.amountSats, ...(lnbitsService.stubMode ? { paymentHash: job.paymentHash } : {}), }, message: "Waiting for Lightning payment", }); break; case "provisioning": res.json({ ...base, message: "Payment confirmed — provisioning your Bitcoin node. Poll again in ~30 s.", }); break; case "ready": { const keyNote = job.sshKeyDelivered ? "SSH private key was delivered on first retrieval — check your records" : null; // Deliver SSH key on first retrieval, then clear it from DB const sshPrivateKey = job.sshKeyDelivered ? null : job.sshPrivateKey; if (!job.sshKeyDelivered && job.sshPrivateKey) { await db .update(bootstrapJobs) .set({ sshKeyDelivered: true, sshPrivateKey: null, updatedAt: new Date() }) .where(eq(bootstrapJobs.id, job.id)); } res.json({ ...base, credentials: { nodeIp: job.nodeIp, tailscaleHostname: job.tailscaleHostname, lnbitsUrl: job.lnbitsUrl, sshPrivateKey, ...(keyNote ? { sshKeyNote: keyNote } : {}), }, nextSteps: [ "SSH into your node: ssh root@", "Bitcoin is syncing — this takes 1-2 weeks: bash /opt/timmy-node/ops.sh sync", "Once sync reaches ~1.0, initialize LND + LNbits: bash /opt/timmy-node/lnd-init.sh", "Auto-configure cold storage sweep: bash /opt/timmy-node/ops.sh configure-sweep", ], stubMode: provisionerService.stubMode, message: provisionerService.stubMode ? "Stub mode — these are fake credentials. Set DO_API_TOKEN for real provisioning." : "Your node is being bootstrapped. Bitcoin sync has started.", }); break; } case "failed": res.json({ ...base, errorMessage: job.errorMessage, message: "Provisioning failed. Contact the operator for a refund.", }); break; default: res.json(base); } } catch (err) { const message = err instanceof Error ? err.message : "Failed to fetch bootstrap job"; res.status(500).json({ error: message }); } }); export default router;