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/🆔 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
This commit is contained in:
alexpaynex
2026-03-18 18:47:48 +00:00
parent 1a60363b74
commit f43e782c50
7 changed files with 617 additions and 0 deletions

View File

@@ -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();

View File

@@ -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 <<BTCCONF
server=1
rpcuser=satoshi
rpcpassword=\$RPC_PASS
rpcallowip=172.16.0.0/12
rpcbind=0.0.0.0
txindex=1
zmqpubrawblock=tcp://0.0.0.0:28332
zmqpubrawtx=tcp://0.0.0.0:28333
[main]
rpcport=8332
BTCCONF
# Docker Compose
cat > /opt/timmy-node/docker-compose.yml <<COMPOSE
version: "3.8"
services:
bitcoin:
image: bitcoinknots/bitcoin:29.3.knots20260210
container_name: bitcoin
restart: unless-stopped
volumes:
- /data/bitcoin:/home/bitcoin/.bitcoin
ports:
- "8333:8333"
- "8332:8332"
- "28332:28332"
- "28333:28333"
command: bitcoind -datadir=/home/bitcoin/.bitcoin -conf=/home/bitcoin/.bitcoin/bitcoin.conf
COMPOSE
# Start Bitcoin Core
cd /opt/timmy-node
docker compose up -d bitcoin
# Save credentials
cat > /root/node-credentials.txt <<CREDS
BITCOIN_RPC_USER=satoshi
BITCOIN_RPC_PASS=\$RPC_PASS
CREDS
chmod 600 /root/node-credentials.txt
echo "[timmy] Bootstrap complete at $(date -u). Bitcoin sync started (takes 1-2 weeks)."
echo "[timmy] Next steps:"
echo " 1. SSH to node, then run: bash /opt/timmy-node/lnd-init.sh"
echo " 2. Monitor sync: bash /opt/timmy-node/ops.sh sync"
`;
}
async function doPost<T>(endpoint: string, token: string, body: unknown): Promise<T> {
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<T>;
}
async function doGet<T>(endpoint: string, token: string): Promise<T> {
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<T>;
}
async function pollDropletIp(
dropletId: number,
token: string,
maxMs = 120_000,
): Promise<string | null> {
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<string> {
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<void> {
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<void> {
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<void> {
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();

View File

@@ -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<BootstrapJob | null> {
const rows = await db
.select()
.from(bootstrapJobs)
.where(eq(bootstrapJobs.id, id))
.limit(1);
return rows[0] ?? null;
}
/**
* Advances the bootstrap job state machine on each poll.
*
* awaiting_payment → (payment confirmed) → provisioning
* (provisioner runs async and writes ready/failed to DB)
*
* Returns the refreshed job, or null if a DB read is needed.
*/
async function advanceBootstrapJob(job: BootstrapJob): Promise<BootstrapJob | null> {
if (job.state !== "awaiting_payment") return job;
const isPaid = await lnbitsService.checkInvoicePaid(job.paymentHash);
if (!isPaid) return job;
const updated = await db
.update(bootstrapJobs)
.set({ state: "provisioning", updatedAt: new Date() })
.where(eq(bootstrapJobs.id, job.id))
.returning();
if (updated.length === 0) return getBootstrapJobById(job.id);
console.log(`[bootstrap] Payment confirmed for ${job.id} — starting provisioning`);
// Fire-and-forget: provisioner updates DB when done
void provisionerService.provision(job.id);
return { ...job, state: "provisioning" };
}
/**
* POST /api/bootstrap
*
* Creates a bootstrap job and returns the Lightning invoice.
*/
router.post("/bootstrap", async (req: Request, res: Response) => {
try {
const fee = pricingService.calculateBootstrapFeeSats();
const jobId = randomUUID();
const invoice = await lnbitsService.createInvoice(
fee,
`Node bootstrap fee — job ${jobId}`,
);
await db.insert(bootstrapJobs).values({
id: jobId,
state: "awaiting_payment",
amountSats: fee,
paymentHash: invoice.paymentHash,
paymentRequest: invoice.paymentRequest,
});
res.status(201).json({
bootstrapJobId: jobId,
invoice: {
paymentRequest: invoice.paymentRequest,
amountSats: fee,
...(lnbitsService.stubMode ? { paymentHash: invoice.paymentHash } : {}),
},
stubMode: lnbitsService.stubMode || provisionerService.stubMode,
message: lnbitsService.stubMode
? `Stub mode: simulate payment with POST /api/dev/stub/pay/${invoice.paymentHash} then poll GET /api/bootstrap/:id`
: "Pay the invoice, then poll GET /api/bootstrap/:id for status",
});
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to create bootstrap job";
res.status(500).json({ error: message });
}
});
/**
* GET /api/bootstrap/:id
*
* Polls status. Triggers provisioning once payment is confirmed.
* Returns credentials (SSH key delivered once, then cleared) when ready.
*/
router.get("/bootstrap/:id", async (req: Request, res: Response) => {
const { id } = req.params;
if (!id || typeof id !== "string") {
res.status(400).json({ error: "Invalid bootstrap job id" });
return;
}
try {
let job = await getBootstrapJobById(id);
if (!job) {
res.status(404).json({ error: "Bootstrap job not found" });
return;
}
const advanced = await advanceBootstrapJob(job);
if (advanced) job = advanced;
const base = {
bootstrapJobId: job.id,
state: job.state,
amountSats: job.amountSats,
createdAt: job.createdAt,
};
switch (job.state) {
case "awaiting_payment":
res.json({
...base,
invoice: {
paymentRequest: job.paymentRequest,
amountSats: job.amountSats,
...(lnbitsService.stubMode ? { paymentHash: job.paymentHash } : {}),
},
message: "Waiting for Lightning payment",
});
break;
case "provisioning":
res.json({
...base,
message: "Payment confirmed — provisioning your Bitcoin node. Poll again in ~30 s.",
});
break;
case "ready": {
const keyNote = job.sshKeyDelivered
? "SSH private key was delivered on first retrieval — check your records"
: null;
// Deliver SSH key on first retrieval, then clear it from DB
const sshPrivateKey = job.sshKeyDelivered ? null : job.sshPrivateKey;
if (!job.sshKeyDelivered && job.sshPrivateKey) {
await db
.update(bootstrapJobs)
.set({ sshKeyDelivered: true, sshPrivateKey: null, updatedAt: new Date() })
.where(eq(bootstrapJobs.id, job.id));
}
res.json({
...base,
credentials: {
nodeIp: job.nodeIp,
tailscaleHostname: job.tailscaleHostname,
lnbitsUrl: job.lnbitsUrl,
sshPrivateKey,
...(keyNote ? { sshKeyNote: keyNote } : {}),
},
nextSteps: [
"SSH into your node: ssh root@<nodeIp>",
"Bitcoin is syncing — this takes 1-2 weeks: bash /opt/timmy-node/ops.sh sync",
"Once sync reaches ~1.0, initialize LND + LNbits: bash /opt/timmy-node/lnd-init.sh",
"Auto-configure cold storage sweep: bash /opt/timmy-node/ops.sh configure-sweep",
],
stubMode: provisionerService.stubMode,
message: provisionerService.stubMode
? "Stub mode — these are fake credentials. Set DO_API_TOKEN for real provisioning."
: "Your node is being bootstrapped. Bitcoin sync has started.",
});
break;
}
case "failed":
res.json({
...base,
errorMessage: job.errorMessage,
message: "Provisioning failed. Contact the operator for a refund.",
});
break;
default:
res.json(base);
}
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to fetch bootstrap job";
res.status(500).json({ error: message });
}
});
export default router;

View File

@@ -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);

View File

@@ -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<BootstrapJobState>().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;

View File

@@ -2,3 +2,4 @@ export * from "./jobs";
export * from "./invoices";
export * from "./conversations";
export * from "./messages";
export * from "./bootstrap-jobs";

View File

@@ -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/<jobId>"
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/<hash>…"}
# 2. (Stub mode) Simulate payment
curl -s -X POST "$BASE/api/dev/stub/pay/<paymentHash>"
# 3. Poll status — transitions: awaiting_payment → provisioning → ready
curl -s "$BASE/api/bootstrap/<bootstrapJobId>"
# 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 12 weeks; `ready` means provisioned + bootstrapped, not fully synced
#### Free demo endpoint (rate-limited: 5 req/hour per IP)
```bash