196 lines
6.1 KiB
TypeScript
196 lines
6.1 KiB
TypeScript
|
|
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<BootstrapJob | null> {
|
||
|
|
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<BootstrapJob | null> {
|
||
|
|
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@<nodeIp>",
|
||
|
|
"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;
|