Files
timmy-tower/artifacts/api-server/src/routes/bootstrap.ts

190 lines
7.2 KiB
TypeScript
Raw Normal View History

Task #5: Lightning-gated node bootstrap (proof-of-concept) Pay a Lightning invoice → Timmy auto-provisions a Bitcoin full node on DO. New: lib/db/src/schema/bootstrap-jobs.ts - bootstrap_jobs table: id, state, amountSats, paymentHash, paymentRequest, dropletId, nodeIp, tailscaleHostname, lnbitsUrl, sshPrivateKey, sshKeyDelivered (bool), errorMessage, createdAt, updatedAt - States: awaiting_payment | provisioning | ready | failed - Payment data stored inline (no FK to jobs/invoices tables — separate entity) - db:push applied to create table in Postgres New: artifacts/api-server/src/lib/provisioner.ts - ProvisionerService: stubs when DO_API_TOKEN absent, real otherwise - Stub mode: generates a real RSA 4096-bit SSH keypair via ssh-keygen, returns RFC 5737 test IP + fake Tailscale hostname after 2s delay - Real mode: upload SSH public key to DO → generate Tailscale auth key → create DO droplet with cloud-init user_data → poll for public IP (2 min) - buildCloudInitScript(): non-interactive bash that installs Docker + Tailscale + UFW + Bitcoin Knots via docker-compose; joins Tailscale if authkey provided - provision() designed as fire-and-forget (void); updates DB to ready/failed New: artifacts/api-server/src/routes/bootstrap.ts - POST /api/bootstrap: create job + LNbits invoice, return paymentRequest - GET /api/bootstrap/:id: poll-driven state machine * awaiting_payment: checks payment, fires provisioner on confirm * provisioning: returns progress message * ready: delivers credentials; SSH private key delivered once then cleared * failed: returns error message - Stub mode message includes the exact /dev/stub/pay URL for easy testing - nextSteps array guides user through post-provision setup Updated: artifacts/api-server/src/lib/pricing.ts - Added bootstrapFee field reading BOOTSTRAP_FEE_SATS env var (default 10000) - calculateBootstrapFeeSats() method Updated: artifacts/api-server/src/routes/index.ts - Mounts bootstrapRouter Updated: replit.md - Documents all 7 new env vars (DO_API_TOKEN, DO_REGION, DO_SIZE, etc.) - Full curl-based flow example with annotated response shape End-to-end verified in stub mode: POST → pay → provisioning → ready (SSH key) → second GET clears key and shows sshKeyNote
2026-03-18 18:47:48 +00:00
import { Router, type Request, type Response } from "express";
import { randomUUID } from "crypto";
import { db, bootstrapJobs, invoices, type BootstrapJob } from "@workspace/db";
Fix Task #5 review findings: race guard, full stack cloud-init, volume, node:crypto SSH 4 changes to address code review rejections: 1. Race condition fix (bootstrap.ts) - advanceBootstrapJob: WHERE now guards on AND state='awaiting_payment' - If UPDATE matches 0 rows, re-fetch current job (already advanced by another concurrent poll) instead of firing a second provisioner - Verified with 5-concurrent-poll test: only 1 "starting provisioning" log entry per job; all 5 responses show consistent state 2. Complete cloud-init to full Bitcoin + LND + LNbits stack (provisioner.ts) - Phase 1: packages, Docker, Tailscale, UFW, block volume mount - Phase 2: Bitcoin Core started; polls for RPC availability (max 5 min) - Phase 3: LND started; waits for REST API (max 6 min) - Phase 4: non-interactive LND wallet init via REST: POST /v1/genseed → POST /v1/initwallet with base64 password (no lncli, no interactive prompts, no expect) - Phase 5: waits for admin.macaroon to appear on mounted volume - Phase 6: LNbits started with LndRestWallet backend; mounts LND data dir so it reads tls.cert + admin.macaroon automatically - Phase 7: saves all credentials (RPC pass, LND wallet pass + seed mnemonic, LNbits URL) to chmod 600 /root/node-credentials.txt 3. DO block volume support (provisioner.ts) - Reads DO_VOLUME_SIZE_GB env var (0 = no volume, default) - createVolume(): POST /v2/volumes (ext4 filesystem, tagged timmy-node) - Passes volumeId in droplet create payload (attached at boot) - Cloud-init Phase 1 detects and mounts the volume automatically (lsblk scan → mkfs if unformatted → mount → /etc/fstab entry) 4. SSH keypair via node:crypto (no ssh-keygen) (provisioner.ts) - generateKeyPairSync('rsa', { modulusLength: 4096 }) - Public key: PKCS#1 DER → OpenSSH wire format via manual DER parser (pkcs1DerToSshPublicKey): reads SEQUENCE → n, e INTEGERs → ssh-rsa base64 string with proper mpint encoding (leading 0x00 for high bit) - Private key: PKCS#1 PEM (-----BEGIN RSA PRIVATE KEY-----) - Both stub and real paths use the same generateSshKeypair() function - Removes runtime dependency on host ssh-keygen binary entirely
2026-03-18 18:55:40 +00:00
import { eq, and } from "drizzle-orm";
Task #5: Lightning-gated node bootstrap (proof-of-concept) Pay a Lightning invoice → Timmy auto-provisions a Bitcoin full node on DO. New: lib/db/src/schema/bootstrap-jobs.ts - bootstrap_jobs table: id, state, amountSats, paymentHash, paymentRequest, dropletId, nodeIp, tailscaleHostname, lnbitsUrl, sshPrivateKey, sshKeyDelivered (bool), errorMessage, createdAt, updatedAt - States: awaiting_payment | provisioning | ready | failed - Payment data stored inline (no FK to jobs/invoices tables — separate entity) - db:push applied to create table in Postgres New: artifacts/api-server/src/lib/provisioner.ts - ProvisionerService: stubs when DO_API_TOKEN absent, real otherwise - Stub mode: generates a real RSA 4096-bit SSH keypair via ssh-keygen, returns RFC 5737 test IP + fake Tailscale hostname after 2s delay - Real mode: upload SSH public key to DO → generate Tailscale auth key → create DO droplet with cloud-init user_data → poll for public IP (2 min) - buildCloudInitScript(): non-interactive bash that installs Docker + Tailscale + UFW + Bitcoin Knots via docker-compose; joins Tailscale if authkey provided - provision() designed as fire-and-forget (void); updates DB to ready/failed New: artifacts/api-server/src/routes/bootstrap.ts - POST /api/bootstrap: create job + LNbits invoice, return paymentRequest - GET /api/bootstrap/:id: poll-driven state machine * awaiting_payment: checks payment, fires provisioner on confirm * provisioning: returns progress message * ready: delivers credentials; SSH private key delivered once then cleared * failed: returns error message - Stub mode message includes the exact /dev/stub/pay URL for easy testing - nextSteps array guides user through post-provision setup Updated: artifacts/api-server/src/lib/pricing.ts - Added bootstrapFee field reading BOOTSTRAP_FEE_SATS env var (default 10000) - calculateBootstrapFeeSats() method Updated: artifacts/api-server/src/routes/index.ts - Mounts bootstrapRouter Updated: replit.md - Documents all 7 new env vars (DO_API_TOKEN, DO_REGION, DO_SIZE, etc.) - Full curl-based flow example with annotated response shape End-to-end verified in stub mode: POST → pay → provisioning → ready (SSH key) → second GET clears key and shows sshKeyNote
2026-03-18 18:47:48 +00:00
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");
Task #5: Lightning-gated node bootstrap (proof-of-concept) Pay a Lightning invoice → Timmy auto-provisions a Bitcoin full node on DO. New: lib/db/src/schema/bootstrap-jobs.ts - bootstrap_jobs table: id, state, amountSats, paymentHash, paymentRequest, dropletId, nodeIp, tailscaleHostname, lnbitsUrl, sshPrivateKey, sshKeyDelivered (bool), errorMessage, createdAt, updatedAt - States: awaiting_payment | provisioning | ready | failed - Payment data stored inline (no FK to jobs/invoices tables — separate entity) - db:push applied to create table in Postgres New: artifacts/api-server/src/lib/provisioner.ts - ProvisionerService: stubs when DO_API_TOKEN absent, real otherwise - Stub mode: generates a real RSA 4096-bit SSH keypair via ssh-keygen, returns RFC 5737 test IP + fake Tailscale hostname after 2s delay - Real mode: upload SSH public key to DO → generate Tailscale auth key → create DO droplet with cloud-init user_data → poll for public IP (2 min) - buildCloudInitScript(): non-interactive bash that installs Docker + Tailscale + UFW + Bitcoin Knots via docker-compose; joins Tailscale if authkey provided - provision() designed as fire-and-forget (void); updates DB to ready/failed New: artifacts/api-server/src/routes/bootstrap.ts - POST /api/bootstrap: create job + LNbits invoice, return paymentRequest - GET /api/bootstrap/:id: poll-driven state machine * awaiting_payment: checks payment, fires provisioner on confirm * provisioning: returns progress message * ready: delivers credentials; SSH private key delivered once then cleared * failed: returns error message - Stub mode message includes the exact /dev/stub/pay URL for easy testing - nextSteps array guides user through post-provision setup Updated: artifacts/api-server/src/lib/pricing.ts - Added bootstrapFee field reading BOOTSTRAP_FEE_SATS env var (default 10000) - calculateBootstrapFeeSats() method Updated: artifacts/api-server/src/routes/index.ts - Mounts bootstrapRouter Updated: replit.md - Documents all 7 new env vars (DO_API_TOKEN, DO_REGION, DO_SIZE, etc.) - Full curl-based flow example with annotated response shape End-to-end verified in stub mode: POST → pay → provisioning → ready (SSH key) → second GET clears key and shows sshKeyNote
2026-03-18 18:47:48 +00:00
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);
Task #5: Lightning-gated node bootstrap (proof-of-concept) Pay a Lightning invoice → Timmy auto-provisions a Bitcoin full node on DO. New: lib/db/src/schema/bootstrap-jobs.ts - bootstrap_jobs table: id, state, amountSats, paymentHash, paymentRequest, dropletId, nodeIp, tailscaleHostname, lnbitsUrl, sshPrivateKey, sshKeyDelivered (bool), errorMessage, createdAt, updatedAt - States: awaiting_payment | provisioning | ready | failed - Payment data stored inline (no FK to jobs/invoices tables — separate entity) - db:push applied to create table in Postgres New: artifacts/api-server/src/lib/provisioner.ts - ProvisionerService: stubs when DO_API_TOKEN absent, real otherwise - Stub mode: generates a real RSA 4096-bit SSH keypair via ssh-keygen, returns RFC 5737 test IP + fake Tailscale hostname after 2s delay - Real mode: upload SSH public key to DO → generate Tailscale auth key → create DO droplet with cloud-init user_data → poll for public IP (2 min) - buildCloudInitScript(): non-interactive bash that installs Docker + Tailscale + UFW + Bitcoin Knots via docker-compose; joins Tailscale if authkey provided - provision() designed as fire-and-forget (void); updates DB to ready/failed New: artifacts/api-server/src/routes/bootstrap.ts - POST /api/bootstrap: create job + LNbits invoice, return paymentRequest - GET /api/bootstrap/:id: poll-driven state machine * awaiting_payment: checks payment, fires provisioner on confirm * provisioning: returns progress message * ready: delivers credentials; SSH private key delivered once then cleared * failed: returns error message - Stub mode message includes the exact /dev/stub/pay URL for easy testing - nextSteps array guides user through post-provision setup Updated: artifacts/api-server/src/lib/pricing.ts - Added bootstrapFee field reading BOOTSTRAP_FEE_SATS env var (default 10000) - calculateBootstrapFeeSats() method Updated: artifacts/api-server/src/routes/index.ts - Mounts bootstrapRouter Updated: replit.md - Documents all 7 new env vars (DO_API_TOKEN, DO_REGION, DO_SIZE, etc.) - Full curl-based flow example with annotated response shape End-to-end verified in stub mode: POST → pay → provisioning → ready (SSH key) → second GET clears key and shows sshKeyNote
2026-03-18 18:47:48 +00:00
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<void> {
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));
}
}
Task #5: Lightning-gated node bootstrap (proof-of-concept) Pay a Lightning invoice → Timmy auto-provisions a Bitcoin full node on DO. New: lib/db/src/schema/bootstrap-jobs.ts - bootstrap_jobs table: id, state, amountSats, paymentHash, paymentRequest, dropletId, nodeIp, tailscaleHostname, lnbitsUrl, sshPrivateKey, sshKeyDelivered (bool), errorMessage, createdAt, updatedAt - States: awaiting_payment | provisioning | ready | failed - Payment data stored inline (no FK to jobs/invoices tables — separate entity) - db:push applied to create table in Postgres New: artifacts/api-server/src/lib/provisioner.ts - ProvisionerService: stubs when DO_API_TOKEN absent, real otherwise - Stub mode: generates a real RSA 4096-bit SSH keypair via ssh-keygen, returns RFC 5737 test IP + fake Tailscale hostname after 2s delay - Real mode: upload SSH public key to DO → generate Tailscale auth key → create DO droplet with cloud-init user_data → poll for public IP (2 min) - buildCloudInitScript(): non-interactive bash that installs Docker + Tailscale + UFW + Bitcoin Knots via docker-compose; joins Tailscale if authkey provided - provision() designed as fire-and-forget (void); updates DB to ready/failed New: artifacts/api-server/src/routes/bootstrap.ts - POST /api/bootstrap: create job + LNbits invoice, return paymentRequest - GET /api/bootstrap/:id: poll-driven state machine * awaiting_payment: checks payment, fires provisioner on confirm * provisioning: returns progress message * ready: delivers credentials; SSH private key delivered once then cleared * failed: returns error message - Stub mode message includes the exact /dev/stub/pay URL for easy testing - nextSteps array guides user through post-provision setup Updated: artifacts/api-server/src/lib/pricing.ts - Added bootstrapFee field reading BOOTSTRAP_FEE_SATS env var (default 10000) - calculateBootstrapFeeSats() method Updated: artifacts/api-server/src/routes/index.ts - Mounts bootstrapRouter Updated: replit.md - Documents all 7 new env vars (DO_API_TOKEN, DO_REGION, DO_SIZE, etc.) - Full curl-based flow example with annotated response shape End-to-end verified in stub mode: POST → pay → provisioning → ready (SSH key) → second GET clears key and shows sshKeyNote
2026-03-18 18:47:48 +00:00
/**
* Checks whether the bootstrap invoice has been paid and, if so,
* advances the state machine.
Task #5: Lightning-gated node bootstrap (proof-of-concept) Pay a Lightning invoice → Timmy auto-provisions a Bitcoin full node on DO. New: lib/db/src/schema/bootstrap-jobs.ts - bootstrap_jobs table: id, state, amountSats, paymentHash, paymentRequest, dropletId, nodeIp, tailscaleHostname, lnbitsUrl, sshPrivateKey, sshKeyDelivered (bool), errorMessage, createdAt, updatedAt - States: awaiting_payment | provisioning | ready | failed - Payment data stored inline (no FK to jobs/invoices tables — separate entity) - db:push applied to create table in Postgres New: artifacts/api-server/src/lib/provisioner.ts - ProvisionerService: stubs when DO_API_TOKEN absent, real otherwise - Stub mode: generates a real RSA 4096-bit SSH keypair via ssh-keygen, returns RFC 5737 test IP + fake Tailscale hostname after 2s delay - Real mode: upload SSH public key to DO → generate Tailscale auth key → create DO droplet with cloud-init user_data → poll for public IP (2 min) - buildCloudInitScript(): non-interactive bash that installs Docker + Tailscale + UFW + Bitcoin Knots via docker-compose; joins Tailscale if authkey provided - provision() designed as fire-and-forget (void); updates DB to ready/failed New: artifacts/api-server/src/routes/bootstrap.ts - POST /api/bootstrap: create job + LNbits invoice, return paymentRequest - GET /api/bootstrap/:id: poll-driven state machine * awaiting_payment: checks payment, fires provisioner on confirm * provisioning: returns progress message * ready: delivers credentials; SSH private key delivered once then cleared * failed: returns error message - Stub mode message includes the exact /dev/stub/pay URL for easy testing - nextSteps array guides user through post-provision setup Updated: artifacts/api-server/src/lib/pricing.ts - Added bootstrapFee field reading BOOTSTRAP_FEE_SATS env var (default 10000) - calculateBootstrapFeeSats() method Updated: artifacts/api-server/src/routes/index.ts - Mounts bootstrapRouter Updated: replit.md - Documents all 7 new env vars (DO_API_TOKEN, DO_REGION, DO_SIZE, etc.) - Full curl-based flow example with annotated response shape End-to-end verified in stub mode: POST → pay → provisioning → ready (SSH key) → second GET clears key and shows sshKeyNote
2026-03-18 18:47:48 +00:00
*/
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 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;
});
Task #5: Lightning-gated node bootstrap (proof-of-concept) Pay a Lightning invoice → Timmy auto-provisions a Bitcoin full node on DO. New: lib/db/src/schema/bootstrap-jobs.ts - bootstrap_jobs table: id, state, amountSats, paymentHash, paymentRequest, dropletId, nodeIp, tailscaleHostname, lnbitsUrl, sshPrivateKey, sshKeyDelivered (bool), errorMessage, createdAt, updatedAt - States: awaiting_payment | provisioning | ready | failed - Payment data stored inline (no FK to jobs/invoices tables — separate entity) - db:push applied to create table in Postgres New: artifacts/api-server/src/lib/provisioner.ts - ProvisionerService: stubs when DO_API_TOKEN absent, real otherwise - Stub mode: generates a real RSA 4096-bit SSH keypair via ssh-keygen, returns RFC 5737 test IP + fake Tailscale hostname after 2s delay - Real mode: upload SSH public key to DO → generate Tailscale auth key → create DO droplet with cloud-init user_data → poll for public IP (2 min) - buildCloudInitScript(): non-interactive bash that installs Docker + Tailscale + UFW + Bitcoin Knots via docker-compose; joins Tailscale if authkey provided - provision() designed as fire-and-forget (void); updates DB to ready/failed New: artifacts/api-server/src/routes/bootstrap.ts - POST /api/bootstrap: create job + LNbits invoice, return paymentRequest - GET /api/bootstrap/:id: poll-driven state machine * awaiting_payment: checks payment, fires provisioner on confirm * provisioning: returns progress message * ready: delivers credentials; SSH private key delivered once then cleared * failed: returns error message - Stub mode message includes the exact /dev/stub/pay URL for easy testing - nextSteps array guides user through post-provision setup Updated: artifacts/api-server/src/lib/pricing.ts - Added bootstrapFee field reading BOOTSTRAP_FEE_SATS env var (default 10000) - calculateBootstrapFeeSats() method Updated: artifacts/api-server/src/routes/index.ts - Mounts bootstrapRouter Updated: replit.md - Documents all 7 new env vars (DO_API_TOKEN, DO_REGION, DO_SIZE, etc.) - Full curl-based flow example with annotated response shape End-to-end verified in stub mode: POST → pay → provisioning → ready (SSH key) → second GET clears key and shows sshKeyNote
2026-03-18 18:47:48 +00:00
if (!advanced) return getBootstrapJobById(job.id);
Task #5: Lightning-gated node bootstrap (proof-of-concept) Pay a Lightning invoice → Timmy auto-provisions a Bitcoin full node on DO. New: lib/db/src/schema/bootstrap-jobs.ts - bootstrap_jobs table: id, state, amountSats, paymentHash, paymentRequest, dropletId, nodeIp, tailscaleHostname, lnbitsUrl, sshPrivateKey, sshKeyDelivered (bool), errorMessage, createdAt, updatedAt - States: awaiting_payment | provisioning | ready | failed - Payment data stored inline (no FK to jobs/invoices tables — separate entity) - db:push applied to create table in Postgres New: artifacts/api-server/src/lib/provisioner.ts - ProvisionerService: stubs when DO_API_TOKEN absent, real otherwise - Stub mode: generates a real RSA 4096-bit SSH keypair via ssh-keygen, returns RFC 5737 test IP + fake Tailscale hostname after 2s delay - Real mode: upload SSH public key to DO → generate Tailscale auth key → create DO droplet with cloud-init user_data → poll for public IP (2 min) - buildCloudInitScript(): non-interactive bash that installs Docker + Tailscale + UFW + Bitcoin Knots via docker-compose; joins Tailscale if authkey provided - provision() designed as fire-and-forget (void); updates DB to ready/failed New: artifacts/api-server/src/routes/bootstrap.ts - POST /api/bootstrap: create job + LNbits invoice, return paymentRequest - GET /api/bootstrap/:id: poll-driven state machine * awaiting_payment: checks payment, fires provisioner on confirm * provisioning: returns progress message * ready: delivers credentials; SSH private key delivered once then cleared * failed: returns error message - Stub mode message includes the exact /dev/stub/pay URL for easy testing - nextSteps array guides user through post-provision setup Updated: artifacts/api-server/src/lib/pricing.ts - Added bootstrapFee field reading BOOTSTRAP_FEE_SATS env var (default 10000) - calculateBootstrapFeeSats() method Updated: artifacts/api-server/src/routes/index.ts - Mounts bootstrapRouter Updated: replit.md - Documents all 7 new env vars (DO_API_TOKEN, DO_REGION, DO_SIZE, etc.) - Full curl-based flow example with annotated response shape End-to-end verified in stub mode: POST → pay → provisioning → ready (SSH key) → second GET clears key and shows sshKeyNote
2026-03-18 18:47:48 +00:00
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); });
Task #5: Lightning-gated node bootstrap (proof-of-concept) Pay a Lightning invoice → Timmy auto-provisions a Bitcoin full node on DO. New: lib/db/src/schema/bootstrap-jobs.ts - bootstrap_jobs table: id, state, amountSats, paymentHash, paymentRequest, dropletId, nodeIp, tailscaleHostname, lnbitsUrl, sshPrivateKey, sshKeyDelivered (bool), errorMessage, createdAt, updatedAt - States: awaiting_payment | provisioning | ready | failed - Payment data stored inline (no FK to jobs/invoices tables — separate entity) - db:push applied to create table in Postgres New: artifacts/api-server/src/lib/provisioner.ts - ProvisionerService: stubs when DO_API_TOKEN absent, real otherwise - Stub mode: generates a real RSA 4096-bit SSH keypair via ssh-keygen, returns RFC 5737 test IP + fake Tailscale hostname after 2s delay - Real mode: upload SSH public key to DO → generate Tailscale auth key → create DO droplet with cloud-init user_data → poll for public IP (2 min) - buildCloudInitScript(): non-interactive bash that installs Docker + Tailscale + UFW + Bitcoin Knots via docker-compose; joins Tailscale if authkey provided - provision() designed as fire-and-forget (void); updates DB to ready/failed New: artifacts/api-server/src/routes/bootstrap.ts - POST /api/bootstrap: create job + LNbits invoice, return paymentRequest - GET /api/bootstrap/:id: poll-driven state machine * awaiting_payment: checks payment, fires provisioner on confirm * provisioning: returns progress message * ready: delivers credentials; SSH private key delivered once then cleared * failed: returns error message - Stub mode message includes the exact /dev/stub/pay URL for easy testing - nextSteps array guides user through post-provision setup Updated: artifacts/api-server/src/lib/pricing.ts - Added bootstrapFee field reading BOOTSTRAP_FEE_SATS env var (default 10000) - calculateBootstrapFeeSats() method Updated: artifacts/api-server/src/routes/index.ts - Mounts bootstrapRouter Updated: replit.md - Documents all 7 new env vars (DO_API_TOKEN, DO_REGION, DO_SIZE, etc.) - Full curl-based flow example with annotated response shape End-to-end verified in stub mode: POST → pay → provisioning → ready (SSH key) → second GET clears key and shows sshKeyNote
2026-03-18 18:47:48 +00:00
Fix Task #5 review findings: race guard, full stack cloud-init, volume, node:crypto SSH 4 changes to address code review rejections: 1. Race condition fix (bootstrap.ts) - advanceBootstrapJob: WHERE now guards on AND state='awaiting_payment' - If UPDATE matches 0 rows, re-fetch current job (already advanced by another concurrent poll) instead of firing a second provisioner - Verified with 5-concurrent-poll test: only 1 "starting provisioning" log entry per job; all 5 responses show consistent state 2. Complete cloud-init to full Bitcoin + LND + LNbits stack (provisioner.ts) - Phase 1: packages, Docker, Tailscale, UFW, block volume mount - Phase 2: Bitcoin Core started; polls for RPC availability (max 5 min) - Phase 3: LND started; waits for REST API (max 6 min) - Phase 4: non-interactive LND wallet init via REST: POST /v1/genseed → POST /v1/initwallet with base64 password (no lncli, no interactive prompts, no expect) - Phase 5: waits for admin.macaroon to appear on mounted volume - Phase 6: LNbits started with LndRestWallet backend; mounts LND data dir so it reads tls.cert + admin.macaroon automatically - Phase 7: saves all credentials (RPC pass, LND wallet pass + seed mnemonic, LNbits URL) to chmod 600 /root/node-credentials.txt 3. DO block volume support (provisioner.ts) - Reads DO_VOLUME_SIZE_GB env var (0 = no volume, default) - createVolume(): POST /v2/volumes (ext4 filesystem, tagged timmy-node) - Passes volumeId in droplet create payload (attached at boot) - Cloud-init Phase 1 detects and mounts the volume automatically (lsblk scan → mkfs if unformatted → mount → /etc/fstab entry) 4. SSH keypair via node:crypto (no ssh-keygen) (provisioner.ts) - generateKeyPairSync('rsa', { modulusLength: 4096 }) - Public key: PKCS#1 DER → OpenSSH wire format via manual DER parser (pkcs1DerToSshPublicKey): reads SEQUENCE → n, e INTEGERs → ssh-rsa base64 string with proper mpint encoding (leading 0x00 for high bit) - Private key: PKCS#1 PEM (-----BEGIN RSA PRIVATE KEY-----) - Both stub and real paths use the same generateSshKeypair() function - Removes runtime dependency on host ssh-keygen binary entirely
2026-03-18 18:55:40 +00:00
return getBootstrapJobById(job.id);
}
Task #5: Lightning-gated node bootstrap (proof-of-concept) Pay a Lightning invoice → Timmy auto-provisions a Bitcoin full node on DO. New: lib/db/src/schema/bootstrap-jobs.ts - bootstrap_jobs table: id, state, amountSats, paymentHash, paymentRequest, dropletId, nodeIp, tailscaleHostname, lnbitsUrl, sshPrivateKey, sshKeyDelivered (bool), errorMessage, createdAt, updatedAt - States: awaiting_payment | provisioning | ready | failed - Payment data stored inline (no FK to jobs/invoices tables — separate entity) - db:push applied to create table in Postgres New: artifacts/api-server/src/lib/provisioner.ts - ProvisionerService: stubs when DO_API_TOKEN absent, real otherwise - Stub mode: generates a real RSA 4096-bit SSH keypair via ssh-keygen, returns RFC 5737 test IP + fake Tailscale hostname after 2s delay - Real mode: upload SSH public key to DO → generate Tailscale auth key → create DO droplet with cloud-init user_data → poll for public IP (2 min) - buildCloudInitScript(): non-interactive bash that installs Docker + Tailscale + UFW + Bitcoin Knots via docker-compose; joins Tailscale if authkey provided - provision() designed as fire-and-forget (void); updates DB to ready/failed New: artifacts/api-server/src/routes/bootstrap.ts - POST /api/bootstrap: create job + LNbits invoice, return paymentRequest - GET /api/bootstrap/:id: poll-driven state machine * awaiting_payment: checks payment, fires provisioner on confirm * provisioning: returns progress message * ready: delivers credentials; SSH private key delivered once then cleared * failed: returns error message - Stub mode message includes the exact /dev/stub/pay URL for easy testing - nextSteps array guides user through post-provision setup Updated: artifacts/api-server/src/lib/pricing.ts - Added bootstrapFee field reading BOOTSTRAP_FEE_SATS env var (default 10000) - calculateBootstrapFeeSats() method Updated: artifacts/api-server/src/routes/index.ts - Mounts bootstrapRouter Updated: replit.md - Documents all 7 new env vars (DO_API_TOKEN, DO_REGION, DO_SIZE, etc.) - Full curl-based flow example with annotated response shape End-to-end verified in stub mode: POST → pay → provisioning → ready (SSH key) → second GET clears key and shows sshKeyNote
2026-03-18 18:47:48 +00:00
return job;
Task #5: Lightning-gated node bootstrap (proof-of-concept) Pay a Lightning invoice → Timmy auto-provisions a Bitcoin full node on DO. New: lib/db/src/schema/bootstrap-jobs.ts - bootstrap_jobs table: id, state, amountSats, paymentHash, paymentRequest, dropletId, nodeIp, tailscaleHostname, lnbitsUrl, sshPrivateKey, sshKeyDelivered (bool), errorMessage, createdAt, updatedAt - States: awaiting_payment | provisioning | ready | failed - Payment data stored inline (no FK to jobs/invoices tables — separate entity) - db:push applied to create table in Postgres New: artifacts/api-server/src/lib/provisioner.ts - ProvisionerService: stubs when DO_API_TOKEN absent, real otherwise - Stub mode: generates a real RSA 4096-bit SSH keypair via ssh-keygen, returns RFC 5737 test IP + fake Tailscale hostname after 2s delay - Real mode: upload SSH public key to DO → generate Tailscale auth key → create DO droplet with cloud-init user_data → poll for public IP (2 min) - buildCloudInitScript(): non-interactive bash that installs Docker + Tailscale + UFW + Bitcoin Knots via docker-compose; joins Tailscale if authkey provided - provision() designed as fire-and-forget (void); updates DB to ready/failed New: artifacts/api-server/src/routes/bootstrap.ts - POST /api/bootstrap: create job + LNbits invoice, return paymentRequest - GET /api/bootstrap/:id: poll-driven state machine * awaiting_payment: checks payment, fires provisioner on confirm * provisioning: returns progress message * ready: delivers credentials; SSH private key delivered once then cleared * failed: returns error message - Stub mode message includes the exact /dev/stub/pay URL for easy testing - nextSteps array guides user through post-provision setup Updated: artifacts/api-server/src/lib/pricing.ts - Added bootstrapFee field reading BOOTSTRAP_FEE_SATS env var (default 10000) - calculateBootstrapFeeSats() method Updated: artifacts/api-server/src/routes/index.ts - Mounts bootstrapRouter Updated: replit.md - Documents all 7 new env vars (DO_API_TOKEN, DO_REGION, DO_SIZE, etc.) - Full curl-based flow example with annotated response shape End-to-end verified in stub mode: POST → pay → provisioning → ready (SSH key) → second GET clears key and shows sshKeyNote
2026-03-18 18:47:48 +00:00
}
// ── POST /api/bootstrap ──────────────────────────────────────────────────────
Task #5: Lightning-gated node bootstrap (proof-of-concept) Pay a Lightning invoice → Timmy auto-provisions a Bitcoin full node on DO. New: lib/db/src/schema/bootstrap-jobs.ts - bootstrap_jobs table: id, state, amountSats, paymentHash, paymentRequest, dropletId, nodeIp, tailscaleHostname, lnbitsUrl, sshPrivateKey, sshKeyDelivered (bool), errorMessage, createdAt, updatedAt - States: awaiting_payment | provisioning | ready | failed - Payment data stored inline (no FK to jobs/invoices tables — separate entity) - db:push applied to create table in Postgres New: artifacts/api-server/src/lib/provisioner.ts - ProvisionerService: stubs when DO_API_TOKEN absent, real otherwise - Stub mode: generates a real RSA 4096-bit SSH keypair via ssh-keygen, returns RFC 5737 test IP + fake Tailscale hostname after 2s delay - Real mode: upload SSH public key to DO → generate Tailscale auth key → create DO droplet with cloud-init user_data → poll for public IP (2 min) - buildCloudInitScript(): non-interactive bash that installs Docker + Tailscale + UFW + Bitcoin Knots via docker-compose; joins Tailscale if authkey provided - provision() designed as fire-and-forget (void); updates DB to ready/failed New: artifacts/api-server/src/routes/bootstrap.ts - POST /api/bootstrap: create job + LNbits invoice, return paymentRequest - GET /api/bootstrap/:id: poll-driven state machine * awaiting_payment: checks payment, fires provisioner on confirm * provisioning: returns progress message * ready: delivers credentials; SSH private key delivered once then cleared * failed: returns error message - Stub mode message includes the exact /dev/stub/pay URL for easy testing - nextSteps array guides user through post-provision setup Updated: artifacts/api-server/src/lib/pricing.ts - Added bootstrapFee field reading BOOTSTRAP_FEE_SATS env var (default 10000) - calculateBootstrapFeeSats() method Updated: artifacts/api-server/src/routes/index.ts - Mounts bootstrapRouter Updated: replit.md - Documents all 7 new env vars (DO_API_TOKEN, DO_REGION, DO_SIZE, etc.) - Full curl-based flow example with annotated response shape End-to-end verified in stub mode: POST → pay → provisioning → ready (SSH key) → second GET clears key and shows sshKeyNote
2026-03-18 18:47:48 +00:00
router.post("/bootstrap", async (req: Request, res: Response) => {
// No request body for now, just trigger bootstrap
Task #5: Lightning-gated node bootstrap (proof-of-concept) Pay a Lightning invoice → Timmy auto-provisions a Bitcoin full node on DO. New: lib/db/src/schema/bootstrap-jobs.ts - bootstrap_jobs table: id, state, amountSats, paymentHash, paymentRequest, dropletId, nodeIp, tailscaleHostname, lnbitsUrl, sshPrivateKey, sshKeyDelivered (bool), errorMessage, createdAt, updatedAt - States: awaiting_payment | provisioning | ready | failed - Payment data stored inline (no FK to jobs/invoices tables — separate entity) - db:push applied to create table in Postgres New: artifacts/api-server/src/lib/provisioner.ts - ProvisionerService: stubs when DO_API_TOKEN absent, real otherwise - Stub mode: generates a real RSA 4096-bit SSH keypair via ssh-keygen, returns RFC 5737 test IP + fake Tailscale hostname after 2s delay - Real mode: upload SSH public key to DO → generate Tailscale auth key → create DO droplet with cloud-init user_data → poll for public IP (2 min) - buildCloudInitScript(): non-interactive bash that installs Docker + Tailscale + UFW + Bitcoin Knots via docker-compose; joins Tailscale if authkey provided - provision() designed as fire-and-forget (void); updates DB to ready/failed New: artifacts/api-server/src/routes/bootstrap.ts - POST /api/bootstrap: create job + LNbits invoice, return paymentRequest - GET /api/bootstrap/:id: poll-driven state machine * awaiting_payment: checks payment, fires provisioner on confirm * provisioning: returns progress message * ready: delivers credentials; SSH private key delivered once then cleared * failed: returns error message - Stub mode message includes the exact /dev/stub/pay URL for easy testing - nextSteps array guides user through post-provision setup Updated: artifacts/api-server/src/lib/pricing.ts - Added bootstrapFee field reading BOOTSTRAP_FEE_SATS env var (default 10000) - calculateBootstrapFeeSats() method Updated: artifacts/api-server/src/routes/index.ts - Mounts bootstrapRouter Updated: replit.md - Documents all 7 new env vars (DO_API_TOKEN, DO_REGION, DO_SIZE, etc.) - Full curl-based flow example with annotated response shape End-to-end verified in stub mode: POST → pay → provisioning → ready (SSH key) → second GET clears key and shows sshKeyNote
2026-03-18 18:47:48 +00:00
try {
const bootstrapFeeSats = pricingService.calculateBootstrapFeeSats();
Task #5: Lightning-gated node bootstrap (proof-of-concept) Pay a Lightning invoice → Timmy auto-provisions a Bitcoin full node on DO. New: lib/db/src/schema/bootstrap-jobs.ts - bootstrap_jobs table: id, state, amountSats, paymentHash, paymentRequest, dropletId, nodeIp, tailscaleHostname, lnbitsUrl, sshPrivateKey, sshKeyDelivered (bool), errorMessage, createdAt, updatedAt - States: awaiting_payment | provisioning | ready | failed - Payment data stored inline (no FK to jobs/invoices tables — separate entity) - db:push applied to create table in Postgres New: artifacts/api-server/src/lib/provisioner.ts - ProvisionerService: stubs when DO_API_TOKEN absent, real otherwise - Stub mode: generates a real RSA 4096-bit SSH keypair via ssh-keygen, returns RFC 5737 test IP + fake Tailscale hostname after 2s delay - Real mode: upload SSH public key to DO → generate Tailscale auth key → create DO droplet with cloud-init user_data → poll for public IP (2 min) - buildCloudInitScript(): non-interactive bash that installs Docker + Tailscale + UFW + Bitcoin Knots via docker-compose; joins Tailscale if authkey provided - provision() designed as fire-and-forget (void); updates DB to ready/failed New: artifacts/api-server/src/routes/bootstrap.ts - POST /api/bootstrap: create job + LNbits invoice, return paymentRequest - GET /api/bootstrap/:id: poll-driven state machine * awaiting_payment: checks payment, fires provisioner on confirm * provisioning: returns progress message * ready: delivers credentials; SSH private key delivered once then cleared * failed: returns error message - Stub mode message includes the exact /dev/stub/pay URL for easy testing - nextSteps array guides user through post-provision setup Updated: artifacts/api-server/src/lib/pricing.ts - Added bootstrapFee field reading BOOTSTRAP_FEE_SATS env var (default 10000) - calculateBootstrapFeeSats() method Updated: artifacts/api-server/src/routes/index.ts - Mounts bootstrapRouter Updated: replit.md - Documents all 7 new env vars (DO_API_TOKEN, DO_REGION, DO_SIZE, etc.) - Full curl-based flow example with annotated response shape End-to-end verified in stub mode: POST → pay → provisioning → ready (SSH key) → second GET clears key and shows sshKeyNote
2026-03-18 18:47:48 +00:00
const jobId = randomUUID();
const createdAt = new Date();
Task #5: Lightning-gated node bootstrap (proof-of-concept) Pay a Lightning invoice → Timmy auto-provisions a Bitcoin full node on DO. New: lib/db/src/schema/bootstrap-jobs.ts - bootstrap_jobs table: id, state, amountSats, paymentHash, paymentRequest, dropletId, nodeIp, tailscaleHostname, lnbitsUrl, sshPrivateKey, sshKeyDelivered (bool), errorMessage, createdAt, updatedAt - States: awaiting_payment | provisioning | ready | failed - Payment data stored inline (no FK to jobs/invoices tables — separate entity) - db:push applied to create table in Postgres New: artifacts/api-server/src/lib/provisioner.ts - ProvisionerService: stubs when DO_API_TOKEN absent, real otherwise - Stub mode: generates a real RSA 4096-bit SSH keypair via ssh-keygen, returns RFC 5737 test IP + fake Tailscale hostname after 2s delay - Real mode: upload SSH public key to DO → generate Tailscale auth key → create DO droplet with cloud-init user_data → poll for public IP (2 min) - buildCloudInitScript(): non-interactive bash that installs Docker + Tailscale + UFW + Bitcoin Knots via docker-compose; joins Tailscale if authkey provided - provision() designed as fire-and-forget (void); updates DB to ready/failed New: artifacts/api-server/src/routes/bootstrap.ts - POST /api/bootstrap: create job + LNbits invoice, return paymentRequest - GET /api/bootstrap/:id: poll-driven state machine * awaiting_payment: checks payment, fires provisioner on confirm * provisioning: returns progress message * ready: delivers credentials; SSH private key delivered once then cleared * failed: returns error message - Stub mode message includes the exact /dev/stub/pay URL for easy testing - nextSteps array guides user through post-provision setup Updated: artifacts/api-server/src/lib/pricing.ts - Added bootstrapFee field reading BOOTSTRAP_FEE_SATS env var (default 10000) - calculateBootstrapFeeSats() method Updated: artifacts/api-server/src/routes/index.ts - Mounts bootstrapRouter Updated: replit.md - Documents all 7 new env vars (DO_API_TOKEN, DO_REGION, DO_SIZE, etc.) - Full curl-based flow example with annotated response shape End-to-end verified in stub mode: POST → pay → provisioning → ready (SSH key) → second GET clears key and shows sshKeyNote
2026-03-18 18:47:48 +00:00
const lnbitsInvoice = await lnbitsService.createInvoice(bootstrapFeeSats, `Node bootstrap fee for job ${jobId}`);
Task #5: Lightning-gated node bootstrap (proof-of-concept) Pay a Lightning invoice → Timmy auto-provisions a Bitcoin full node on DO. New: lib/db/src/schema/bootstrap-jobs.ts - bootstrap_jobs table: id, state, amountSats, paymentHash, paymentRequest, dropletId, nodeIp, tailscaleHostname, lnbitsUrl, sshPrivateKey, sshKeyDelivered (bool), errorMessage, createdAt, updatedAt - States: awaiting_payment | provisioning | ready | failed - Payment data stored inline (no FK to jobs/invoices tables — separate entity) - db:push applied to create table in Postgres New: artifacts/api-server/src/lib/provisioner.ts - ProvisionerService: stubs when DO_API_TOKEN absent, real otherwise - Stub mode: generates a real RSA 4096-bit SSH keypair via ssh-keygen, returns RFC 5737 test IP + fake Tailscale hostname after 2s delay - Real mode: upload SSH public key to DO → generate Tailscale auth key → create DO droplet with cloud-init user_data → poll for public IP (2 min) - buildCloudInitScript(): non-interactive bash that installs Docker + Tailscale + UFW + Bitcoin Knots via docker-compose; joins Tailscale if authkey provided - provision() designed as fire-and-forget (void); updates DB to ready/failed New: artifacts/api-server/src/routes/bootstrap.ts - POST /api/bootstrap: create job + LNbits invoice, return paymentRequest - GET /api/bootstrap/:id: poll-driven state machine * awaiting_payment: checks payment, fires provisioner on confirm * provisioning: returns progress message * ready: delivers credentials; SSH private key delivered once then cleared * failed: returns error message - Stub mode message includes the exact /dev/stub/pay URL for easy testing - nextSteps array guides user through post-provision setup Updated: artifacts/api-server/src/lib/pricing.ts - Added bootstrapFee field reading BOOTSTRAP_FEE_SATS env var (default 10000) - calculateBootstrapFeeSats() method Updated: artifacts/api-server/src/routes/index.ts - Mounts bootstrapRouter Updated: replit.md - Documents all 7 new env vars (DO_API_TOKEN, DO_REGION, DO_SIZE, etc.) - Full curl-based flow example with annotated response shape End-to-end verified in stub mode: POST → pay → provisioning → ready (SSH key) → second GET clears key and shows sshKeyNote
2026-03-18 18:47:48 +00:00
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,
Task #5: Lightning-gated node bootstrap (proof-of-concept) Pay a Lightning invoice → Timmy auto-provisions a Bitcoin full node on DO. New: lib/db/src/schema/bootstrap-jobs.ts - bootstrap_jobs table: id, state, amountSats, paymentHash, paymentRequest, dropletId, nodeIp, tailscaleHostname, lnbitsUrl, sshPrivateKey, sshKeyDelivered (bool), errorMessage, createdAt, updatedAt - States: awaiting_payment | provisioning | ready | failed - Payment data stored inline (no FK to jobs/invoices tables — separate entity) - db:push applied to create table in Postgres New: artifacts/api-server/src/lib/provisioner.ts - ProvisionerService: stubs when DO_API_TOKEN absent, real otherwise - Stub mode: generates a real RSA 4096-bit SSH keypair via ssh-keygen, returns RFC 5737 test IP + fake Tailscale hostname after 2s delay - Real mode: upload SSH public key to DO → generate Tailscale auth key → create DO droplet with cloud-init user_data → poll for public IP (2 min) - buildCloudInitScript(): non-interactive bash that installs Docker + Tailscale + UFW + Bitcoin Knots via docker-compose; joins Tailscale if authkey provided - provision() designed as fire-and-forget (void); updates DB to ready/failed New: artifacts/api-server/src/routes/bootstrap.ts - POST /api/bootstrap: create job + LNbits invoice, return paymentRequest - GET /api/bootstrap/:id: poll-driven state machine * awaiting_payment: checks payment, fires provisioner on confirm * provisioning: returns progress message * ready: delivers credentials; SSH private key delivered once then cleared * failed: returns error message - Stub mode message includes the exact /dev/stub/pay URL for easy testing - nextSteps array guides user through post-provision setup Updated: artifacts/api-server/src/lib/pricing.ts - Added bootstrapFee field reading BOOTSTRAP_FEE_SATS env var (default 10000) - calculateBootstrapFeeSats() method Updated: artifacts/api-server/src/routes/index.ts - Mounts bootstrapRouter Updated: replit.md - Documents all 7 new env vars (DO_API_TOKEN, DO_REGION, DO_SIZE, etc.) - Full curl-based flow example with annotated response shape End-to-end verified in stub mode: POST → pay → provisioning → ready (SSH key) → second GET clears key and shows sshKeyNote
2026-03-18 18:47:48 +00:00
});
res.status(201).json({
jobId,
createdAt: createdAt.toISOString(),
bootstrapInvoice: {
paymentRequest: lnbitsInvoice.paymentRequest,
amountSats: bootstrapFeeSats,
paymentHash: lnbitsInvoice.paymentHash,
Task #5: Lightning-gated node bootstrap (proof-of-concept) Pay a Lightning invoice → Timmy auto-provisions a Bitcoin full node on DO. New: lib/db/src/schema/bootstrap-jobs.ts - bootstrap_jobs table: id, state, amountSats, paymentHash, paymentRequest, dropletId, nodeIp, tailscaleHostname, lnbitsUrl, sshPrivateKey, sshKeyDelivered (bool), errorMessage, createdAt, updatedAt - States: awaiting_payment | provisioning | ready | failed - Payment data stored inline (no FK to jobs/invoices tables — separate entity) - db:push applied to create table in Postgres New: artifacts/api-server/src/lib/provisioner.ts - ProvisionerService: stubs when DO_API_TOKEN absent, real otherwise - Stub mode: generates a real RSA 4096-bit SSH keypair via ssh-keygen, returns RFC 5737 test IP + fake Tailscale hostname after 2s delay - Real mode: upload SSH public key to DO → generate Tailscale auth key → create DO droplet with cloud-init user_data → poll for public IP (2 min) - buildCloudInitScript(): non-interactive bash that installs Docker + Tailscale + UFW + Bitcoin Knots via docker-compose; joins Tailscale if authkey provided - provision() designed as fire-and-forget (void); updates DB to ready/failed New: artifacts/api-server/src/routes/bootstrap.ts - POST /api/bootstrap: create job + LNbits invoice, return paymentRequest - GET /api/bootstrap/:id: poll-driven state machine * awaiting_payment: checks payment, fires provisioner on confirm * provisioning: returns progress message * ready: delivers credentials; SSH private key delivered once then cleared * failed: returns error message - Stub mode message includes the exact /dev/stub/pay URL for easy testing - nextSteps array guides user through post-provision setup Updated: artifacts/api-server/src/lib/pricing.ts - Added bootstrapFee field reading BOOTSTRAP_FEE_SATS env var (default 10000) - calculateBootstrapFeeSats() method Updated: artifacts/api-server/src/routes/index.ts - Mounts bootstrapRouter Updated: replit.md - Documents all 7 new env vars (DO_API_TOKEN, DO_REGION, DO_SIZE, etc.) - Full curl-based flow example with annotated response shape End-to-end verified in stub mode: POST → pay → provisioning → ready (SSH key) → second GET clears key and shows sshKeyNote
2026-03-18 18:47:48 +00:00
},
});
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to create bootstrap job";
logger.error("bootstrap job creation failed", { error: message });
Task #5: Lightning-gated node bootstrap (proof-of-concept) Pay a Lightning invoice → Timmy auto-provisions a Bitcoin full node on DO. New: lib/db/src/schema/bootstrap-jobs.ts - bootstrap_jobs table: id, state, amountSats, paymentHash, paymentRequest, dropletId, nodeIp, tailscaleHostname, lnbitsUrl, sshPrivateKey, sshKeyDelivered (bool), errorMessage, createdAt, updatedAt - States: awaiting_payment | provisioning | ready | failed - Payment data stored inline (no FK to jobs/invoices tables — separate entity) - db:push applied to create table in Postgres New: artifacts/api-server/src/lib/provisioner.ts - ProvisionerService: stubs when DO_API_TOKEN absent, real otherwise - Stub mode: generates a real RSA 4096-bit SSH keypair via ssh-keygen, returns RFC 5737 test IP + fake Tailscale hostname after 2s delay - Real mode: upload SSH public key to DO → generate Tailscale auth key → create DO droplet with cloud-init user_data → poll for public IP (2 min) - buildCloudInitScript(): non-interactive bash that installs Docker + Tailscale + UFW + Bitcoin Knots via docker-compose; joins Tailscale if authkey provided - provision() designed as fire-and-forget (void); updates DB to ready/failed New: artifacts/api-server/src/routes/bootstrap.ts - POST /api/bootstrap: create job + LNbits invoice, return paymentRequest - GET /api/bootstrap/:id: poll-driven state machine * awaiting_payment: checks payment, fires provisioner on confirm * provisioning: returns progress message * ready: delivers credentials; SSH private key delivered once then cleared * failed: returns error message - Stub mode message includes the exact /dev/stub/pay URL for easy testing - nextSteps array guides user through post-provision setup Updated: artifacts/api-server/src/lib/pricing.ts - Added bootstrapFee field reading BOOTSTRAP_FEE_SATS env var (default 10000) - calculateBootstrapFeeSats() method Updated: artifacts/api-server/src/routes/index.ts - Mounts bootstrapRouter Updated: replit.md - Documents all 7 new env vars (DO_API_TOKEN, DO_REGION, DO_SIZE, etc.) - Full curl-based flow example with annotated response shape End-to-end verified in stub mode: POST → pay → provisioning → ready (SSH key) → second GET clears key and shows sshKeyNote
2026-03-18 18:47:48 +00:00
res.status(500).json({ error: message });
}
});
// ── GET /api/bootstrap/:id ───────────────────────────────────────────────────
Task #5: Lightning-gated node bootstrap (proof-of-concept) Pay a Lightning invoice → Timmy auto-provisions a Bitcoin full node on DO. New: lib/db/src/schema/bootstrap-jobs.ts - bootstrap_jobs table: id, state, amountSats, paymentHash, paymentRequest, dropletId, nodeIp, tailscaleHostname, lnbitsUrl, sshPrivateKey, sshKeyDelivered (bool), errorMessage, createdAt, updatedAt - States: awaiting_payment | provisioning | ready | failed - Payment data stored inline (no FK to jobs/invoices tables — separate entity) - db:push applied to create table in Postgres New: artifacts/api-server/src/lib/provisioner.ts - ProvisionerService: stubs when DO_API_TOKEN absent, real otherwise - Stub mode: generates a real RSA 4096-bit SSH keypair via ssh-keygen, returns RFC 5737 test IP + fake Tailscale hostname after 2s delay - Real mode: upload SSH public key to DO → generate Tailscale auth key → create DO droplet with cloud-init user_data → poll for public IP (2 min) - buildCloudInitScript(): non-interactive bash that installs Docker + Tailscale + UFW + Bitcoin Knots via docker-compose; joins Tailscale if authkey provided - provision() designed as fire-and-forget (void); updates DB to ready/failed New: artifacts/api-server/src/routes/bootstrap.ts - POST /api/bootstrap: create job + LNbits invoice, return paymentRequest - GET /api/bootstrap/:id: poll-driven state machine * awaiting_payment: checks payment, fires provisioner on confirm * provisioning: returns progress message * ready: delivers credentials; SSH private key delivered once then cleared * failed: returns error message - Stub mode message includes the exact /dev/stub/pay URL for easy testing - nextSteps array guides user through post-provision setup Updated: artifacts/api-server/src/lib/pricing.ts - Added bootstrapFee field reading BOOTSTRAP_FEE_SATS env var (default 10000) - calculateBootstrapFeeSats() method Updated: artifacts/api-server/src/routes/index.ts - Mounts bootstrapRouter Updated: replit.md - Documents all 7 new env vars (DO_API_TOKEN, DO_REGION, DO_SIZE, etc.) - Full curl-based flow example with annotated response shape End-to-end verified in stub mode: POST → pay → provisioning → ready (SSH key) → second GET clears key and shows sshKeyNote
2026-03-18 18:47:48 +00:00
router.get("/bootstrap/:id", async (req: Request, res: Response) => {
const { id } = req.params; // Assuming ID is always valid, add Zod validation later
Task #5: Lightning-gated node bootstrap (proof-of-concept) Pay a Lightning invoice → Timmy auto-provisions a Bitcoin full node on DO. New: lib/db/src/schema/bootstrap-jobs.ts - bootstrap_jobs table: id, state, amountSats, paymentHash, paymentRequest, dropletId, nodeIp, tailscaleHostname, lnbitsUrl, sshPrivateKey, sshKeyDelivered (bool), errorMessage, createdAt, updatedAt - States: awaiting_payment | provisioning | ready | failed - Payment data stored inline (no FK to jobs/invoices tables — separate entity) - db:push applied to create table in Postgres New: artifacts/api-server/src/lib/provisioner.ts - ProvisionerService: stubs when DO_API_TOKEN absent, real otherwise - Stub mode: generates a real RSA 4096-bit SSH keypair via ssh-keygen, returns RFC 5737 test IP + fake Tailscale hostname after 2s delay - Real mode: upload SSH public key to DO → generate Tailscale auth key → create DO droplet with cloud-init user_data → poll for public IP (2 min) - buildCloudInitScript(): non-interactive bash that installs Docker + Tailscale + UFW + Bitcoin Knots via docker-compose; joins Tailscale if authkey provided - provision() designed as fire-and-forget (void); updates DB to ready/failed New: artifacts/api-server/src/routes/bootstrap.ts - POST /api/bootstrap: create job + LNbits invoice, return paymentRequest - GET /api/bootstrap/:id: poll-driven state machine * awaiting_payment: checks payment, fires provisioner on confirm * provisioning: returns progress message * ready: delivers credentials; SSH private key delivered once then cleared * failed: returns error message - Stub mode message includes the exact /dev/stub/pay URL for easy testing - nextSteps array guides user through post-provision setup Updated: artifacts/api-server/src/lib/pricing.ts - Added bootstrapFee field reading BOOTSTRAP_FEE_SATS env var (default 10000) - calculateBootstrapFeeSats() method Updated: artifacts/api-server/src/routes/index.ts - Mounts bootstrapRouter Updated: replit.md - Documents all 7 new env vars (DO_API_TOKEN, DO_REGION, DO_SIZE, etc.) - Full curl-based flow example with annotated response shape End-to-end verified in stub mode: POST → pay → provisioning → ready (SSH key) → second GET clears key and shows sshKeyNote
2026-03-18 18:47:48 +00:00
try {
let job = await getBootstrapJobById(id);
if (!job) { res.status(404).json({ error: "Bootstrap job not found" }); return; }
Task #5: Lightning-gated node bootstrap (proof-of-concept) Pay a Lightning invoice → Timmy auto-provisions a Bitcoin full node on DO. New: lib/db/src/schema/bootstrap-jobs.ts - bootstrap_jobs table: id, state, amountSats, paymentHash, paymentRequest, dropletId, nodeIp, tailscaleHostname, lnbitsUrl, sshPrivateKey, sshKeyDelivered (bool), errorMessage, createdAt, updatedAt - States: awaiting_payment | provisioning | ready | failed - Payment data stored inline (no FK to jobs/invoices tables — separate entity) - db:push applied to create table in Postgres New: artifacts/api-server/src/lib/provisioner.ts - ProvisionerService: stubs when DO_API_TOKEN absent, real otherwise - Stub mode: generates a real RSA 4096-bit SSH keypair via ssh-keygen, returns RFC 5737 test IP + fake Tailscale hostname after 2s delay - Real mode: upload SSH public key to DO → generate Tailscale auth key → create DO droplet with cloud-init user_data → poll for public IP (2 min) - buildCloudInitScript(): non-interactive bash that installs Docker + Tailscale + UFW + Bitcoin Knots via docker-compose; joins Tailscale if authkey provided - provision() designed as fire-and-forget (void); updates DB to ready/failed New: artifacts/api-server/src/routes/bootstrap.ts - POST /api/bootstrap: create job + LNbits invoice, return paymentRequest - GET /api/bootstrap/:id: poll-driven state machine * awaiting_payment: checks payment, fires provisioner on confirm * provisioning: returns progress message * ready: delivers credentials; SSH private key delivered once then cleared * failed: returns error message - Stub mode message includes the exact /dev/stub/pay URL for easy testing - nextSteps array guides user through post-provision setup Updated: artifacts/api-server/src/lib/pricing.ts - Added bootstrapFee field reading BOOTSTRAP_FEE_SATS env var (default 10000) - calculateBootstrapFeeSats() method Updated: artifacts/api-server/src/routes/index.ts - Mounts bootstrapRouter Updated: replit.md - Documents all 7 new env vars (DO_API_TOKEN, DO_REGION, DO_SIZE, etc.) - Full curl-based flow example with annotated response shape End-to-end verified in stub mode: POST → pay → provisioning → ready (SSH key) → second GET clears key and shows sshKeyNote
2026-03-18 18:47:48 +00:00
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,
Task #5: Lightning-gated node bootstrap (proof-of-concept) Pay a Lightning invoice → Timmy auto-provisions a Bitcoin full node on DO. New: lib/db/src/schema/bootstrap-jobs.ts - bootstrap_jobs table: id, state, amountSats, paymentHash, paymentRequest, dropletId, nodeIp, tailscaleHostname, lnbitsUrl, sshPrivateKey, sshKeyDelivered (bool), errorMessage, createdAt, updatedAt - States: awaiting_payment | provisioning | ready | failed - Payment data stored inline (no FK to jobs/invoices tables — separate entity) - db:push applied to create table in Postgres New: artifacts/api-server/src/lib/provisioner.ts - ProvisionerService: stubs when DO_API_TOKEN absent, real otherwise - Stub mode: generates a real RSA 4096-bit SSH keypair via ssh-keygen, returns RFC 5737 test IP + fake Tailscale hostname after 2s delay - Real mode: upload SSH public key to DO → generate Tailscale auth key → create DO droplet with cloud-init user_data → poll for public IP (2 min) - buildCloudInitScript(): non-interactive bash that installs Docker + Tailscale + UFW + Bitcoin Knots via docker-compose; joins Tailscale if authkey provided - provision() designed as fire-and-forget (void); updates DB to ready/failed New: artifacts/api-server/src/routes/bootstrap.ts - POST /api/bootstrap: create job + LNbits invoice, return paymentRequest - GET /api/bootstrap/:id: poll-driven state machine * awaiting_payment: checks payment, fires provisioner on confirm * provisioning: returns progress message * ready: delivers credentials; SSH private key delivered once then cleared * failed: returns error message - Stub mode message includes the exact /dev/stub/pay URL for easy testing - nextSteps array guides user through post-provision setup Updated: artifacts/api-server/src/lib/pricing.ts - Added bootstrapFee field reading BOOTSTRAP_FEE_SATS env var (default 10000) - calculateBootstrapFeeSats() method Updated: artifacts/api-server/src/routes/index.ts - Mounts bootstrapRouter Updated: replit.md - Documents all 7 new env vars (DO_API_TOKEN, DO_REGION, DO_SIZE, etc.) - Full curl-based flow example with annotated response shape End-to-end verified in stub mode: POST → pay → provisioning → ready (SSH key) → second GET clears key and shows sshKeyNote
2026-03-18 18:47:48 +00:00
state: job.state,
createdAt: job.createdAt.toISOString(),
updatedAt: job.updatedAt.toISOString(),
Task #5: Lightning-gated node bootstrap (proof-of-concept) Pay a Lightning invoice → Timmy auto-provisions a Bitcoin full node on DO. New: lib/db/src/schema/bootstrap-jobs.ts - bootstrap_jobs table: id, state, amountSats, paymentHash, paymentRequest, dropletId, nodeIp, tailscaleHostname, lnbitsUrl, sshPrivateKey, sshKeyDelivered (bool), errorMessage, createdAt, updatedAt - States: awaiting_payment | provisioning | ready | failed - Payment data stored inline (no FK to jobs/invoices tables — separate entity) - db:push applied to create table in Postgres New: artifacts/api-server/src/lib/provisioner.ts - ProvisionerService: stubs when DO_API_TOKEN absent, real otherwise - Stub mode: generates a real RSA 4096-bit SSH keypair via ssh-keygen, returns RFC 5737 test IP + fake Tailscale hostname after 2s delay - Real mode: upload SSH public key to DO → generate Tailscale auth key → create DO droplet with cloud-init user_data → poll for public IP (2 min) - buildCloudInitScript(): non-interactive bash that installs Docker + Tailscale + UFW + Bitcoin Knots via docker-compose; joins Tailscale if authkey provided - provision() designed as fire-and-forget (void); updates DB to ready/failed New: artifacts/api-server/src/routes/bootstrap.ts - POST /api/bootstrap: create job + LNbits invoice, return paymentRequest - GET /api/bootstrap/:id: poll-driven state machine * awaiting_payment: checks payment, fires provisioner on confirm * provisioning: returns progress message * ready: delivers credentials; SSH private key delivered once then cleared * failed: returns error message - Stub mode message includes the exact /dev/stub/pay URL for easy testing - nextSteps array guides user through post-provision setup Updated: artifacts/api-server/src/lib/pricing.ts - Added bootstrapFee field reading BOOTSTRAP_FEE_SATS env var (default 10000) - calculateBootstrapFeeSats() method Updated: artifacts/api-server/src/routes/index.ts - Mounts bootstrapRouter Updated: replit.md - Documents all 7 new env vars (DO_API_TOKEN, DO_REGION, DO_SIZE, etc.) - Full curl-based flow example with annotated response shape End-to-end verified in stub mode: POST → pay → provisioning → ready (SSH key) → second GET clears key and shows sshKeyNote
2026-03-18 18:47:48 +00:00
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 });
Task #5: Lightning-gated node bootstrap (proof-of-concept) Pay a Lightning invoice → Timmy auto-provisions a Bitcoin full node on DO. New: lib/db/src/schema/bootstrap-jobs.ts - bootstrap_jobs table: id, state, amountSats, paymentHash, paymentRequest, dropletId, nodeIp, tailscaleHostname, lnbitsUrl, sshPrivateKey, sshKeyDelivered (bool), errorMessage, createdAt, updatedAt - States: awaiting_payment | provisioning | ready | failed - Payment data stored inline (no FK to jobs/invoices tables — separate entity) - db:push applied to create table in Postgres New: artifacts/api-server/src/lib/provisioner.ts - ProvisionerService: stubs when DO_API_TOKEN absent, real otherwise - Stub mode: generates a real RSA 4096-bit SSH keypair via ssh-keygen, returns RFC 5737 test IP + fake Tailscale hostname after 2s delay - Real mode: upload SSH public key to DO → generate Tailscale auth key → create DO droplet with cloud-init user_data → poll for public IP (2 min) - buildCloudInitScript(): non-interactive bash that installs Docker + Tailscale + UFW + Bitcoin Knots via docker-compose; joins Tailscale if authkey provided - provision() designed as fire-and-forget (void); updates DB to ready/failed New: artifacts/api-server/src/routes/bootstrap.ts - POST /api/bootstrap: create job + LNbits invoice, return paymentRequest - GET /api/bootstrap/:id: poll-driven state machine * awaiting_payment: checks payment, fires provisioner on confirm * provisioning: returns progress message * ready: delivers credentials; SSH private key delivered once then cleared * failed: returns error message - Stub mode message includes the exact /dev/stub/pay URL for easy testing - nextSteps array guides user through post-provision setup Updated: artifacts/api-server/src/lib/pricing.ts - Added bootstrapFee field reading BOOTSTRAP_FEE_SATS env var (default 10000) - calculateBootstrapFeeSats() method Updated: artifacts/api-server/src/routes/index.ts - Mounts bootstrapRouter Updated: replit.md - Documents all 7 new env vars (DO_API_TOKEN, DO_REGION, DO_SIZE, etc.) - Full curl-based flow example with annotated response shape End-to-end verified in stub mode: POST → pay → provisioning → ready (SSH key) → second GET clears key and shows sshKeyNote
2026-03-18 18:47:48 +00:00
}
Task #5: Lightning-gated node bootstrap (proof-of-concept) Pay a Lightning invoice → Timmy auto-provisions a Bitcoin full node on DO. New: lib/db/src/schema/bootstrap-jobs.ts - bootstrap_jobs table: id, state, amountSats, paymentHash, paymentRequest, dropletId, nodeIp, tailscaleHostname, lnbitsUrl, sshPrivateKey, sshKeyDelivered (bool), errorMessage, createdAt, updatedAt - States: awaiting_payment | provisioning | ready | failed - Payment data stored inline (no FK to jobs/invoices tables — separate entity) - db:push applied to create table in Postgres New: artifacts/api-server/src/lib/provisioner.ts - ProvisionerService: stubs when DO_API_TOKEN absent, real otherwise - Stub mode: generates a real RSA 4096-bit SSH keypair via ssh-keygen, returns RFC 5737 test IP + fake Tailscale hostname after 2s delay - Real mode: upload SSH public key to DO → generate Tailscale auth key → create DO droplet with cloud-init user_data → poll for public IP (2 min) - buildCloudInitScript(): non-interactive bash that installs Docker + Tailscale + UFW + Bitcoin Knots via docker-compose; joins Tailscale if authkey provided - provision() designed as fire-and-forget (void); updates DB to ready/failed New: artifacts/api-server/src/routes/bootstrap.ts - POST /api/bootstrap: create job + LNbits invoice, return paymentRequest - GET /api/bootstrap/:id: poll-driven state machine * awaiting_payment: checks payment, fires provisioner on confirm * provisioning: returns progress message * ready: delivers credentials; SSH private key delivered once then cleared * failed: returns error message - Stub mode message includes the exact /dev/stub/pay URL for easy testing - nextSteps array guides user through post-provision setup Updated: artifacts/api-server/src/lib/pricing.ts - Added bootstrapFee field reading BOOTSTRAP_FEE_SATS env var (default 10000) - calculateBootstrapFeeSats() method Updated: artifacts/api-server/src/routes/index.ts - Mounts bootstrapRouter Updated: replit.md - Documents all 7 new env vars (DO_API_TOKEN, DO_REGION, DO_SIZE, etc.) - Full curl-based flow example with annotated response shape End-to-end verified in stub mode: POST → pay → provisioning → ready (SSH key) → second GET clears key and shows sshKeyNote
2026-03-18 18:47:48 +00:00
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to fetch bootstrap job";
logger.error("bootstrap job fetch failed", { error: message });
Task #5: Lightning-gated node bootstrap (proof-of-concept) Pay a Lightning invoice → Timmy auto-provisions a Bitcoin full node on DO. New: lib/db/src/schema/bootstrap-jobs.ts - bootstrap_jobs table: id, state, amountSats, paymentHash, paymentRequest, dropletId, nodeIp, tailscaleHostname, lnbitsUrl, sshPrivateKey, sshKeyDelivered (bool), errorMessage, createdAt, updatedAt - States: awaiting_payment | provisioning | ready | failed - Payment data stored inline (no FK to jobs/invoices tables — separate entity) - db:push applied to create table in Postgres New: artifacts/api-server/src/lib/provisioner.ts - ProvisionerService: stubs when DO_API_TOKEN absent, real otherwise - Stub mode: generates a real RSA 4096-bit SSH keypair via ssh-keygen, returns RFC 5737 test IP + fake Tailscale hostname after 2s delay - Real mode: upload SSH public key to DO → generate Tailscale auth key → create DO droplet with cloud-init user_data → poll for public IP (2 min) - buildCloudInitScript(): non-interactive bash that installs Docker + Tailscale + UFW + Bitcoin Knots via docker-compose; joins Tailscale if authkey provided - provision() designed as fire-and-forget (void); updates DB to ready/failed New: artifacts/api-server/src/routes/bootstrap.ts - POST /api/bootstrap: create job + LNbits invoice, return paymentRequest - GET /api/bootstrap/:id: poll-driven state machine * awaiting_payment: checks payment, fires provisioner on confirm * provisioning: returns progress message * ready: delivers credentials; SSH private key delivered once then cleared * failed: returns error message - Stub mode message includes the exact /dev/stub/pay URL for easy testing - nextSteps array guides user through post-provision setup Updated: artifacts/api-server/src/lib/pricing.ts - Added bootstrapFee field reading BOOTSTRAP_FEE_SATS env var (default 10000) - calculateBootstrapFeeSats() method Updated: artifacts/api-server/src/routes/index.ts - Mounts bootstrapRouter Updated: replit.md - Documents all 7 new env vars (DO_API_TOKEN, DO_REGION, DO_SIZE, etc.) - Full curl-based flow example with annotated response shape End-to-end verified in stub mode: POST → pay → provisioning → ready (SSH key) → second GET clears key and shows sshKeyNote
2026-03-18 18:47:48 +00:00
res.status(500).json({ error: message });
}
});
export default router;