import { Router, type Request, type Response } from "express"; import { randomUUID } from "crypto"; import { db, bootstrapJobs, 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"; 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; // Guard: only advance if still awaiting_payment — prevents duplicate provisioning // on concurrent polls (each poll independently confirms payment). const updated = await db .update(bootstrapJobs) .set({ state: "provisioning", updatedAt: new Date() }) .where(and(eq(bootstrapJobs.id, job.id), eq(bootstrapJobs.state, "awaiting_payment"))) .returning(); if (updated.length === 0) { // Another concurrent request already advanced the state — just re-fetch. 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": { // Atomic one-time SSH key delivery: only the request that wins the // guarded UPDATE (WHERE ssh_key_delivered = false) delivers the key. // Concurrent first-reads both see delivered=false in the pre-fetched // job, but only one UPDATE matches — the other gets 0 rows and falls // back to the "already delivered" note. let sshPrivateKey: string | null = null; let keyNote: string | null = null; if (!job.sshKeyDelivered && job.sshPrivateKey) { const won = await db .update(bootstrapJobs) .set({ sshKeyDelivered: true, sshPrivateKey: null, updatedAt: new Date() }) .where(and(eq(bootstrapJobs.id, job.id), eq(bootstrapJobs.sshKeyDelivered, false))) .returning({ id: bootstrapJobs.id }); if (won.length > 0) { // This request won the delivery race — return the key we pre-read. sshPrivateKey = job.sshPrivateKey; } else { keyNote = "SSH private key was delivered on a concurrent request — check your records"; } } else { keyNote = "SSH private key was delivered on first retrieval — check your records"; } res.json({ ...base, credentials: { nodeIp: job.nodeIp, tailscaleHostname: job.tailscaleHostname, lnbitsUrl: job.lnbitsUrl, sshPrivateKey, ...(keyNote ? { sshKeyNote: keyNote } : {}), }, nextSteps: [ `SSH into your node using the private key above: ssh -i root@${job.nodeIp ?? ""}`, "Read your node credentials: cat /root/node-credentials.txt", "Monitor Bitcoin sync (takes 1-2 weeks to reach 100%): bash /opt/timmy-node/ops.sh sync", "Once sync is complete, fund your LND wallet, then open LNbits to create your wallet and get the API key", "Set LNBITS_URL and LNBITS_API_KEY in your Timmy deployment to enable payment processing", ], 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;