# 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

```text
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) |
| `AI_INTEGRATIONS_GEMINI_BASE_URL` | Replit AI Integrations proxy base URL for Gemini (`localhost:1106/...`) |
| `AI_INTEGRATIONS_GEMINI_API_KEY` | Replit AI Integrations proxy key for Gemini (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

```bash
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_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 1–2 weeks; `ready` means provisioned + bootstrapped, not fully synced

#### Free demo endpoint (rate-limited: 5 req/hour per IP)

```bash
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.

```bash
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_payment` → `active` ⇄ `paused` (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** — 100–10,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
bash scripts/push-to-gitea.sh <PORT>   # saves port to .bore-port, then pushes
```

### Every subsequent push this session
```bash
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
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:

```bash
# 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

## Deployment

### Canonical deployment config — artifact.toml (not .replit)

The API server's authoritative deployment configuration lives in
`artifacts/api-server/.replit-artifact/artifact.toml`. This file controls the
production build command and the run command for the always-on VM deployment.

```
deploymentTarget = "vm"
buildCommand     = "pnpm --filter @workspace/api-server run build"
runCommand       = "node artifacts/api-server/dist/index.js"
```

The root `.replit` file may show an older `deploymentTarget = "autoscale"` and
`run = "dist/index.cjs"` — these are legacy entries left from when Replit
platform protection blocked agent edits. **artifact.toml is the source of
truth**; `.replit` entries for this artifact should be ignored.

### Hermes Gitea (backup / deployment source)

All workspace code is mirrored to a self-hosted Gitea instance on hermes backed by PostgreSQL.
This is the second git remote — independent of the Mac Tailscale Gitea — so no single machine holds all the code.

| Item | Value |
|---|---|
| Web UI | `http://143.198.27.163:3000/admin/timmy-tower` |
| SSH (git) | `ssh://git@143.198.27.163:2222` |
| DB backend | PostgreSQL (`gitea` DB on hermes) |
| Admin user | `admin` |
| Token store | `/root/.gitea-replit-token` on VPS |

**Push from Replit** (any session):
```bash
bash scripts/push-to-hermes.sh
```
This script fetches the API token from the VPS over SSH (never stored in git), adds the `hermes` remote, and pushes all branches + tags.

**Postgres backup** — the Gitea metadata lives in the `gitea` PostgreSQL DB. Backup with:
```bash
# On hermes
sudo -u postgres pg_dump gitea > /root/gitea-backup-$(date +%Y%m%d).sql
```
The bare git objects live in `/var/lib/gitea/repositories/` and can be backed up with rsync or tar.

### VPS deployment (hermes — 143.198.27.163)

The production instance runs on the user's VPS via systemd, outside Replit:

| Item | Value |
|---|---|
| URL | `http://143.198.27.163/` |
| Service | `systemctl status timmy-tower` |
| Deploy dir | `/opt/timmy-tower/` |
| Env file | `/opt/timmy-tower/.env` |
| DB | `postgres://timmy:...@localhost:5432/timmy_tower` |
| Nostr npub | `npub1e3gu2j08t6hymjd5sz9dmy4u5pcl22mj5hl60avkpj5tdpaq3dasjax6tv` |
| AI backend | OpenRouter (`https://openrouter.ai/api/v1`) via Anthropic SDK compat layer |

To redeploy after a build:
```bash
# From Replit — rebuild and copy bundle
pnpm --filter @workspace/api-server run build
cat artifacts/api-server/dist/index.js | ssh root@143.198.27.163 "cat > /opt/timmy-tower/index.js"
ssh root@143.198.27.163 "systemctl restart timmy-tower"
```

External packages that must be present in `/opt/timmy-tower/node_modules/`:
- `nostr-tools` (^2.23.3)
- `cookie-parser` (^1.4.7)

These are externalized by esbuild (not in the allowlist in `build.ts`).

## 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.
