Files
timmy-tower/replit.md

16 KiB
Raw Blame History

Workspace

Overview

pnpm workspace monorepo using TypeScript. Each package manages its own dependencies.

Stack

  • Monorepo tool: pnpm workspaces
  • Node.js version: 24
  • Package manager: pnpm
  • TypeScript version: 5.9
  • API framework: Express 5
  • Database: PostgreSQL + Drizzle ORM
  • Validation: Zod (zod/v4), drizzle-zod
  • API codegen: Orval (from OpenAPI spec)
  • Build: esbuild (CJS bundle)

Structure

artifacts-monorepo/
├── artifacts/              # Deployable applications
│   └── api-server/         # Express API server
├── lib/                    # Shared libraries
│   ├── api-spec/           # OpenAPI spec + Orval codegen config
│   ├── api-client-react/   # Generated React Query hooks
│   ├── api-zod/            # Generated Zod schemas from OpenAPI
│   └── db/                 # Drizzle ORM schema + DB connection
├── scripts/                # Utility scripts (single workspace package)
│   └── src/                # Individual .ts scripts, run via `pnpm --filter @workspace/scripts run <script>`
├── pnpm-workspace.yaml     # pnpm workspace (artifacts/*, lib/*, lib/integrations/*, scripts)
├── tsconfig.base.json      # Shared TS options (composite, bundler resolution, es2022)
├── tsconfig.json           # Root TS project references
└── package.json            # Root package with hoisted devDeps

TypeScript & Composite Projects

Every package extends tsconfig.base.json which sets composite: true. The root tsconfig.json lists all packages as project references. This means:

  • Always typecheck from the root — run pnpm run typecheck (which runs tsc --build --emitDeclarationOnly). This builds the full dependency graph so that cross-package imports resolve correctly. Running tsc inside a single package will fail if its dependencies haven't been built yet.
  • emitDeclarationOnly — we only emit .d.ts files during typecheck; actual JS bundling is handled by esbuild/tsx/vite...etc, not tsc.
  • Project references — when package A depends on package B, A's tsconfig.json must list B in its references array. tsc --build uses this to determine build order and skip up-to-date packages.

Root Scripts

  • pnpm run build — runs typecheck first, then recursively runs build in all packages that define it
  • pnpm run typecheck — runs tsc --build --emitDeclarationOnly using project references

Environment Variables & Secrets

Automatically provisioned (do not set manually)

Secret Purpose
AI_INTEGRATIONS_ANTHROPIC_BASE_URL Replit AI Integrations proxy base URL for Anthropic
AI_INTEGRATIONS_ANTHROPIC_API_KEY Replit AI Integrations proxy API key (dummy value, auto-managed)
DATABASE_URL PostgreSQL connection string (Replit-managed)
SESSION_SECRET Express session secret (Replit-managed)

Required secrets (set via Replit Secrets tab)

Secret Description Example
LNBITS_URL Base URL of your LNbits instance https://legend.lnbits.com
LNBITS_API_KEY Invoice/Admin API key from your LNbits wallet a3f...

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.

Cost-based work fee pricing

Secret Description Default
HAIKU_INPUT_COST_PER_1K_TOKENS Haiku input cost per 1K tokens (USD) 0.0008
HAIKU_OUTPUT_COST_PER_1K_TOKENS Haiku output cost per 1K tokens (USD) 0.004
SONNET_INPUT_COST_PER_1K_TOKENS Sonnet input cost per 1K tokens (USD) 0.003
SONNET_OUTPUT_COST_PER_1K_TOKENS Sonnet output cost per 1K tokens (USD) 0.015
DO_MONTHLY_COST_USD Monthly DO infra cost amortised per request 100
DO_MONTHLY_REQUEST_VOLUME Expected monthly request volume (divisor) 1000
ORIGINATOR_MARGIN_PCT Margin percentage on top of cost 25
OUTPUT_TOKENS_SHORT_EST Estimated output tokens for short requests 200
OUTPUT_TOKENS_MEDIUM_EST Estimated output tokens for medium requests 400
OUTPUT_TOKENS_LONG_EST Estimated output tokens for long requests 800
WORK_SYSTEM_PROMPT_TOKENS_EST Work model system-prompt size in tokens 50
SHORT_MAX_CHARS Max chars for "short" request tier 100
MEDIUM_MAX_CHARS Max chars for "medium" request tier 300
EVAL_FEE_SATS Fixed eval invoice amount 10
BTC_PRICE_USD_FALLBACK BTC/USD price fallback if CoinGecko is unreachable 100000
EVAL_MODEL Anthropic model used for evaluation claude-haiku-4-5
WORK_MODEL Anthropic model used for work execution claude-sonnet-4-6

