This commit was merged in pull request #98.
This commit is contained in:
@@ -1,214 +1,190 @@
|
||||
import { Router, type Request, type Response } from "express";
|
||||
import { randomUUID } from "crypto";
|
||||
import { db, bootstrapJobs, type BootstrapJob } from "@workspace/db";
|
||||
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");
|
||||
const logger = makeLogger("bootstrap-routes");
|
||||
|
||||
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);
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Runs the node provisioning in a background task so HTTP polls return fast.
|
||||
*/
|
||||
async function advanceBootstrapJob(job: BootstrapJob): Promise<BootstrapJob | null> {
|
||||
if (job.state !== "awaiting_payment") return job;
|
||||
async function runProvisioningInBackground(jobId: string): Promise<void> {
|
||||
try {
|
||||
logger.info("starting node provisioning", { jobId });
|
||||
await db.update(bootstrapJobs).set({ state: "provisioning", updatedAt: new Date() }).where(eq(bootstrapJobs.id, jobId));
|
||||
|
||||
const isPaid = await lnbitsService.checkInvoicePaid(job.paymentHash);
|
||||
if (!isPaid) return job;
|
||||
const provisionResult = await provisionerService.provisionNode(jobId);
|
||||
|
||||
// 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();
|
||||
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));
|
||||
|
||||
if (updated.length === 0) {
|
||||
// Another concurrent request already advanced the state — just re-fetch.
|
||||
return getBootstrapJobById(job.id);
|
||||
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));
|
||||
}
|
||||
|
||||
logger.info("bootstrap payment confirmed — starting provisioning", { bootstrapJobId: job.id });
|
||||
|
||||
// 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.
|
||||
* Checks whether the bootstrap invoice has been paid and, if so,
|
||||
* advances the state machine.
|
||||
*/
|
||||
router.post("/bootstrap", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const fee = pricingService.calculateBootstrapFeeSats();
|
||||
const jobId = randomUUID();
|
||||
async function advanceBootstrapJob(job: BootstrapJob): Promise<BootstrapJob | null> {
|
||||
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 invoice = await lnbitsService.createInvoice(
|
||||
fee,
|
||||
`Node bootstrap fee — job ${jobId}`,
|
||||
);
|
||||
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: fee,
|
||||
paymentHash: invoice.paymentHash,
|
||||
paymentRequest: invoice.paymentRequest,
|
||||
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({
|
||||
bootstrapJobId: jobId,
|
||||
invoice: {
|
||||
paymentRequest: invoice.paymentRequest,
|
||||
amountSats: fee,
|
||||
paymentHash: invoice.paymentHash,
|
||||
jobId,
|
||||
createdAt: createdAt.toISOString(),
|
||||
bootstrapInvoice: {
|
||||
paymentRequest: lnbitsInvoice.paymentRequest,
|
||||
amountSats: bootstrapFeeSats,
|
||||
paymentHash: lnbitsInvoice.paymentHash,
|
||||
},
|
||||
stubMode: lnbitsService.stubMode || provisionerService.stubMode,
|
||||
message: `Simulate payment with POST /api/dev/stub/pay/${invoice.paymentHash} then poll GET /api/bootstrap/:id`,
|
||||
});
|
||||
} 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
|
||||
*
|
||||
* Polls status. Triggers provisioning once payment is confirmed.
|
||||
* Returns credentials (SSH key delivered once, then cleared) when ready.
|
||||
*/
|
||||
// ── GET /api/bootstrap/:id ───────────────────────────────────────────────────
|
||||
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
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,
|
||||
// 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,
|
||||
createdAt: job.createdAt,
|
||||
};
|
||||
...(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 } : {}),
|
||||
});
|
||||
|
||||
switch (job.state) {
|
||||
case "awaiting_payment":
|
||||
res.json({
|
||||
...base,
|
||||
invoice: {
|
||||
paymentRequest: job.paymentRequest,
|
||||
amountSats: job.amountSats,
|
||||
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 <key_file> root@${job.nodeIp ?? "<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);
|
||||
// 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;
|
||||
export default router;
|
||||
Reference in New Issue
Block a user