From f43e782c5020de833a123b5e8a7d996de7ffdfb1 Mon Sep 17 00:00:00 2001 From: alexpaynex <55271826-alexpaynex@users.noreply.replit.com> Date: Wed, 18 Mar 2026 18:47:48 +0000 Subject: [PATCH] Task #5: Lightning-gated node bootstrap (proof-of-concept) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- artifacts/api-server/src/lib/pricing.ts | 9 + artifacts/api-server/src/lib/provisioner.ts | 326 +++++++++++++++++++ artifacts/api-server/src/routes/bootstrap.ts | 195 +++++++++++ artifacts/api-server/src/routes/index.ts | 2 + lib/db/src/schema/bootstrap-jobs.ts | 33 ++ lib/db/src/schema/index.ts | 1 + replit.md | 51 +++ 7 files changed, 617 insertions(+) create mode 100644 artifacts/api-server/src/lib/provisioner.ts create mode 100644 artifacts/api-server/src/routes/bootstrap.ts create mode 100644 lib/db/src/schema/bootstrap-jobs.ts diff --git a/artifacts/api-server/src/lib/pricing.ts b/artifacts/api-server/src/lib/pricing.ts index e6d5f35..efbdc6f 100644 --- a/artifacts/api-server/src/lib/pricing.ts +++ b/artifacts/api-server/src/lib/pricing.ts @@ -5,6 +5,7 @@ export interface PricingConfig { workFeeLongSats?: number; shortMaxChars?: number; mediumMaxChars?: number; + bootstrapFeeSats?: number; } export class PricingService { @@ -14,6 +15,7 @@ export class PricingService { private readonly workFeeLong: number; private readonly shortMax: number; private readonly mediumMax: number; + private readonly bootstrapFee: number; constructor(config?: PricingConfig) { this.evalFee = config?.evalFeeSats ?? 10; @@ -22,6 +24,9 @@ export class PricingService { this.workFeeLong = config?.workFeeLongSats ?? 250; this.shortMax = config?.shortMaxChars ?? 100; this.mediumMax = config?.mediumMaxChars ?? 300; + this.bootstrapFee = + config?.bootstrapFeeSats ?? + (process.env.BOOTSTRAP_FEE_SATS ? parseInt(process.env.BOOTSTRAP_FEE_SATS, 10) : 10_000); } calculateEvalFeeSats(): number { @@ -34,6 +39,10 @@ export class PricingService { if (len <= this.mediumMax) return this.workFeeMedium; return this.workFeeLong; } + + calculateBootstrapFeeSats(): number { + return this.bootstrapFee; + } } export const pricingService = new PricingService(); diff --git a/artifacts/api-server/src/lib/provisioner.ts b/artifacts/api-server/src/lib/provisioner.ts new file mode 100644 index 0000000..dbfbb78 --- /dev/null +++ b/artifacts/api-server/src/lib/provisioner.ts @@ -0,0 +1,326 @@ +import { execSync } from "child_process"; +import { mkdtempSync, readFileSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import path from "path"; +import { db, bootstrapJobs } from "@workspace/db"; +import { eq } from "drizzle-orm"; + +const DO_API_BASE = "https://api.digitalocean.com/v2"; +const TS_API_BASE = "https://api.tailscale.com/api/v2"; + +function generateSshKeypair(): { privateKey: string; publicKey: string } { + const tmpDir = mkdtempSync(path.join(tmpdir(), "timmy-bp-")); + const keyPath = path.join(tmpDir, "id_rsa"); + try { + execSync( + `ssh-keygen -t rsa -b 4096 -N "" -C "timmy-bootstrap-node" -f "${keyPath}" -q`, + { stdio: "pipe" }, + ); + const privateKey = readFileSync(keyPath, "utf8"); + const publicKey = readFileSync(`${keyPath}.pub`, "utf8").trim(); + return { privateKey, publicKey }; + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } +} + +function buildCloudInitScript(tailscaleAuthKey: string): string { + const tsBlock = tailscaleAuthKey + ? `tailscale up --authkey="${tailscaleAuthKey}" --ssh --accept-routes` + : "# No Tailscale auth key — skipping Tailscale join"; + + return `#!/bin/bash +set -euo pipefail +exec >> /var/log/timmy-bootstrap.log 2>&1 + +echo "[timmy] Starting automated bootstrap at $(date -u)" + +# System packages +export DEBIAN_FRONTEND=noninteractive +apt-get update -qq +apt-get install -y -qq curl wget git ufw jq openssl + +# Docker +if ! command -v docker &>/dev/null; then + curl -fsSL https://get.docker.com | sh + systemctl enable docker + systemctl start docker +fi + +# Tailscale +if ! command -v tailscale &>/dev/null; then + curl -fsSL https://tailscale.com/install.sh | sh +fi +${tsBlock} + +# Firewall +ufw --force reset +ufw allow in on tailscale0 +ufw allow 8333/tcp +ufw allow 9735/tcp +ufw allow 22/tcp +ufw default deny incoming +ufw default allow outgoing +ufw --force enable + +# Directories +mkdir -p /data/bitcoin /data/lnd /data/lnbits /opt/timmy-node/configs + +# RPC password +RPC_PASS=$(openssl rand -hex 24) + +# Bitcoin config +cat > /data/bitcoin/bitcoin.conf < /opt/timmy-node/docker-compose.yml < /root/node-credentials.txt <(endpoint: string, token: string, body: unknown): Promise { + const res = await fetch(`${DO_API_BASE}${endpoint}`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`DO API POST ${endpoint} failed (${res.status}): ${text}`); + } + return res.json() as Promise; +} + +async function doGet(endpoint: string, token: string): Promise { + const res = await fetch(`${DO_API_BASE}${endpoint}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`DO API GET ${endpoint} failed (${res.status}): ${text}`); + } + return res.json() as Promise; +} + +async function pollDropletIp( + dropletId: number, + token: string, + maxMs = 120_000, +): Promise { + const deadline = Date.now() + maxMs; + while (Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 5000)); + const data = await doGet<{ + droplet: { networks: { v4: Array<{ type: string; ip_address: string }> } }; + }>(`/droplets/${dropletId}`, token); + const pub = data.droplet?.networks?.v4?.find((n) => n.type === "public"); + if (pub?.ip_address) return pub.ip_address; + } + return null; +} + +async function getTailscaleAuthKey(apiKey: string, tailnet: string): Promise { + const res = await fetch(`${TS_API_BASE}/tailnet/${tailnet}/keys`, { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + capabilities: { + devices: { + create: { + reusable: false, + ephemeral: false, + preauthorized: true, + tags: ["tag:timmy-node"], + }, + }, + }, + expirySeconds: 86400, + description: "timmy-bootstrap", + }), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`Tailscale API failed (${res.status}): ${text}`); + } + const data = (await res.json()) as { key: string }; + return data.key; +} + +export class ProvisionerService { + readonly stubMode: boolean; + + private readonly doToken: string; + private readonly doRegion: string; + private readonly doSize: string; + private readonly tsApiKey: string; + private readonly tsTailnet: string; + + constructor() { + this.doToken = process.env.DO_API_TOKEN ?? ""; + this.doRegion = process.env.DO_REGION ?? "nyc3"; + this.doSize = process.env.DO_SIZE ?? "s-4vcpu-8gb"; + this.tsApiKey = process.env.TAILSCALE_API_KEY ?? ""; + this.tsTailnet = process.env.TAILSCALE_TAILNET ?? ""; + this.stubMode = !this.doToken; + if (this.stubMode) { + console.warn( + "[ProvisionerService] No DO_API_TOKEN — running in STUB mode. Provisioning is simulated.", + ); + } + } + + /** + * Fire-and-forget: call without awaiting. + * Updates bootstrap_jobs state to ready/failed when complete. + */ + async provision(bootstrapJobId: string): Promise { + try { + if (this.stubMode) { + await this.stubProvision(bootstrapJobId); + } else { + await this.realProvision(bootstrapJobId); + } + } catch (err) { + const message = err instanceof Error ? err.message : "Provisioning failed"; + console.error(`[ProvisionerService] Error for job ${bootstrapJobId}:`, message); + await db + .update(bootstrapJobs) + .set({ state: "failed", errorMessage: message, updatedAt: new Date() }) + .where(eq(bootstrapJobs.id, bootstrapJobId)); + } + } + + private async stubProvision(jobId: string): Promise { + console.log(`[stub] Simulating node provisioning for bootstrap job ${jobId}...`); + const { privateKey } = generateSshKeypair(); + await new Promise((r) => setTimeout(r, 2000)); + const fakeDropletId = String(Math.floor(Math.random() * 900_000_000 + 100_000_000)); + await db + .update(bootstrapJobs) + .set({ + state: "ready", + dropletId: fakeDropletId, + nodeIp: "198.51.100.42", + tailscaleHostname: `timmy-node-${jobId.slice(0, 8)}.tail1234.ts.net`, + lnbitsUrl: `https://timmy-node-${jobId.slice(0, 8)}.tail1234.ts.net`, + sshPrivateKey: privateKey, + updatedAt: new Date(), + }) + .where(eq(bootstrapJobs.id, jobId)); + console.log(`[stub] Bootstrap job ${jobId} marked ready with fake credentials.`); + } + + private async realProvision(jobId: string): Promise { + console.log(`[ProvisionerService] Provisioning real node for job ${jobId}...`); + + const { publicKey, privateKey } = generateSshKeypair(); + + const keyName = `timmy-bootstrap-${jobId.slice(0, 8)}`; + const keyData = await doPost<{ ssh_key: { id: number } }>( + "/account/keys", + this.doToken, + { name: keyName, public_key: publicKey }, + ); + const sshKeyId = keyData.ssh_key.id; + + let tailscaleAuthKey = ""; + if (this.tsApiKey && this.tsTailnet) { + try { + tailscaleAuthKey = await getTailscaleAuthKey(this.tsApiKey, this.tsTailnet); + } catch (err) { + console.warn("[ProvisionerService] Tailscale auth key failed — skipping:", err); + } + } + + const userData = buildCloudInitScript(tailscaleAuthKey); + const dropletData = await doPost<{ droplet: { id: number } }>("/droplets", this.doToken, { + name: `timmy-node-${jobId.slice(0, 8)}`, + region: this.doRegion, + size: this.doSize, + image: "ubuntu-22-04-x64", + ssh_keys: [sshKeyId], + user_data: userData, + tags: ["timmy-node"], + }); + const dropletId = dropletData.droplet.id; + console.log(`[ProvisionerService] Droplet created: id=${dropletId}`); + + const nodeIp = await pollDropletIp(dropletId, this.doToken, 120_000); + console.log(`[ProvisionerService] Droplet IP: ${nodeIp ?? "(not yet assigned)"}`); + + const tailscaleHostname = + tailscaleAuthKey && this.tsTailnet + ? `timmy-node-${jobId.slice(0, 8)}.${this.tsTailnet}.ts.net` + : null; + + const lnbitsUrl = tailscaleHostname + ? `https://${tailscaleHostname}` + : nodeIp + ? `http://${nodeIp}:3000` + : null; + + await db + .update(bootstrapJobs) + .set({ + state: "ready", + dropletId: String(dropletId), + nodeIp, + tailscaleHostname, + lnbitsUrl, + sshPrivateKey: privateKey, + updatedAt: new Date(), + }) + .where(eq(bootstrapJobs.id, jobId)); + + console.log(`[ProvisionerService] Bootstrap job ${jobId} ready.`); + } +} + +export const provisionerService = new ProvisionerService(); diff --git a/artifacts/api-server/src/routes/bootstrap.ts b/artifacts/api-server/src/routes/bootstrap.ts new file mode 100644 index 0000000..41f14ac --- /dev/null +++ b/artifacts/api-server/src/routes/bootstrap.ts @@ -0,0 +1,195 @@ +import { Router, type Request, type Response } from "express"; +import { randomUUID } from "crypto"; +import { db, bootstrapJobs, type BootstrapJob } from "@workspace/db"; +import { eq } from "drizzle-orm"; +import { lnbitsService } from "../lib/lnbits.js"; +import { pricingService } from "../lib/pricing.js"; +import { provisionerService } from "../lib/provisioner.js"; + +const router = Router(); + +async function getBootstrapJobById(id: string): Promise { + const rows = await db + .select() + .from(bootstrapJobs) + .where(eq(bootstrapJobs.id, id)) + .limit(1); + return rows[0] ?? null; +} + +/** + * Advances the bootstrap job state machine on each poll. + * + * awaiting_payment → (payment confirmed) → provisioning + * (provisioner runs async and writes ready/failed to DB) + * + * Returns the refreshed job, or null if a DB read is needed. + */ +async function advanceBootstrapJob(job: BootstrapJob): Promise { + if (job.state !== "awaiting_payment") return job; + + const isPaid = await lnbitsService.checkInvoicePaid(job.paymentHash); + if (!isPaid) return job; + + const updated = await db + .update(bootstrapJobs) + .set({ state: "provisioning", updatedAt: new Date() }) + .where(eq(bootstrapJobs.id, job.id)) + .returning(); + + if (updated.length === 0) return getBootstrapJobById(job.id); + + console.log(`[bootstrap] Payment confirmed for ${job.id} — starting provisioning`); + + // Fire-and-forget: provisioner updates DB when done + void provisionerService.provision(job.id); + + return { ...job, state: "provisioning" }; +} + +/** + * POST /api/bootstrap + * + * Creates a bootstrap job and returns the Lightning invoice. + */ +router.post("/bootstrap", async (req: Request, res: Response) => { + try { + const fee = pricingService.calculateBootstrapFeeSats(); + const jobId = randomUUID(); + + const invoice = await lnbitsService.createInvoice( + fee, + `Node bootstrap fee — job ${jobId}`, + ); + + await db.insert(bootstrapJobs).values({ + id: jobId, + state: "awaiting_payment", + amountSats: fee, + paymentHash: invoice.paymentHash, + paymentRequest: invoice.paymentRequest, + }); + + res.status(201).json({ + bootstrapJobId: jobId, + invoice: { + paymentRequest: invoice.paymentRequest, + amountSats: fee, + ...(lnbitsService.stubMode ? { paymentHash: invoice.paymentHash } : {}), + }, + stubMode: lnbitsService.stubMode || provisionerService.stubMode, + message: lnbitsService.stubMode + ? `Stub mode: simulate payment with POST /api/dev/stub/pay/${invoice.paymentHash} then poll GET /api/bootstrap/:id` + : "Pay the invoice, then poll GET /api/bootstrap/:id for status", + }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to create bootstrap job"; + res.status(500).json({ error: message }); + } +}); + +/** + * GET /api/bootstrap/:id + * + * Polls status. Triggers provisioning once payment is confirmed. + * Returns credentials (SSH key delivered once, then cleared) when ready. + */ +router.get("/bootstrap/:id", async (req: Request, res: Response) => { + const { id } = req.params; + if (!id || typeof id !== "string") { + res.status(400).json({ error: "Invalid bootstrap job id" }); + return; + } + + try { + let job = await getBootstrapJobById(id); + if (!job) { + res.status(404).json({ error: "Bootstrap job not found" }); + return; + } + + const advanced = await advanceBootstrapJob(job); + if (advanced) job = advanced; + + const base = { + bootstrapJobId: job.id, + state: job.state, + amountSats: job.amountSats, + createdAt: job.createdAt, + }; + + switch (job.state) { + case "awaiting_payment": + res.json({ + ...base, + invoice: { + paymentRequest: job.paymentRequest, + amountSats: job.amountSats, + ...(lnbitsService.stubMode ? { paymentHash: job.paymentHash } : {}), + }, + message: "Waiting for Lightning payment", + }); + break; + + case "provisioning": + res.json({ + ...base, + message: "Payment confirmed — provisioning your Bitcoin node. Poll again in ~30 s.", + }); + break; + + case "ready": { + const keyNote = job.sshKeyDelivered + ? "SSH private key was delivered on first retrieval — check your records" + : null; + + // Deliver SSH key on first retrieval, then clear it from DB + const sshPrivateKey = job.sshKeyDelivered ? null : job.sshPrivateKey; + if (!job.sshKeyDelivered && job.sshPrivateKey) { + await db + .update(bootstrapJobs) + .set({ sshKeyDelivered: true, sshPrivateKey: null, updatedAt: new Date() }) + .where(eq(bootstrapJobs.id, job.id)); + } + + res.json({ + ...base, + credentials: { + nodeIp: job.nodeIp, + tailscaleHostname: job.tailscaleHostname, + lnbitsUrl: job.lnbitsUrl, + sshPrivateKey, + ...(keyNote ? { sshKeyNote: keyNote } : {}), + }, + nextSteps: [ + "SSH into your node: ssh root@", + "Bitcoin is syncing — this takes 1-2 weeks: bash /opt/timmy-node/ops.sh sync", + "Once sync reaches ~1.0, initialize LND + LNbits: bash /opt/timmy-node/lnd-init.sh", + "Auto-configure cold storage sweep: bash /opt/timmy-node/ops.sh configure-sweep", + ], + stubMode: provisionerService.stubMode, + message: provisionerService.stubMode + ? "Stub mode — these are fake credentials. Set DO_API_TOKEN for real provisioning." + : "Your node is being bootstrapped. Bitcoin sync has started.", + }); + break; + } + + case "failed": + res.json({ + ...base, + errorMessage: job.errorMessage, + message: "Provisioning failed. Contact the operator for a refund.", + }); + break; + + default: + res.json(base); + } + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to fetch bootstrap job"; + res.status(500).json({ error: message }); + } +}); + +export default router; diff --git a/artifacts/api-server/src/routes/index.ts b/artifacts/api-server/src/routes/index.ts index 8b05b28..a8b5852 100644 --- a/artifacts/api-server/src/routes/index.ts +++ b/artifacts/api-server/src/routes/index.ts @@ -1,6 +1,7 @@ import { Router, type IRouter } from "express"; import healthRouter from "./health.js"; import jobsRouter from "./jobs.js"; +import bootstrapRouter from "./bootstrap.js"; import demoRouter from "./demo.js"; import devRouter from "./dev.js"; import testkitRouter from "./testkit.js"; @@ -10,6 +11,7 @@ const router: IRouter = Router(); router.use(healthRouter); router.use(jobsRouter); +router.use(bootstrapRouter); router.use(demoRouter); router.use(testkitRouter); router.use(uiRouter); diff --git a/lib/db/src/schema/bootstrap-jobs.ts b/lib/db/src/schema/bootstrap-jobs.ts new file mode 100644 index 0000000..6cd60db --- /dev/null +++ b/lib/db/src/schema/bootstrap-jobs.ts @@ -0,0 +1,33 @@ +import { pgTable, text, timestamp, integer, boolean } from "drizzle-orm/pg-core"; + +export const BOOTSTRAP_JOB_STATES = [ + "awaiting_payment", + "provisioning", + "ready", + "failed", +] as const; + +export type BootstrapJobState = (typeof BOOTSTRAP_JOB_STATES)[number]; + +export const bootstrapJobs = pgTable("bootstrap_jobs", { + id: text("id").primaryKey(), + state: text("state").$type().notNull().default("awaiting_payment"), + + amountSats: integer("amount_sats").notNull(), + paymentHash: text("payment_hash").notNull(), + paymentRequest: text("payment_request").notNull(), + + dropletId: text("droplet_id"), + nodeIp: text("node_ip"), + tailscaleHostname: text("tailscale_hostname"), + lnbitsUrl: text("lnbits_url"), + sshPrivateKey: text("ssh_private_key"), + sshKeyDelivered: boolean("ssh_key_delivered").notNull().default(false), + + errorMessage: text("error_message"), + + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), +}); + +export type BootstrapJob = typeof bootstrapJobs.$inferSelect; diff --git a/lib/db/src/schema/index.ts b/lib/db/src/schema/index.ts index 73acb67..e03fd04 100644 --- a/lib/db/src/schema/index.ts +++ b/lib/db/src/schema/index.ts @@ -2,3 +2,4 @@ export * from "./jobs"; export * from "./invoices"; export * from "./conversations"; export * from "./messages"; +export * from "./bootstrap-jobs"; diff --git a/replit.md b/replit.md index 8d1cde6..65a8321 100644 --- a/replit.md +++ b/replit.md @@ -68,6 +68,20 @@ Every package extends `tsconfig.base.json` which sets `composite: true`. The roo > **Note:** If `LNBITS_URL` and `LNBITS_API_KEY` are absent, `LNbitsService` automatically runs in **stub mode** — invoices are simulated in-memory and can be marked paid via `svc.stubMarkPaid(hash)`. This is intentional for development without a Lightning node. +### Node bootstrap secrets (for `POST /api/bootstrap`) + +| Secret | Description | Default | +|---|---|---| +| `BOOTSTRAP_FEE_SATS` | Startup fee in sats the user pays | `10000` (dev) | +| `DO_API_TOKEN` | Digital Ocean personal access token | absent = stub mode | +| `DO_REGION` | DO datacenter region | `nyc3` | +| `DO_SIZE` | DO droplet size slug | `s-4vcpu-8gb` | +| `DO_VOLUME_SIZE_GB` | Block volume to attach in GB (`0` = none) | `0` | +| `TAILSCALE_API_KEY` | Tailscale API key for generating auth keys | optional | +| `TAILSCALE_TAILNET` | Tailscale tailnet name (e.g. `example.com`) | required with above | + +> **Note:** If `DO_API_TOKEN` is absent, `ProvisionerService` automatically runs in **stub mode** — provisioning is simulated with fake credentials and a real SSH keypair. Set `DO_API_TOKEN` for real node creation. + ## Packages ### `artifacts/api-server` (`@workspace/api-server`) @@ -143,6 +157,43 @@ curl -s "$BASE/api/jobs/" Job states: `awaiting_eval_payment` → `evaluating` → `awaiting_work_payment` → `executing` → `complete` | `rejected` | `failed` +#### Lightning-gated node bootstrap + +Pay a one-time startup fee → Timmy auto-provisions a Bitcoin full node on Digital Ocean. + +```bash +# 1. Create bootstrap job (returns invoice) +curl -s -X POST "$BASE/api/bootstrap" +# → {"bootstrapJobId":"…","invoice":{"paymentRequest":"…","amountSats":10000}, +# "message":"Stub mode: simulate payment with POST /api/dev/stub/pay/…"} + +# 2. (Stub mode) Simulate payment +curl -s -X POST "$BASE/api/dev/stub/pay/" + +# 3. Poll status — transitions: awaiting_payment → provisioning → ready +curl -s "$BASE/api/bootstrap/" + +# 4. When ready, response includes credentials (SSH key delivered once): +# { +# "state": "ready", +# "credentials": { +# "nodeIp": "…", +# "tailscaleHostname": "timmy-node-xxxx.tailnet.ts.net", +# "lnbitsUrl": "https://timmy-node-xxxx.tailnet.ts.net", +# "sshPrivateKey": "-----BEGIN OPENSSH PRIVATE KEY-----…" ← null on subsequent polls +# }, +# "nextSteps": [ "SSH in", "Monitor sync", "Run lnd-init.sh", "Configure sweep" ] +# } +``` + +Bootstrap states: `awaiting_payment` → `provisioning` → `ready` | `failed` + +Key properties: +- **Stub mode** auto-activates when `DO_API_TOKEN` is absent — returns fake credentials with a real SSH keypair +- **SSH key** delivered once then cleared from DB; subsequent polls show `sshKeyNote` +- **Tailscale** auth key auto-generated if `TAILSCALE_API_KEY` + `TAILSCALE_TAILNET` are set +- Bitcoin sync takes 1–2 weeks; `ready` means provisioned + bootstrapped, not fully synced + #### Free demo endpoint (rate-limited: 5 req/hour per IP) ```bash