Work fee flow: estimate tokens → fetch BTC price from CoinGecko (60s cache) → (token_cost + DO_infra) × (1 + margin%) → convert USD → sats. After work runs, actual token counts and raw Anthropic spend are stored in jobs as actual_input_tokens, actual_output_tokens, actual_cost_usd.

Honest accounting and automatic refunds

After every job completes, Timmy re-prices the job using actual token counts, adds DO infra amortisation and margin, converts back to sats using the same BTC price locked at invoice time, and computes the overpayment:

actual_amount_sats = ceil((actual_cost_usd + DO_infra) × (1 + margin%) / btc_price_usd × 1e8)
refund_amount_sats = max(0, work_amount_sats - actual_amount_sats)

The costLedger in GET /api/jobs/:id shows all figures side-by-side. If refundState = "pending" and refundAmountSats > 0, the user submits a BOLT11 invoice for that exact amount to POST /api/jobs/:id/refund to receive the Lightning payment. The endpoint is idempotent — subsequent calls return 409 with the original refundPaymentHash.

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)

Express 5 API server. Routes live in src/routes/ and use @workspace/api-zod for request and response validation and @workspace/db for persistence.

  • Entry: src/index.ts — reads PORT, starts Express
  • App setup: src/app.ts — mounts CORS, JSON/urlencoded parsing, routes at /api
  • Routes: src/routes/index.ts mounts sub-routers; src/routes/health.ts exposes GET /health (full path: /api/health)
  • Depends on: @workspace/db, @workspace/api-zod
  • pnpm --filter @workspace/api-server run dev — run the dev server
  • pnpm --filter @workspace/api-server run build — production esbuild bundle (dist/index.cjs)
  • Build bundles an allowlist of deps (express, cors, pg, drizzle-orm, zod, etc.) and externalizes the rest

lib/db (@workspace/db)

Database layer using Drizzle ORM with PostgreSQL. Exports a Drizzle client instance and schema models.

  • src/index.ts — creates a Pool + Drizzle instance, exports schema
  • src/schema/index.ts — barrel re-export of all models
  • src/schema/<modelname>.ts — table definitions with drizzle-zod insert schemas (no models definitions exist right now)
  • drizzle.config.ts — Drizzle Kit config (requires DATABASE_URL, automatically provided by Replit)
  • Exports: . (pool, db, schema), ./schema (schema only)

Production migrations are handled by Replit when publishing. In development, we just use pnpm --filter @workspace/db run push, and we fallback to pnpm --filter @workspace/db run push-force.

lib/api-spec (@workspace/api-spec)

Owns the OpenAPI 3.1 spec (openapi.yaml) and the Orval config (orval.config.ts). Running codegen produces output into two sibling packages:

  1. lib/api-client-react/src/generated/ — React Query hooks + fetch client
  2. lib/api-zod/src/generated/ — Zod schemas

Run codegen: pnpm --filter @workspace/api-spec run codegen

lib/api-zod (@workspace/api-zod)

Generated Zod schemas from the OpenAPI spec (e.g. HealthCheckResponse). Used by api-server for response validation.

lib/api-client-react (@workspace/api-client-react)

Generated React Query hooks and fetch client from the OpenAPI spec (e.g. useHealthCheck, healthCheck).

artifacts/api-server — Timmy API endpoints

Payment-gated job flow

BASE="https://${REPLIT_DEV_DOMAIN}"  # or http://localhost:8080 in dev

# 1. Create a job (returns eval invoice)
curl -s -X POST "$BASE/api/jobs" \
  -H "Content-Type: application/json" \
  -d '{"request": "Write a haiku about lightning payments"}'
# → {"jobId":"…","evalInvoice":{"paymentRequest":"lnbcrt10u1…","amountSats":10}}

# 2. Poll status (returns eval invoice while unpaid)
curl -s "$BASE/api/jobs/<jobId>"

# 3. (Stub mode only) Mark eval invoice paid
curl -s -X POST "$BASE/api/dev/stub/pay/<paymentHash>"

# 4. Poll again — auto-advances to awaiting_work_payment, returns work invoice
curl -s "$BASE/api/jobs/<jobId>"

# 5. (Stub mode only) Mark work invoice paid
curl -s -X POST "$BASE/api/dev/stub/pay/<workPaymentHash>"

# 6. Poll again — auto-advances to complete, returns result
curl -s "$BASE/api/jobs/<jobId>"
# → {"jobId":"…","state":"complete","result":"…"}

Job states: awaiting_eval_paymentevaluatingawaiting_work_paymentexecutingcomplete | rejected | failed

Lightning-gated node bootstrap

Pay a one-time startup fee → Timmy auto-provisions a Bitcoin full node on Digital Ocean.

# 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_paymentprovisioningready | 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)

curl -s "$BASE/api/demo?request=Explain+proof+of+work+in+one+sentence"
# → {"result":"…"}

