350 lines
16 KiB
Markdown
350 lines
16 KiB
Markdown
# 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) |
|
||
| `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
|
||
|
||
## 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.
|