Files
timmy-tower/artifacts/api-server/src/routes/index.ts

56 lines
2.1 KiB
TypeScript
Raw Normal View History

2026-03-13 23:21:55 +00:00
import { Router, type IRouter } from "express";
Task #3: MVP API — payment-gated jobs + demo endpoint OpenAPI spec (lib/api-spec/openapi.yaml) - Added POST /jobs, GET /jobs/{id}, GET /demo endpoints - Added schemas: CreateJobRequest, CreateJobResponse, JobStatusResponse, InvoiceInfo, JobState, DemoResponse, ErrorResponse - Ran codegen: generated CreateJobBody, GetJobResponse, RunDemoQueryParams etc. Jobs router (artifacts/api-server/src/routes/jobs.ts) - POST /jobs: validates body, creates LNbits eval invoice, inserts job + invoice in a DB transaction, returns { jobId, evalInvoice } - GET /jobs/:id: fetches job, calls advanceJob() helper, returns state- appropriate payload (eval/work invoice, reason, result, errorMessage) - advanceJob() state machine: - awaiting_eval_payment: checks LNbits, atomically marks paid + advances state via optimistic WHERE state='awaiting_eval_payment'; runs AgentService.evaluateRequest, branches to awaiting_work_payment or rejected - awaiting_work_payment: same pattern for work invoice, runs AgentService.executeWork, advances to complete - Any agent/LNbits error transitions job to failed Demo router (artifacts/api-server/src/routes/demo.ts) - GET /demo?request=...: in-memory rate limiter (5 req/hour per IP) - Explicit guard for missing request param (coerce.string() workaround) - Calls AgentService.executeWork directly, returns { result } Dev router (artifacts/api-server/src/routes/dev.ts) - POST /dev/stub/pay/:paymentHash: marks stub invoice paid in-memory - Only mounted when NODE_ENV !== 'production' Route index updated to mount all three routers replit.md: documented full curl flow with all 6 steps, demo endpoint, and dev stub-pay trigger End-to-end verified with curl: - Full flow: create → eval pay → evaluating → work pay → executing → complete - Error cases: 400 on missing body/param, 404 on unknown job
2026-03-18 15:31:26 +00:00
import healthRouter from "./health.js";
import jobsRouter from "./jobs.js";
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/: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
2026-03-18 18:47:48 +00:00
import bootstrapRouter from "./bootstrap.js";
import sessionsRouter from "./sessions.js";
Task #3: MVP API — payment-gated jobs + demo endpoint OpenAPI spec (lib/api-spec/openapi.yaml) - Added POST /jobs, GET /jobs/{id}, GET /demo endpoints - Added schemas: CreateJobRequest, CreateJobResponse, JobStatusResponse, InvoiceInfo, JobState, DemoResponse, ErrorResponse - Ran codegen: generated CreateJobBody, GetJobResponse, RunDemoQueryParams etc. Jobs router (artifacts/api-server/src/routes/jobs.ts) - POST /jobs: validates body, creates LNbits eval invoice, inserts job + invoice in a DB transaction, returns { jobId, evalInvoice } - GET /jobs/:id: fetches job, calls advanceJob() helper, returns state- appropriate payload (eval/work invoice, reason, result, errorMessage) - advanceJob() state machine: - awaiting_eval_payment: checks LNbits, atomically marks paid + advances state via optimistic WHERE state='awaiting_eval_payment'; runs AgentService.evaluateRequest, branches to awaiting_work_payment or rejected - awaiting_work_payment: same pattern for work invoice, runs AgentService.executeWork, advances to complete - Any agent/LNbits error transitions job to failed Demo router (artifacts/api-server/src/routes/demo.ts) - GET /demo?request=...: in-memory rate limiter (5 req/hour per IP) - Explicit guard for missing request param (coerce.string() workaround) - Calls AgentService.executeWork directly, returns { result } Dev router (artifacts/api-server/src/routes/dev.ts) - POST /dev/stub/pay/:paymentHash: marks stub invoice paid in-memory - Only mounted when NODE_ENV !== 'production' Route index updated to mount all three routers replit.md: documented full curl flow with all 6 steps, demo endpoint, and dev stub-pay trigger End-to-end verified with curl: - Full flow: create → eval pay → evaluating → work pay → executing → complete - Error cases: 400 on missing body/param, 404 on unknown job
2026-03-18 15:31:26 +00:00
import demoRouter from "./demo.js";
import devRouter from "./dev.js";
import testkitRouter from "./testkit.js";
Task #45: Deploy API server — VM deployment, production build, testkit PASS=40/41 FAIL=0 ## What was done 1. **TIMMY_NOSTR_NSEC secret set** — Generated a permanent Nostr keypair and the user set it as a Replit secret. Timmy's identity is now stable across restarts: npub1e3gu2j08t6hymjd5sz9dmy4u5pcl22mj5hl60avkpj5tdpaq3dasjax6tv 2. **app.ts — Fixed import.meta.url for CJS production bundle** esbuild CJS bundles set import.meta={} (empty), crashing the Tower static path resolution. Fixed with try/catch: ESM dev mode uses import.meta.url (3 levels up from src/); CJS prod bundle falls back to process.cwd() + "the-matrix/dist" (workspace root assumption correct since run command is issued from workspace root). 3. **routes/index.ts — Stub-mode-aware dev route gating** Changed condition from `NODE_ENV !== "production"` to `NODE_ENV !== "production" || lnbitsService.stubMode`. The testkit relies on POST /dev/stub/pay/:hash to simulate Lightning payments. Previously this endpoint was hidden in production even when LNbits was in stub mode, causing FAIL=5. Now: real production with real LNbits → stubMode=false → dev routes unexposed (secure). Production bundle with stub LNbits → stubMode=true → dev routes exposed → testkit passes. 4. **artifact.toml — deploymentTarget = "vm"** set so the artifact deploys always-on. The .replit file cannot be edited directly via available APIs; artifact.toml takes precedence for this artifact's deployment configuration. 5. **Production bundle rebuilt** (dist/index.cjs, 1.6MB) — clean build, no warnings. 6. **Full testkit against production bundle in stub mode: PASS=40/41 FAIL=0 SKIP=1** (SKIP=1 is the Nostr challenge/sign/verify test which requires nostr-tools in bash, same baseline as dev mode). ## Deployment command Build: pnpm --filter @workspace/api-server run build Run: node artifacts/api-server/dist/index.cjs Health: /api/healthz Target: VM (always-on) — required for WebSocket connections and in-memory world state.
2026-03-20 01:29:50 +00:00
import { lnbitsService } from "../lib/lnbits.js";
import uiRouter from "./ui.js";
import nodeDiagnosticsRouter from "./node-diagnostics.js";
import metricsRouter from "./metrics.js";
import worldRouter from "./world.js";
import identityRouter from "./identity.js";
Task #27: Cost-routing + free-tier gate ## What was built ### DB schema - `timmy_config` table: key/value store for the generosity pool balance - `free_tier_grants` table: immutable audit log of every Timmy-absorbed request - `jobs.free_tier` (boolean) + `jobs.absorbed_sats` (integer) columns ### FreeTierService (`lib/free-tier.ts`) - Per-tier daily sats budgets (new=0, established=50, trusted=200, elite=1000) — all env-var overridable - `decide(pubkey, estimatedSats)` → `{ serve: free|partial|gate, absorbSats, chargeSats }` — checks pool balance AND identity daily budget atomically - `credit(paidSats)` — credits POOL_CREDIT_PCT (default 10%) of every paid work invoice back to the generosity pool - `recordGrant(pubkey, reqHash, absorbSats)` — DB transaction: deducts pool, updates identity daily absorption counter, writes audit row - `poolStatus()` — snapshot for metrics/monitoring ### Route integration - `POST /api/jobs` (eval → work flow): after eval passes, `freeTierService.decide()` intercepts. Free → skip invoice, fire work directly. Partial → discounted invoice. Gate (anonymous/new tier/pool empty) → unchanged full-price flow. - `POST /api/sessions/:id/request`: after compute, free-tier discount applied to balance debit. Session balance only reduced by `chargeSats`; absorbed portion comes from pool. - Pool credited on every paid work completion (both jobs and session paths). - Response fields: `free_tier: true`, `absorbed_sats: N` when applicable. ### GET /api/estimate - Lightweight pre-flight cost estimator; no payment required - Returns: estimatedSats, btcPriceUsd, tokenEstimate, identity.free_tier decision (if valid nostr_token provided), pool.balanceSats, pool.dailyBudgets ### Tests - All 29 existing testkit tests pass (0 failures) - Anonymous/new-tier users hit gate path correctly (verified manually) - Pool seeds to 10,000 sats on first boot ## Architecture notes - Free tier decision happens BEFORE invoice creation for jobs (save user the click) - Partial grant recorded at invoice creation time (reserves pool capacity proactively) - Free tier for sessions decided AFTER compute (actual cost known, applied to debit) - Pool crediting is fire-and-forget (non-blocking)
2026-03-19 16:34:05 +00:00
import estimateRouter from "./estimate.js";
task/30: Sovereign Nostr relay infrastructure (strfry) ## Summary Deploys strfry (C++ Nostr relay) + relay-policy sidecar as a containerised stack on the VPS, wired to the API server for event-level access control. ## Files created - `infrastructure/strfry.conf` — strfry config: bind 0.0.0.0:7777, writePolicy plugin → /usr/local/bin/relay-policy-plugin, maxEventSize 65536, rejectEphemeral false, db /data/strfry-db - `infrastructure/relay-policy/plugin.sh` — strfry write-policy plugin (stdin/stdout bridge). Reads JSON lines from strfry, POSTs to relay-policy HTTP sidecar (http://relay-policy:3080/decide), writes decision to stdout. Safe fallback: reject on sidecar timeout/failure - `infrastructure/relay-policy/index.ts` — Node.js HTTP relay-policy sidecar: POST /decide receives strfry events, calls API server /api/relay/policy with Bearer RELAY_POLICY_SECRET, returns strfry decision JSON - `infrastructure/relay-policy/package.json + tsconfig.json` — TS build config - `infrastructure/relay-policy/Dockerfile` — multi-stage: builder (tsc) + runtime - `infrastructure/relay-policy/.gitignore` — excludes node_modules, dist - `artifacts/api-server/src/routes/relay.ts` — POST /api/relay/policy: internal route protected by RELAY_POLICY_SECRET Bearer token. Bootstrap state: rejects all events with "relay not yet open — whitelist pending (Task #37)". Stable contract — future tasks extend evaluatePolicy() without API shape changes ## Files modified - `infrastructure/docker-compose.yml` — adds relay-policy + strfry services on node-net; strfry_data volume (bind-mounted at /data/strfry); relay-policy healthcheck; strfry depends on relay-policy healthy - `infrastructure/ops.sh` — adds relay:logs, relay:restart, relay:status commands - `artifacts/api-server/src/routes/index.ts` — registers relayRouter ## Operator setup required on VPS mkdir -p /data/strfry && chmod 700 /data/strfry echo "RELAY_API_URL=https://alexanderwhitestone.com" >> /opt/timmy-node/.env echo "RELAY_POLICY_SECRET=$(openssl rand -hex 32)" >> /opt/timmy-node/.env # Also set RELAY_POLICY_SECRET in Replit secrets for API server ## Notes - TypeScript: 0 errors (API server + relay-policy sidecar both compile clean) - POST /api/relay/policy smoke test: correct bootstrap reject response - strfry image: ghcr.io/hoytech/strfry:latest
2026-03-19 20:02:00 +00:00
import relayRouter from "./relay.js";
task/31: Relay account whitelist + trust-gated access ## What was built Full relay access control system: relay_accounts table, RelayAccountService, trust hook integration, live policy enforcement, admin CRUD API, Timmy seed. ## DB change `lib/db/src/schema/relay-accounts.ts` — new `relay_accounts` table: pubkey (PK, FK → nostr_identities.pubkey ON DELETE CASCADE), access_level ("none"|"read"|"write"), granted_by ("manual"|"auto-tier"), granted_at, revoked_at (nullable), notes. Pushed via `pnpm run push`. `lib/db/src/schema/index.ts` — exports relay-accounts. ## RelayAccountService (`artifacts/api-server/src/lib/relay-accounts.ts`) - getAccess(pubkey) → RelayAccessLevel (none if missing or revoked) - grant(pubkey, level, reason, grantedBy) — upsert; creates nostr_identity FK - revoke(pubkey, reason) — sets revokedAt, access_level → none - syncFromTrustTier(pubkey, tier) — auto-promotes by tier; never downgrades manual grants - list(opts) — returns all accounts, optionally filtered to active - Tier→access map: new=none, established/trusted/elite=write (env-overridable) ## Trust hook (`artifacts/api-server/src/lib/trust.ts`) recordSuccess + recordFailure both call syncFromTrustTier after writing tier. Failure is caught + logged (non-blocking — trust flow never fails on relay error). ## Policy endpoint (`artifacts/api-server/src/routes/relay.ts`) evaluatePolicy() now async: queries relay_accounts.getAccess(pubkey). "write" → accept; "read"/"none"/missing → reject with clear message. DB error → reject with "policy service error" (safe fail-closed). ## Admin routes (`artifacts/api-server/src/routes/admin-relay.ts`) ADMIN_SECRET Bearer token auth (localhost-only fallback in dev; error log in prod). GET /api/admin/relay/accounts — list all accounts POST /api/admin/relay/accounts/:pk/grant — grant (level + notes body) POST /api/admin/relay/accounts/:pk/revoke — revoke (reason body) pubkey validation: must be 64-char lowercase hex. ## Startup seed (`artifacts/api-server/src/index.ts`) On every startup: grants Timmy's own pubkeyHex "write" access ("manual"). Idempotent upsert — safe across restarts. ## Smoke test results (all pass) - Timmy pubkey → accept ✓; unknown pubkey → reject ✓ - Admin grant → accept ✓; admin revoke → reject ✓; admin list shows accounts ✓ - TypeScript: 0 errors in API server + lib/db
2026-03-19 20:21:12 +00:00
import adminRelayRouter from "./admin-relay.js";
task/32: Event moderation queue + Timmy AI review ## What was built Full moderation pipeline: relay_event_queue table, strfry inject helper, ModerationService with Claude haiku review, policy tier routing, 30s poll loop, admin approve/reject/list endpoints. ## DB schema (`lib/db/src/schema/relay-event-queue.ts`) relay_event_queue: event_id (PK), pubkey (FK → nostr_identities), kind, raw_event (text JSON), status (pending/approved/rejected/auto_approved), reviewed_by (timmy_ai/admin/null), review_reason, created_at, decided_at. Exported from schema/index.ts. Pushed via pnpm run push. ## strfry HTTP client (`artifacts/api-server/src/lib/strfry.ts`) injectEvent(rawEventJson) — POST {STRFRY_URL}/import (NDJSON). STRFRY_URL defaults to "http://strfry:7777" (Docker internal network). 5s timeout; graceful failure in dev when strfry not running; never throws. ## ModerationService (`artifacts/api-server/src/lib/moderation.ts`) - enqueue(event) — insert pending row; idempotent onConflictDoNothing - autoReview(eventId) — Claude haiku prompt: approve or flag. On flag, marks reviewedBy=timmy_ai and leaves pending for admin. On approve, calls decide(). - decide(eventId, status, reason, reviewedBy) — updates DB + calls injectEvent - processPending(limit=10) — batch poll: auto-review up to limit pending events - Stub mode: auto-approves all events when Anthropic key absent ## Policy endpoint update (`artifacts/api-server/src/routes/relay.ts`) Tier routing in evaluatePolicy: read/none → reject (unchanged) write + elite tier → injectEvent + accept (elite bypass; shadowReject if inject fails) write + non-elite → enqueue + shadowReject (held for moderation) Imports db/nostrIdentities directly for tier check. Both inject and enqueue errors are fail-closed (reject vs shadowReject respectively). ## Background poll loop (`artifacts/api-server/src/index.ts`) setInterval every 30s calling moderationService.processPending(10). Interval configurable via MODERATION_POLL_MS env var. Errors caught per-event; poll loop never crashes the server. ## Admin queue routes (`artifacts/api-server/src/routes/admin-relay-queue.ts`) ADMIN_SECRET Bearer auth (same pattern as admin-relay.ts). GET /api/admin/relay/queue?status=... — list all / by status POST /api/admin/relay/queue/:eventId/approve — approve + inject into strfry POST /api/admin/relay/queue/:eventId/reject — reject (no inject) 409 on duplicate decisions. Registered in routes/index.ts. ## Smoke tests (all pass) Unknown → reject ✓; elite → shadowReject (strfry unavailable in dev) ✓; non-elite write → shadowReject + pending in queue ✓; admin approve → approved ✓; moderation poll loop started ✓; TypeScript 0 errors.
2026-03-19 20:35:39 +00:00
import adminRelayQueueRouter from "./admin-relay-queue.js";
import geminiRouter from "./gemini.js";
2026-03-13 23:21:55 +00:00
const router: IRouter = Router();
router.use(healthRouter);
router.use(metricsRouter);
Task #3: MVP API — payment-gated jobs + demo endpoint OpenAPI spec (lib/api-spec/openapi.yaml) - Added POST /jobs, GET /jobs/{id}, GET /demo endpoints - Added schemas: CreateJobRequest, CreateJobResponse, JobStatusResponse, InvoiceInfo, JobState, DemoResponse, ErrorResponse - Ran codegen: generated CreateJobBody, GetJobResponse, RunDemoQueryParams etc. Jobs router (artifacts/api-server/src/routes/jobs.ts) - POST /jobs: validates body, creates LNbits eval invoice, inserts job + invoice in a DB transaction, returns { jobId, evalInvoice } - GET /jobs/:id: fetches job, calls advanceJob() helper, returns state- appropriate payload (eval/work invoice, reason, result, errorMessage) - advanceJob() state machine: - awaiting_eval_payment: checks LNbits, atomically marks paid + advances state via optimistic WHERE state='awaiting_eval_payment'; runs AgentService.evaluateRequest, branches to awaiting_work_payment or rejected - awaiting_work_payment: same pattern for work invoice, runs AgentService.executeWork, advances to complete - Any agent/LNbits error transitions job to failed Demo router (artifacts/api-server/src/routes/demo.ts) - GET /demo?request=...: in-memory rate limiter (5 req/hour per IP) - Explicit guard for missing request param (coerce.string() workaround) - Calls AgentService.executeWork directly, returns { result } Dev router (artifacts/api-server/src/routes/dev.ts) - POST /dev/stub/pay/:paymentHash: marks stub invoice paid in-memory - Only mounted when NODE_ENV !== 'production' Route index updated to mount all three routers replit.md: documented full curl flow with all 6 steps, demo endpoint, and dev stub-pay trigger End-to-end verified with curl: - Full flow: create → eval pay → evaluating → work pay → executing → complete - Error cases: 400 on missing body/param, 404 on unknown job
2026-03-18 15:31:26 +00:00
router.use(jobsRouter);
Task #27: Cost-routing + free-tier gate ## What was built ### DB schema - `timmy_config` table: key/value store for the generosity pool balance - `free_tier_grants` table: immutable audit log of every Timmy-absorbed request - `jobs.free_tier` (boolean) + `jobs.absorbed_sats` (integer) columns ### FreeTierService (`lib/free-tier.ts`) - Per-tier daily sats budgets (new=0, established=50, trusted=200, elite=1000) — all env-var overridable - `decide(pubkey, estimatedSats)` → `{ serve: free|partial|gate, absorbSats, chargeSats }` — checks pool balance AND identity daily budget atomically - `credit(paidSats)` — credits POOL_CREDIT_PCT (default 10%) of every paid work invoice back to the generosity pool - `recordGrant(pubkey, reqHash, absorbSats)` — DB transaction: deducts pool, updates identity daily absorption counter, writes audit row - `poolStatus()` — snapshot for metrics/monitoring ### Route integration - `POST /api/jobs` (eval → work flow): after eval passes, `freeTierService.decide()` intercepts. Free → skip invoice, fire work directly. Partial → discounted invoice. Gate (anonymous/new tier/pool empty) → unchanged full-price flow. - `POST /api/sessions/:id/request`: after compute, free-tier discount applied to balance debit. Session balance only reduced by `chargeSats`; absorbed portion comes from pool. - Pool credited on every paid work completion (both jobs and session paths). - Response fields: `free_tier: true`, `absorbed_sats: N` when applicable. ### GET /api/estimate - Lightweight pre-flight cost estimator; no payment required - Returns: estimatedSats, btcPriceUsd, tokenEstimate, identity.free_tier decision (if valid nostr_token provided), pool.balanceSats, pool.dailyBudgets ### Tests - All 29 existing testkit tests pass (0 failures) - Anonymous/new-tier users hit gate path correctly (verified manually) - Pool seeds to 10,000 sats on first boot ## Architecture notes - Free tier decision happens BEFORE invoice creation for jobs (save user the click) - Partial grant recorded at invoice creation time (reserves pool capacity proactively) - Free tier for sessions decided AFTER compute (actual cost known, applied to debit) - Pool crediting is fire-and-forget (non-blocking)
2026-03-19 16:34:05 +00:00
router.use(estimateRouter);
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/: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
2026-03-18 18:47:48 +00:00
router.use(bootstrapRouter);
router.use(sessionsRouter);
router.use(identityRouter);
task/30: Sovereign Nostr relay infrastructure (strfry) ## Summary Deploys strfry (C++ Nostr relay) + relay-policy sidecar as a containerised stack on the VPS, wired to the API server for event-level access control. ## Files created - `infrastructure/strfry.conf` — strfry config: bind 0.0.0.0:7777, writePolicy plugin → /usr/local/bin/relay-policy-plugin, maxEventSize 65536, rejectEphemeral false, db /data/strfry-db - `infrastructure/relay-policy/plugin.sh` — strfry write-policy plugin (stdin/stdout bridge). Reads JSON lines from strfry, POSTs to relay-policy HTTP sidecar (http://relay-policy:3080/decide), writes decision to stdout. Safe fallback: reject on sidecar timeout/failure - `infrastructure/relay-policy/index.ts` — Node.js HTTP relay-policy sidecar: POST /decide receives strfry events, calls API server /api/relay/policy with Bearer RELAY_POLICY_SECRET, returns strfry decision JSON - `infrastructure/relay-policy/package.json + tsconfig.json` — TS build config - `infrastructure/relay-policy/Dockerfile` — multi-stage: builder (tsc) + runtime - `infrastructure/relay-policy/.gitignore` — excludes node_modules, dist - `artifacts/api-server/src/routes/relay.ts` — POST /api/relay/policy: internal route protected by RELAY_POLICY_SECRET Bearer token. Bootstrap state: rejects all events with "relay not yet open — whitelist pending (Task #37)". Stable contract — future tasks extend evaluatePolicy() without API shape changes ## Files modified - `infrastructure/docker-compose.yml` — adds relay-policy + strfry services on node-net; strfry_data volume (bind-mounted at /data/strfry); relay-policy healthcheck; strfry depends on relay-policy healthy - `infrastructure/ops.sh` — adds relay:logs, relay:restart, relay:status commands - `artifacts/api-server/src/routes/index.ts` — registers relayRouter ## Operator setup required on VPS mkdir -p /data/strfry && chmod 700 /data/strfry echo "RELAY_API_URL=https://alexanderwhitestone.com" >> /opt/timmy-node/.env echo "RELAY_POLICY_SECRET=$(openssl rand -hex 32)" >> /opt/timmy-node/.env # Also set RELAY_POLICY_SECRET in Replit secrets for API server ## Notes - TypeScript: 0 errors (API server + relay-policy sidecar both compile clean) - POST /api/relay/policy smoke test: correct bootstrap reject response - strfry image: ghcr.io/hoytech/strfry:latest
2026-03-19 20:02:00 +00:00
router.use(relayRouter);
task/31: Relay account whitelist + trust-gated access ## What was built Full relay access control system: relay_accounts table, RelayAccountService, trust hook integration, live policy enforcement, admin CRUD API, Timmy seed. ## DB change `lib/db/src/schema/relay-accounts.ts` — new `relay_accounts` table: pubkey (PK, FK → nostr_identities.pubkey ON DELETE CASCADE), access_level ("none"|"read"|"write"), granted_by ("manual"|"auto-tier"), granted_at, revoked_at (nullable), notes. Pushed via `pnpm run push`. `lib/db/src/schema/index.ts` — exports relay-accounts. ## RelayAccountService (`artifacts/api-server/src/lib/relay-accounts.ts`) - getAccess(pubkey) → RelayAccessLevel (none if missing or revoked) - grant(pubkey, level, reason, grantedBy) — upsert; creates nostr_identity FK - revoke(pubkey, reason) — sets revokedAt, access_level → none - syncFromTrustTier(pubkey, tier) — auto-promotes by tier; never downgrades manual grants - list(opts) — returns all accounts, optionally filtered to active - Tier→access map: new=none, established/trusted/elite=write (env-overridable) ## Trust hook (`artifacts/api-server/src/lib/trust.ts`) recordSuccess + recordFailure both call syncFromTrustTier after writing tier. Failure is caught + logged (non-blocking — trust flow never fails on relay error). ## Policy endpoint (`artifacts/api-server/src/routes/relay.ts`) evaluatePolicy() now async: queries relay_accounts.getAccess(pubkey). "write" → accept; "read"/"none"/missing → reject with clear message. DB error → reject with "policy service error" (safe fail-closed). ## Admin routes (`artifacts/api-server/src/routes/admin-relay.ts`) ADMIN_SECRET Bearer token auth (localhost-only fallback in dev; error log in prod). GET /api/admin/relay/accounts — list all accounts POST /api/admin/relay/accounts/:pk/grant — grant (level + notes body) POST /api/admin/relay/accounts/:pk/revoke — revoke (reason body) pubkey validation: must be 64-char lowercase hex. ## Startup seed (`artifacts/api-server/src/index.ts`) On every startup: grants Timmy's own pubkeyHex "write" access ("manual"). Idempotent upsert — safe across restarts. ## Smoke test results (all pass) - Timmy pubkey → accept ✓; unknown pubkey → reject ✓ - Admin grant → accept ✓; admin revoke → reject ✓; admin list shows accounts ✓ - TypeScript: 0 errors in API server + lib/db
2026-03-19 20:21:12 +00:00
router.use(adminRelayRouter);
task/32: Event moderation queue + Timmy AI review ## What was built Full moderation pipeline: relay_event_queue table, strfry inject helper, ModerationService with Claude haiku review, policy tier routing, 30s poll loop, admin approve/reject/list endpoints. ## DB schema (`lib/db/src/schema/relay-event-queue.ts`) relay_event_queue: event_id (PK), pubkey (FK → nostr_identities), kind, raw_event (text JSON), status (pending/approved/rejected/auto_approved), reviewed_by (timmy_ai/admin/null), review_reason, created_at, decided_at. Exported from schema/index.ts. Pushed via pnpm run push. ## strfry HTTP client (`artifacts/api-server/src/lib/strfry.ts`) injectEvent(rawEventJson) — POST {STRFRY_URL}/import (NDJSON). STRFRY_URL defaults to "http://strfry:7777" (Docker internal network). 5s timeout; graceful failure in dev when strfry not running; never throws. ## ModerationService (`artifacts/api-server/src/lib/moderation.ts`) - enqueue(event) — insert pending row; idempotent onConflictDoNothing - autoReview(eventId) — Claude haiku prompt: approve or flag. On flag, marks reviewedBy=timmy_ai and leaves pending for admin. On approve, calls decide(). - decide(eventId, status, reason, reviewedBy) — updates DB + calls injectEvent - processPending(limit=10) — batch poll: auto-review up to limit pending events - Stub mode: auto-approves all events when Anthropic key absent ## Policy endpoint update (`artifacts/api-server/src/routes/relay.ts`) Tier routing in evaluatePolicy: read/none → reject (unchanged) write + elite tier → injectEvent + accept (elite bypass; shadowReject if inject fails) write + non-elite → enqueue + shadowReject (held for moderation) Imports db/nostrIdentities directly for tier check. Both inject and enqueue errors are fail-closed (reject vs shadowReject respectively). ## Background poll loop (`artifacts/api-server/src/index.ts`) setInterval every 30s calling moderationService.processPending(10). Interval configurable via MODERATION_POLL_MS env var. Errors caught per-event; poll loop never crashes the server. ## Admin queue routes (`artifacts/api-server/src/routes/admin-relay-queue.ts`) ADMIN_SECRET Bearer auth (same pattern as admin-relay.ts). GET /api/admin/relay/queue?status=... — list all / by status POST /api/admin/relay/queue/:eventId/approve — approve + inject into strfry POST /api/admin/relay/queue/:eventId/reject — reject (no inject) 409 on duplicate decisions. Registered in routes/index.ts. ## Smoke tests (all pass) Unknown → reject ✓; elite → shadowReject (strfry unavailable in dev) ✓; non-elite write → shadowReject + pending in queue ✓; admin approve → approved ✓; moderation poll loop started ✓; TypeScript 0 errors.
2026-03-19 20:35:39 +00:00
router.use(adminRelayQueueRouter);
Task #3: MVP API — payment-gated jobs + demo endpoint OpenAPI spec (lib/api-spec/openapi.yaml) - Added POST /jobs, GET /jobs/{id}, GET /demo endpoints - Added schemas: CreateJobRequest, CreateJobResponse, JobStatusResponse, InvoiceInfo, JobState, DemoResponse, ErrorResponse - Ran codegen: generated CreateJobBody, GetJobResponse, RunDemoQueryParams etc. Jobs router (artifacts/api-server/src/routes/jobs.ts) - POST /jobs: validates body, creates LNbits eval invoice, inserts job + invoice in a DB transaction, returns { jobId, evalInvoice } - GET /jobs/:id: fetches job, calls advanceJob() helper, returns state- appropriate payload (eval/work invoice, reason, result, errorMessage) - advanceJob() state machine: - awaiting_eval_payment: checks LNbits, atomically marks paid + advances state via optimistic WHERE state='awaiting_eval_payment'; runs AgentService.evaluateRequest, branches to awaiting_work_payment or rejected - awaiting_work_payment: same pattern for work invoice, runs AgentService.executeWork, advances to complete - Any agent/LNbits error transitions job to failed Demo router (artifacts/api-server/src/routes/demo.ts) - GET /demo?request=...: in-memory rate limiter (5 req/hour per IP) - Explicit guard for missing request param (coerce.string() workaround) - Calls AgentService.executeWork directly, returns { result } Dev router (artifacts/api-server/src/routes/dev.ts) - POST /dev/stub/pay/:paymentHash: marks stub invoice paid in-memory - Only mounted when NODE_ENV !== 'production' Route index updated to mount all three routers replit.md: documented full curl flow with all 6 steps, demo endpoint, and dev stub-pay trigger End-to-end verified with curl: - Full flow: create → eval pay → evaluating → work pay → executing → complete - Error cases: 400 on missing body/param, 404 on unknown job
2026-03-18 15:31:26 +00:00
router.use(demoRouter);
router.use("/gemini", geminiRouter);
router.use(testkitRouter);
router.use(uiRouter);
router.use(nodeDiagnosticsRouter);
router.use(worldRouter);
Task #3: MVP API — payment-gated jobs + demo endpoint OpenAPI spec (lib/api-spec/openapi.yaml) - Added POST /jobs, GET /jobs/{id}, GET /demo endpoints - Added schemas: CreateJobRequest, CreateJobResponse, JobStatusResponse, InvoiceInfo, JobState, DemoResponse, ErrorResponse - Ran codegen: generated CreateJobBody, GetJobResponse, RunDemoQueryParams etc. Jobs router (artifacts/api-server/src/routes/jobs.ts) - POST /jobs: validates body, creates LNbits eval invoice, inserts job + invoice in a DB transaction, returns { jobId, evalInvoice } - GET /jobs/:id: fetches job, calls advanceJob() helper, returns state- appropriate payload (eval/work invoice, reason, result, errorMessage) - advanceJob() state machine: - awaiting_eval_payment: checks LNbits, atomically marks paid + advances state via optimistic WHERE state='awaiting_eval_payment'; runs AgentService.evaluateRequest, branches to awaiting_work_payment or rejected - awaiting_work_payment: same pattern for work invoice, runs AgentService.executeWork, advances to complete - Any agent/LNbits error transitions job to failed Demo router (artifacts/api-server/src/routes/demo.ts) - GET /demo?request=...: in-memory rate limiter (5 req/hour per IP) - Explicit guard for missing request param (coerce.string() workaround) - Calls AgentService.executeWork directly, returns { result } Dev router (artifacts/api-server/src/routes/dev.ts) - POST /dev/stub/pay/:paymentHash: marks stub invoice paid in-memory - Only mounted when NODE_ENV !== 'production' Route index updated to mount all three routers replit.md: documented full curl flow with all 6 steps, demo endpoint, and dev stub-pay trigger End-to-end verified with curl: - Full flow: create → eval pay → evaluating → work pay → executing → complete - Error cases: 400 on missing body/param, 404 on unknown job
2026-03-18 15:31:26 +00:00
Task #45: Deploy API server — VM deployment, production build, testkit PASS=40/41 FAIL=0 ## What was done 1. **TIMMY_NOSTR_NSEC secret set** — Generated a permanent Nostr keypair and the user set it as a Replit secret. Timmy's identity is now stable across restarts: npub1e3gu2j08t6hymjd5sz9dmy4u5pcl22mj5hl60avkpj5tdpaq3dasjax6tv 2. **app.ts — Fixed import.meta.url for CJS production bundle** esbuild CJS bundles set import.meta={} (empty), crashing the Tower static path resolution. Fixed with try/catch: ESM dev mode uses import.meta.url (3 levels up from src/); CJS prod bundle falls back to process.cwd() + "the-matrix/dist" (workspace root assumption correct since run command is issued from workspace root). 3. **routes/index.ts — Stub-mode-aware dev route gating** Changed condition from `NODE_ENV !== "production"` to `NODE_ENV !== "production" || lnbitsService.stubMode`. The testkit relies on POST /dev/stub/pay/:hash to simulate Lightning payments. Previously this endpoint was hidden in production even when LNbits was in stub mode, causing FAIL=5. Now: real production with real LNbits → stubMode=false → dev routes unexposed (secure). Production bundle with stub LNbits → stubMode=true → dev routes exposed → testkit passes. 4. **artifact.toml — deploymentTarget = "vm"** set so the artifact deploys always-on. The .replit file cannot be edited directly via available APIs; artifact.toml takes precedence for this artifact's deployment configuration. 5. **Production bundle rebuilt** (dist/index.cjs, 1.6MB) — clean build, no warnings. 6. **Full testkit against production bundle in stub mode: PASS=40/41 FAIL=0 SKIP=1** (SKIP=1 is the Nostr challenge/sign/verify test which requires nostr-tools in bash, same baseline as dev mode). ## Deployment command Build: pnpm --filter @workspace/api-server run build Run: node artifacts/api-server/dist/index.cjs Health: /api/healthz Target: VM (always-on) — required for WebSocket connections and in-memory world state.
2026-03-20 01:29:50 +00:00
// Mount dev routes when NOT in production OR when LNbits is in stub mode.
// Stub mode means there is no real Lightning backend — payments are simulated
// in-memory. The testkit relies on POST /dev/stub/pay/:hash to simulate payment
// confirmation, so we expose it whenever stub mode is active regardless of NODE_ENV.
// In real production with a live LNbits backend, stubMode is false, so these
// routes remain unexposed.
//
// OPERATIONAL TRADEOFF: Running the testkit against a live node with LNBITS_STUB=true
// (e.g. for CI without a real Lightning backend) will expose dev routes in that
// process. This is intentional — stub mode has no real funds, so the exposure
// is harmless. Never set LNBITS_STUB=true on a node with a real LNbits backend.
Task #45: Deploy API server — VM deployment, production build, testkit PASS=40/41 FAIL=0 ## What was done 1. **TIMMY_NOSTR_NSEC secret set** — Generated a permanent Nostr keypair and the user set it as a Replit secret. Timmy's identity is now stable across restarts: npub1e3gu2j08t6hymjd5sz9dmy4u5pcl22mj5hl60avkpj5tdpaq3dasjax6tv 2. **app.ts — Fixed import.meta.url for CJS production bundle** esbuild CJS bundles set import.meta={} (empty), crashing the Tower static path resolution. Fixed with try/catch: ESM dev mode uses import.meta.url (3 levels up from src/); CJS prod bundle falls back to process.cwd() + "the-matrix/dist" (workspace root assumption correct since run command is issued from workspace root). 3. **routes/index.ts — Stub-mode-aware dev route gating** Changed condition from `NODE_ENV !== "production"` to `NODE_ENV !== "production" || lnbitsService.stubMode`. The testkit relies on POST /dev/stub/pay/:hash to simulate Lightning payments. Previously this endpoint was hidden in production even when LNbits was in stub mode, causing FAIL=5. Now: real production with real LNbits → stubMode=false → dev routes unexposed (secure). Production bundle with stub LNbits → stubMode=true → dev routes exposed → testkit passes. 4. **artifact.toml — deploymentTarget = "vm"** set so the artifact deploys always-on. The .replit file cannot be edited directly via available APIs; artifact.toml takes precedence for this artifact's deployment configuration. 5. **Production bundle rebuilt** (dist/index.cjs, 1.6MB) — clean build, no warnings. 6. **Full testkit against production bundle in stub mode: PASS=40/41 FAIL=0 SKIP=1** (SKIP=1 is the Nostr challenge/sign/verify test which requires nostr-tools in bash, same baseline as dev mode). ## Deployment command Build: pnpm --filter @workspace/api-server run build Run: node artifacts/api-server/dist/index.cjs Health: /api/healthz Target: VM (always-on) — required for WebSocket connections and in-memory world state.
2026-03-20 01:29:50 +00:00
if (process.env.NODE_ENV !== "production" || lnbitsService.stubMode) {
Task #3: MVP API — payment-gated jobs + demo endpoint OpenAPI spec (lib/api-spec/openapi.yaml) - Added POST /jobs, GET /jobs/{id}, GET /demo endpoints - Added schemas: CreateJobRequest, CreateJobResponse, JobStatusResponse, InvoiceInfo, JobState, DemoResponse, ErrorResponse - Ran codegen: generated CreateJobBody, GetJobResponse, RunDemoQueryParams etc. Jobs router (artifacts/api-server/src/routes/jobs.ts) - POST /jobs: validates body, creates LNbits eval invoice, inserts job + invoice in a DB transaction, returns { jobId, evalInvoice } - GET /jobs/:id: fetches job, calls advanceJob() helper, returns state- appropriate payload (eval/work invoice, reason, result, errorMessage) - advanceJob() state machine: - awaiting_eval_payment: checks LNbits, atomically marks paid + advances state via optimistic WHERE state='awaiting_eval_payment'; runs AgentService.evaluateRequest, branches to awaiting_work_payment or rejected - awaiting_work_payment: same pattern for work invoice, runs AgentService.executeWork, advances to complete - Any agent/LNbits error transitions job to failed Demo router (artifacts/api-server/src/routes/demo.ts) - GET /demo?request=...: in-memory rate limiter (5 req/hour per IP) - Explicit guard for missing request param (coerce.string() workaround) - Calls AgentService.executeWork directly, returns { result } Dev router (artifacts/api-server/src/routes/dev.ts) - POST /dev/stub/pay/:paymentHash: marks stub invoice paid in-memory - Only mounted when NODE_ENV !== 'production' Route index updated to mount all three routers replit.md: documented full curl flow with all 6 steps, demo endpoint, and dev stub-pay trigger End-to-end verified with curl: - Full flow: create → eval pay → evaluating → work pay → executing → complete - Error cases: 400 on missing body/param, 404 on unknown job
2026-03-18 15:31:26 +00:00
router.use(devRouter);
}
2026-03-13 23:21:55 +00:00
export default router;