Dev-only stub payment trigger

POST /api/dev/stub/pay/:paymentHash — marks a stub invoice paid in-memory. Only available in development (NODE_ENV !== 'production').

scripts (@workspace/scripts)

Utility scripts package. Each script is a .ts file in src/ with a corresponding npm script in package.json. Run scripts via pnpm --filter @workspace/scripts run <script>. Scripts can import any workspace package (e.g., @workspace/db) by adding it as a dependency in scripts/package.json.

Pre-funded session mode (Mode 2)

Pay once, run many requests. Balance is debited at actual cost per request — no per-job invoices.

BASE="http://localhost:8080"

# 1. Create a session (returns deposit invoice)
curl -s -X POST "$BASE/api/sessions" \
  -H "Content-Type: application/json" \
  -d '{"amount_sats": 500}'
# → {"sessionId":"…","state":"awaiting_payment","invoice":{"paymentRequest":"…","amountSats":500,"paymentHash":"…"}}

# 2. (Stub mode only) Pay deposit
curl -s -X POST "$BASE/api/dev/stub/pay/<paymentHash>"

# 3. Poll session — auto-advances to active, issues macaroon
curl -s "$BASE/api/sessions/<sessionId>"
# → {"sessionId":"…","state":"active","balanceSats":500,"macaroon":"…","minimumBalanceSats":50}

# 4. Submit requests (use macaroon as Bearer token)
curl -s -X POST "$BASE/api/sessions/<sessionId>/request" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer <macaroon>" \
  -d '{"request":"What is a satoshi?"}'
# → {"requestId":"…","state":"complete","result":"…","debitedSats":178,"balanceRemaining":322}

# 5. Top up when balance is low (session auto-pauses below 50 sats)
curl -s -X POST "$BASE/api/sessions/<sessionId>/topup" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer <macaroon>" \
  -d '{"amount_sats": 500}'

Session states: awaiting_paymentactivepaused (low balance) → expired (24h TTL, refreshed on each request)

Key properties:

  • No per-job invoices — balance is debited at actual compute cost (eval + work tokens + DO infra + margin)
  • Rejected requests still incur a small eval fee (Haiku model only)
  • Macaroon auth — 32-byte hex token issued on activation; required as Authorization: Bearer header
  • Pause on low balance — session auto-pauses when balance < 50 sats; pay topup invoice to resume
  • TTL — sessions expire 24 hours after last successful request
  • Deposit limits — 10010,000 sats; env vars: SESSION_MIN_DEPOSIT_SATS, SESSION_MAX_DEPOSIT_SATS, SESSION_MIN_BALANCE_SATS, SESSION_EXPIRY_HOURS

DB tables: sessions (state machine, balance, macaroon), session_requests (per-request token + cost accounting)

Pushing to Gitea

Gitea runs on the local Mac behind a bore tunnel. The bore port changes every session.

One-time setup

bash scripts/push-to-gitea.sh <PORT>   # saves port to .bore-port, then pushes

Every subsequent push this session

bash scripts/push-to-gitea.sh          # reads port from .bore-port automatically

When bore restarts (new port)

Bore assigns a new random port on each restart. You must pass it once — after that .bore-port remembers it:

bash scripts/push-to-gitea.sh <NEW_PORT>   # overwrites .bore-port, then pushes

How to find the bore port: The port is shown in the Mac terminal where bore is running:

bore local 3000 --to bore.pub
→ "listening at bore.pub:NNNNN"

Credentials (GITEA_TOKEN): The script never hard-codes a token. Set it one of two ways:

# Option A — env var (add to shell profile for persistence)
export GITEA_TOKEN=<your-token>

# Option B — gitignored credentials file (one-time setup)
echo <your-token> > .gitea-credentials

Get your token from Gitea → User Settings → Applications → Generate Token.

Rules:

  • Always create a branch and open a PR — never push directly to main (Gitea enforces this)
  • The .bore-port and .gitea-credentials files are gitignored — never committed

Gitea repos

  • replit/token-gated-economy — TypeScript API server (this repo)
  • perplexity/the-matrix — Three.js 3D world frontend

Roadmap

Nostr integration

Nostr (NIP-04/NIP-44 encrypted DMs) is planned as the delivery layer for the bootstrap flow and beyond:

  • Node credential delivery — provisioned node generates a Nostr keypair during cloud-init and publishes credentials as an encrypted DM to the user's pubkey; eliminates the current HTTP one-time SSH key polling mechanism
  • Job status events — job state transitions (awaiting_payment → complete) published as Nostr events; clients subscribe instead of polling
  • Node identity — each Timmy node gets a persistent Nostr identity (npub) for discovery and communication

Until Nostr is wired in, the current HTTP polling + one-time SSH key delivery serves as the POC placeholder.