From 7ba4cb4a3140820031af679022145239db08c43e Mon Sep 17 00:00:00 2001 From: admin Date: Tue, 26 May 2026 16:06:29 +0200 Subject: [PATCH] Initial import: live state on api.qbirr.com (server v0.6.3) --- .gitignore | 6 + CHANGELOG.md | 236 ++++ Dockerfile | 28 + README.md | 54 + deploy/docker-compose.coolify.yml | 40 + deploy/vps-bootstrap.sh | 28 + docker-compose.yml | 40 + package-lock.json | 1870 +++++++++++++++++++++++++++++ package.json | 30 + src/accounts/service.ts | 103 ++ src/accounts/store.ts | 106 ++ src/accounts/teams.ts | 84 ++ src/billing/stripe.ts | 93 ++ src/config.ts | 94 ++ src/db/pool.ts | 159 +++ src/db/runs.ts | 33 + src/lib/crypto.ts | 81 ++ src/lib/logger.ts | 16 + src/lib/session.ts | 38 + src/orchestrator/index.ts | 451 +++++++ src/orchestrator/memory.ts | 43 + src/orchestrator/registry.ts | 119 ++ src/orchestrator/router.ts | 39 + src/orchestrator/types.ts | 97 ++ src/providers/index.ts | 43 + src/providers/openai.ts | 287 +++++ src/routes/auth.ts | 52 + src/routes/billing.ts | 100 ++ src/routes/dashboard.ts | 128 ++ src/routes/health.ts | 30 + src/routes/oauth.ts | 99 ++ src/routes/runs.ts | 190 +++ src/routes/teams.ts | 52 + src/routing/policy.ts | 77 ++ src/server.ts | 83 ++ src/site-callback/client.ts | 79 ++ src/tools/manifest.ts | 115 ++ tsconfig.json | 19 + 38 files changed, 5242 insertions(+) create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 deploy/docker-compose.coolify.yml create mode 100755 deploy/vps-bootstrap.sh create mode 100644 docker-compose.yml create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/accounts/service.ts create mode 100644 src/accounts/store.ts create mode 100644 src/accounts/teams.ts create mode 100644 src/billing/stripe.ts create mode 100644 src/config.ts create mode 100644 src/db/pool.ts create mode 100644 src/db/runs.ts create mode 100644 src/lib/crypto.ts create mode 100644 src/lib/logger.ts create mode 100644 src/lib/session.ts create mode 100644 src/orchestrator/index.ts create mode 100644 src/orchestrator/memory.ts create mode 100644 src/orchestrator/registry.ts create mode 100644 src/orchestrator/router.ts create mode 100644 src/orchestrator/types.ts create mode 100644 src/providers/index.ts create mode 100644 src/providers/openai.ts create mode 100644 src/routes/auth.ts create mode 100644 src/routes/billing.ts create mode 100644 src/routes/dashboard.ts create mode 100644 src/routes/health.ts create mode 100644 src/routes/oauth.ts create mode 100644 src/routes/runs.ts create mode 100644 src/routes/teams.ts create mode 100644 src/routing/policy.ts create mode 100644 src/server.ts create mode 100644 src/site-callback/client.ts create mode 100644 src/tools/manifest.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4ddcf5a --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +data/ +*.bak +.env +.env.local diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..0eb7487 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,236 @@ +# Changelog + +All notable changes to wpide-server will be documented in this file. Format +follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning +is [SemVer](https://semver.org/spec/v2.0.0.html). + +## [0.6.3] — 2026-05-26 + +### Changed +- **Request logging back to `!isDev` (silent in prod).** Was temporarily + forced on to debug the live browser-direct tool round-trip with the + plugin on Hostinger (see plugin 56.95/56.96). Now that browser-direct + is verified end-to-end (744 ms tool round-trip from `mersaai.diretenders.com`), + prod logs go quiet again — every 30s `/v1/health` from Docker's + healthcheck no longer fills the log file. + +### Kept +- The targeted diagnostic lines added in 0.6.2 stay in for future + support work: + - `browser-direct: awaiting tool result from browser` (orchestrator) + - `browser-direct: tool result TIMED OUT after 90s` (orchestrator) + - `tool_result POST received … matched:true|false` (runs route) + These are low-volume and useful for diagnosing future host quirks. + +## [0.6.2] — 2026-05-26 + +### Fixed +- **`z.coerce.boolean()` read every boolean env var as `true`.** `Boolean("false")` + is `true` in JS, so `ALLOW_INSECURE_TLS=false`, `REQUIRE_LICENSE=false`, and + `FREE_TIER_ACTIVE=false` were all silently `true`. Replaced with a `boolish()` + parser that treats `0/false/no/off` as false and `1/true/yes/on` as true. + This bit on the first real VPS deploy: TLS verification stayed disabled and + license gating would have switched on unexpectedly. + +### Added +- **Production deploy artifacts (`deploy/`).** `vps-bootstrap.sh` (Ubuntu prep: + update, ufw, base tools) and `docker-compose.coolify.yml` (runs the container + behind Coolify's existing Traefik proxy on the external `coolify` network, with + Let's Encrypt labels). First live deploy: Contabo VPS → Coolify/Traefik → + `https://api.qbirr.com`. + +## [0.6.1] — 2026-05-26 + +### Fixed +- **SSE stream endpoint now sends CORS headers.** The `/v1/runs/:id/stream` + reply is hijacked (raw), so the cors plugin didn't run on it. Added + `access-control-allow-origin` (echoes request origin) so the plugin's + browser-direct `EventSource` can open the stream cross-origin (browser + on the WP site → server on another domain). Without this the browser + blocked the stream. + +## [0.6.0] — 2026-05-25 — Memory, teams, browser-direct tools + +### Added +- **Server-side conversation memory (`orchestrator/memory.ts`).** Persists + each run's user goal + final answer per `session_id`; recalls the last + ~12 turns and merges (de-duplicated) into context on the next run. Runs + now have continuity even when the plugin sends little history. (DB + migration v3: `conversation_turns`.) +- **Multi-tenant teams (`accounts/teams.ts`, `routes/teams.ts`).** Orgs + + members + roles; `POST/GET /v1/teams`, member add/remove. A member + **inherits the org owner's subscription tier** (team owner pays once, + seats share the tier) — wired into `resolveAccess`. (Migration v3: + `orgs`, `org_members`, `subscriptions.org_id`.) +- **Browser-direct tool execution (cap-immune transport, server side).** + `browser_tools: true` on a run makes the loop emit `tool_call` SSE + events and await `POST /v1/runs/:id/tool_result` from the browser, + instead of calling back into the plugin. Removes the long-lived request + from the WP host entirely → works on any shared host. Relay mode + (plugin callback) remains the default; this is additive. Proven + end-to-end via test (tool_call emitted → result posted → run completed + using the posted data). + +### Notes +- The browser-direct **plugin JS** (browser opens EventSource + runs the + tool via admin-ajax + POSTs the result) is the remaining client-side + step; the server fully supports it now. + +## [0.5.0] — 2026-05-25 — SaaS platform foundation + +### Added +- **Accounts & auth.** Email+password (scrypt, no native deps), Google + + GitHub OAuth (authorization-code flow), session JWT in an httpOnly + cookie. `/v1/auth/{register,login,logout,me,set-password}` and + `/v1/auth/oauth/:provider/{start,callback}`. +- **Licensing & subscriptions (no credits).** Each account gets a license + key + a subscription row (tier `basic|pro|max`, status). The run path + resolves the license → tier and gates on an active subscription + (`REQUIRE_LICENSE`, default off in dev so local testing needs no + account; `DEV_DEFAULT_TIER` applies when off). +- **Model routing by tier** (`routing/policy.ts`): tier sets the model + ceiling — basic→flash, pro→+thinking, max→+pro-max — combined with + mode/complexity. Explicit model picks are honored only within the tier, + else downgraded. DeepSeek `thinking` param wired through. +- **Stripe billing** (`fetch`, no SDK): `/v1/billing/{checkout,portal,webhook}` + with manual webhook signature verification; subscription state mirrored + into the DB. Disabled cleanly when keys absent. +- **Dashboard** at `/app`: signup/login (email + Google + GitHub), + license key with copy, plan + tier picker + manage-billing. Single + inline HTML page, no build step. +- **DB migration v2:** users, oauth_identities, licenses, subscriptions, + sites (site registry); runs gain user_id/tier/model. +- **Deploy:** `SETUP.md` operator checklist, `docker-compose.prod.yml` + + `Caddyfile.prod` for a plain VPS (Coolify uses the Dockerfile directly). + SQLite on a persistent volume for v1; Postgres is the documented + scale-up path. + +### Notes +- Credits/metering intentionally deferred — access is subscription-gated, + tier gates models. Multi-tenant teams, server-side memory, and the + browser-direct SSE transport remain as later sub-projects. + +## [0.4.1] — 2026-05-25 + +### Fixed +- **Reasoning rendered one token per line in the chat.** The agentic loop + was emitting a `thinking` event per reasoning token; the plugin relays + each as a `thought` event, and the browser appends one list item per + thought — so each reasoning token landed on its own line. Now the loop + accumulates `reasoning_content` per step and emits a single `thinking` + event when the step's LLM call finishes (matches the local + orchestrator's one-thought-per-step model). Content tokens still stream + individually into the live bubble. + +## [0.4.0] — 2026-05-25 + +### Added +- **SSE token streaming.** `GET /v1/runs/:run_id/stream` streams a run's + live events (`token`, `thinking`, `tool_call`, `tool_result`, `status`, + `done`, `error`, `end`) with `?since=` resume. Backed by a per-run + event buffer in the registry (`addEvent`). +- `OpenAIClient.chatStream()` — streaming chat completions over fetch: + parses SSE token deltas, accumulates `content` / `reasoning_content` / + `tool_calls`, returns the same shape as `chat()`. Connect-only retry + (safe to retry before any token; never mid-stream). +- The agentic loop now uses `chatStream` and emits token/tool events into + the run's buffer as they happen. + +### Fixed +- **DeepSeek v4 thinking mode HTTP 400** — the loop now echoes the + assistant message's `reasoning_content` back on the next turn after a + tool call, as DeepSeek v4 requires. (`chat()` and `chatStream()` both + capture the field; the loop re-sends it.) + +## [0.3.0] — 2026-05-25 + +### Added +- **Async run model — removes the synchronous timeout ceiling.** + - `POST /v1/runs/start` registers a run, kicks off the orchestrator in + the background, and returns `{ run_id, session_id }` immediately. + - `GET /v1/runs/:run_id/status` reports live progress + (`status`, `steps_done`, `tools_used`, `elapsed_ms`, `partial_content`) + and the full PHP-shape `response` once finished. + - In-memory run registry (`src/orchestrator/registry.ts`) with a 30-min + TTL for finished runs. `process_request` shares its step/tool arrays + with the registry so status reflects progress as it happens. + - Old synchronous `POST /v1/runs` retained for non-browser callers. +- **Built-in server-side `wait` tool** — lets agents pace long-running + diagnostics without a DB `SLEEP()` (SQLite has none). Handled in-loop, + never calls back to the plugin. Max 30s per call. +- LLM chat retry hardened: 6 attempts, 20s per-attempt timeout (fails + fast on a hung VPN connection and retries), abort/timeout treated as + retryable. Step cap raised 25 → 40. +- `AGENT_STEP tool_call → ` logged at info level for live flow + visibility. + +## [0.2.1] — 2026-05-25 + +### Added +- xAI (Grok) provider client — OpenAI-compatible base URL `https://api.x.ai/v1`. +- DeepSeek provider client — base URL `https://api.deepseek.com/v1`. +- Provider auto-routing in `pickProvider()`: explicit override → model-name + prefix (`deepseek*`, `grok*`, `gpt-*`) → first configured key + (deepseek → xai → openai). `defaultModelFor()` picks a sane model per + provider. +- `ALLOW_INSECURE_TLS` (default on in dev) — skips outbound TLS + verification for machines behind a VPN/MITM whose root CA Node doesn't + trust. Set off in prod. +- Tool-manifest sanitizer (`src/tools/manifest.ts`) — coerces PHP's empty + `[]` to `{}` where objects are required and strips null values, so + strict validators (DeepSeek) stop rejecting the plugin's 95 tool + schemas with HTTP 400. +- LLM chat calls now retry up to 3× with backoff on network errors + (connect timeout, DNS, TLS) and 5xx/429 — rides through flaky-VPN + blips. 4xx are treated as real errors and not retried. +- Detailed logging on tool-exec callback failures (URL, status, headers, + body length) to diagnose cross-machine / rewrite issues. + +### Changed +- Server binds to `0.0.0.0` by default (was `127.0.0.1`) so other LAN + machines can reach it. Default port `3017`. +- Default model is now `deepseek-chat`. + +## [0.2.0] — 2026-05-21 + +### Added +- `GET /` root route returns an API directory (name, version, endpoint + hints) — friendlier than the previous 404 when poking around in a + browser. +- `POST /v1/runs` — main orchestrator entrypoint. Accepts + `{ goal, context, options, tools_manifest, callback_url, callback_secret, license_key, site_url }` + and returns the exact response shape PHP's `wp_ide_process_agentic()` + uses: `{ success, content, tool_results, execution: { run_id, + session_id, mode, steps, status_messages, ... }, approval_payload }`. +- `GET /v1/runs/:run_id` — fetch a stored run record. +- Greeting + simple paths fully working. Greetings need no LLM call; + simple invokes OpenAI chat/completions (default `gpt-4o-mini`). + Agentic path is a placeholder that returns a "step 5" message. +- Orchestrator router (`src/orchestrator/router.ts`) — port of + `WP_IDE_AI_Router::classify`; cheap regex/keyword heuristics that pick + greeting / simple / agentic without an LLM call. +- OpenAI client over fetch (`src/providers/openai.ts`) — no SDK + dependency. Provider router (`src/providers/index.ts`) dispatches by + model name; Anthropic / xAI plug in here in later steps. +- Runs table CRUD (`src/db/runs.ts`) — persists every run with status + transitions so a future status endpoint can answer "what happened". + +### Notes +- `OPENAI_API_KEY` is read from `.env` on boot. Without it the simple + path returns a clear actionable error in the standard response shape; + the greeting path still works because it doesn't call any LLM. + +## [0.1.0] — 2026-05-21 + +### Added +- Initial Fastify scaffold (Node 20, TypeScript, ESM). +- `GET /v1/health` returns name, version, uptime, Node version, timestamp. +- SQLite via `better-sqlite3` as the dev database; WAL mode + foreign keys + enabled. Postgres URL recognised for later Coolify deploy but not yet + wired. +- Initial schema: `schema_version`, `runs` tables (more added per + milestone). +- Zod-validated env loader, pino logger, CORS, graceful shutdown. +- `start-dev.bat` for one-command Windows dev startup. +- `Dockerfile` for future Coolify deployment (not used locally). +- Optional `Caddyfile` for local TLS via `wpide.local`. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8537ebd --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +# Multi-stage Docker build for Coolify deployment. +# Not used during local Windows dev — `npm run dev` runs Node directly. + +FROM node:20-alpine AS deps +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN apk add --no-cache python3 make g++ \ + && npm ci --include=dev \ + && apk del python3 make g++ + +FROM node:20-alpine AS build +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN npm run build + +FROM node:20-alpine AS runtime +WORKDIR /app +ENV NODE_ENV=production +COPY package.json package-lock.json* ./ +RUN apk add --no-cache python3 make g++ \ + && npm ci --omit=dev \ + && apk del python3 make g++ +COPY --from=build /app/dist ./dist +EXPOSE 3017 +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD wget -qO- http://127.0.0.1:3017/v1/health || exit 1 +CMD ["node", "dist/server.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..3b8c128 --- /dev/null +++ b/README.md @@ -0,0 +1,54 @@ +# wpide-server + +Closed orchestrator server for the WordPress IDE plugin. The plugin (open +GPL client) routes its agentic chat through this server when "server +mode" is on, otherwise falls back to its own local orchestrator. See +`plans/server-app-for-wp-harmonic-eclipse.md` in the parent repo for the +full architecture. + +## Local dev (this PC, Windows, no Docker) + +Requirements: Node 20+. Optional: Caddy 2 for TLS. + +```bat +cd wpide-server +start-dev.bat +``` + +The script copies `.env.example` to `.env` on first run, installs deps, +and starts Fastify on `http://127.0.0.1:3017`. (Port 3017 was chosen to +avoid colliding with common Node app defaults like 3000.) + +Smoke test: + +```bash +curl http://127.0.0.1:3017/v1/health +curl -X POST http://127.0.0.1:3017/v1/runs \ + -H "content-type: application/json" \ + -d '{"goal":"hello"}' +``` + +Expected: health returns version/uptime JSON; `/v1/runs` with a greeting +returns the full PHP-compatible response shape with `mode: "greeting"`. +A real prompt requires `OPENAI_API_KEY` in `.env` — without it the +response is still the standard shape but `success: false` with a clear +error message. + +## Future prod (other PC, Coolify) + +Coolify reads `Dockerfile`. The build is a standard multi-stage Node 20 +alpine image. See the plan file for the Gitea + Coolify wiring. + +## Layout + +``` +src/ + server.ts Fastify boot + config.ts Zod-validated env loader + routes/health.ts GET /v1/health + db/pool.ts SQLite (dev) / Postgres (prod) abstraction + lib/logger.ts pino + pino-pretty +``` + +Subsequent milestones add `routes/runs.ts`, `routes/license.ts`, +`orchestrator/*`, `providers/*`, `agents/*`, `site-callback/*`. diff --git a/deploy/docker-compose.coolify.yml b/deploy/docker-compose.coolify.yml new file mode 100644 index 0000000..4763420 --- /dev/null +++ b/deploy/docker-compose.coolify.yml @@ -0,0 +1,40 @@ +# wpide-server deployed behind Coolify's existing Traefik proxy. +# Traefik (coolify-proxy) auto-discovers this container via the labels below +# because it's attached to the external `coolify` network. HTTPS is issued by +# Coolify's `letsencrypt` cert resolver (HTTP-01 challenge on :80). +# +# Deploy: docker compose -f docker-compose.yml up -d --build +# Domain is set via the DOMAIN env var (defaults to api.qbirr.com). + +services: + wpide-server: + build: . + image: wpide-server:latest + container_name: wpide-server + restart: unless-stopped + env_file: .env + volumes: + - wpide-data:/app/data + networks: + - coolify + labels: + - traefik.enable=true + - traefik.docker.network=coolify + # --- HTTPS router --- + - "traefik.http.routers.wpide.rule=Host(`api.qbirr.com`)" + - traefik.http.routers.wpide.entrypoints=https + - traefik.http.routers.wpide.tls=true + - traefik.http.routers.wpide.tls.certresolver=letsencrypt + - traefik.http.services.wpide.loadbalancer.server.port=3017 + # --- HTTP -> HTTPS redirect --- + - "traefik.http.routers.wpide-http.rule=Host(`api.qbirr.com`)" + - traefik.http.routers.wpide-http.entrypoints=http + - traefik.http.routers.wpide-http.middlewares=wpide-redirect + - traefik.http.middlewares.wpide-redirect.redirectscheme.scheme=https + +volumes: + wpide-data: + +networks: + coolify: + external: true diff --git a/deploy/vps-bootstrap.sh b/deploy/vps-bootstrap.sh new file mode 100755 index 0000000..9429f1f --- /dev/null +++ b/deploy/vps-bootstrap.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# One-time VPS prep for a Coolify-managed box (Ubuntu 24.04). +# Coolify installs Docker + its reverse proxy itself, so we keep this minimal: +# system update, firewall, base tools. Idempotent. Run as root. +set -euo pipefail +export DEBIAN_FRONTEND=noninteractive + +echo "### [1/4] needrestart -> automatic (no interactive prompts)" +if [ -f /etc/needrestart/needrestart.conf ]; then + sed -i "s/#\$nrconf{restart} = .*/\$nrconf{restart} = 'a';/" /etc/needrestart/needrestart.conf || true +fi + +echo "### [2/4] apt update + upgrade" +apt-get update -y +apt-get upgrade -y + +echo "### [3/4] base packages" +apt-get install -y curl ca-certificates gnupg lsb-release ufw jq + +echo "### [4/4] firewall (ufw)" +ufw allow OpenSSH # 22 - keep our key login alive +ufw allow 80/tcp # http (Coolify proxy / ACME) +ufw allow 443/tcp # https (Coolify proxy) +ufw allow 8000/tcp # Coolify dashboard +ufw --force enable +ufw status verbose + +echo "BOOTSTRAP_DONE" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4763420 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,40 @@ +# wpide-server deployed behind Coolify's existing Traefik proxy. +# Traefik (coolify-proxy) auto-discovers this container via the labels below +# because it's attached to the external `coolify` network. HTTPS is issued by +# Coolify's `letsencrypt` cert resolver (HTTP-01 challenge on :80). +# +# Deploy: docker compose -f docker-compose.yml up -d --build +# Domain is set via the DOMAIN env var (defaults to api.qbirr.com). + +services: + wpide-server: + build: . + image: wpide-server:latest + container_name: wpide-server + restart: unless-stopped + env_file: .env + volumes: + - wpide-data:/app/data + networks: + - coolify + labels: + - traefik.enable=true + - traefik.docker.network=coolify + # --- HTTPS router --- + - "traefik.http.routers.wpide.rule=Host(`api.qbirr.com`)" + - traefik.http.routers.wpide.entrypoints=https + - traefik.http.routers.wpide.tls=true + - traefik.http.routers.wpide.tls.certresolver=letsencrypt + - traefik.http.services.wpide.loadbalancer.server.port=3017 + # --- HTTP -> HTTPS redirect --- + - "traefik.http.routers.wpide-http.rule=Host(`api.qbirr.com`)" + - traefik.http.routers.wpide-http.entrypoints=http + - traefik.http.routers.wpide-http.middlewares=wpide-redirect + - traefik.http.middlewares.wpide-redirect.redirectscheme.scheme=https + +volumes: + wpide-data: + +networks: + coolify: + external: true diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..982539f --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1870 @@ +{ + "name": "wpide-server", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "wpide-server", + "version": "0.1.0", + "dependencies": { + "@fastify/cors": "^10.0.1", + "better-sqlite3": "^11.3.0", + "fastify": "^5.0.0", + "pino": "^9.4.0", + "pino-pretty": "^11.2.2", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.11", + "@types/node": "^20.16.10", + "tsx": "^4.19.1", + "typescript": "^5.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@fastify/ajv-compiler": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", + "integrity": "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0" + } + }, + "node_modules/@fastify/cors": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-10.1.0.tgz", + "integrity": "sha512-MZyBCBJtII60CU9Xme/iE4aEy8G7QpzGR8zkdXZkDFt7ElEMachbE61tfhAG/bvSaULlqlf0huMT12T7iqEmdQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fastify-plugin": "^5.0.0", + "mnemonist": "0.40.0" + } + }, + "node_modules/@fastify/error": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", + "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.0.3.tgz", + "integrity": "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fast-json-stringify": "^6.0.0" + } + }, + "node_modules/@fastify/forwarded": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.1.tgz", + "integrity": "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", + "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@fastify/proxy-addr": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", + "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/forwarded": "^3.0.0", + "ipaddr.js": "^2.1.0" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "20.19.41", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz", + "integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "license": "MIT" + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/avvio": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.2.0.tgz", + "integrity": "sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/error": "^4.0.0", + "fastq": "^1.17.1" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/better-sqlite3": { + "version": "11.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", + "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bl/node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/bl/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/dateformat": { + "version": "4.6.3", + "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz", + "integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/esbuild": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-copy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-3.0.2.tgz", + "integrity": "sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ==", + "license": "MIT" + }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stringify": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-6.4.0.tgz", + "integrity": "sha512-ibRCQ0GZKJIQ+P3Et1h0LhPgp3PMTYk0MH8O+kW3lNYsvmaQww5Nn3f1jf73Q0jR1Yz3a1CDP4/NZD3vOajWJQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastify": { + "version": "5.8.5", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.5.tgz", + "integrity": "sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", + "abstract-logging": "^2.0.1", + "avvio": "^9.0.0", + "fast-json-stringify": "^6.0.0", + "find-my-way": "^9.0.0", + "light-my-request": "^6.0.0", + "pino": "^9.14.0 || ^10.1.0", + "process-warning": "^5.0.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/fastify-plugin": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz", + "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/find-my-way": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.6.0.tgz", + "integrity": "sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/help-me": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", + "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==", + "license": "MIT" + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz", + "integrity": "sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/json-schema-ref-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", + "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/light-my-request": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", + "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "dependencies": { + "cookie": "^1.0.1", + "process-warning": "^4.0.0", + "set-cookie-parser": "^2.6.0" + } + }, + "node_modules/light-my-request/node_modules/process-warning": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", + "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/mnemonist": { + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.0.tgz", + "integrity": "sha512-kdd8AFNig2AD5Rkih7EPCXhu/iMvwevQFX/uEiGhZyPZi7fHqOoF4V4kHLpCfysxXMgQ4B52kdPMCwARshKvEg==", + "license": "MIT", + "dependencies": { + "obliterator": "^2.0.4" + } + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/node-abi": { + "version": "3.92.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", + "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/obliterator": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz", + "integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==", + "license": "MIT" + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/pino": { + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", + "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", + "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-pretty": { + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-11.3.0.tgz", + "integrity": "sha512-oXwn7ICywaZPHmu3epHGU2oJX4nPmKvHvB/bwrJHlGcbEWaVcotkpyVHMKLKmiVryWYByNp0jpgAcXpFJDXJzA==", + "license": "MIT", + "dependencies": { + "colorette": "^2.0.7", + "dateformat": "^4.6.3", + "fast-copy": "^3.0.2", + "fast-safe-stringify": "^2.1.1", + "help-me": "^5.0.0", + "joycon": "^3.1.1", + "minimist": "^1.2.6", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pump": "^3.0.0", + "readable-stream": "^4.0.0", + "secure-json-parse": "^2.4.0", + "sonic-boom": "^4.0.1", + "strip-json-comments": "^3.1.1" + }, + "bin": { + "pino-pretty": "bin.js" + } + }, + "node_modules/pino-pretty/node_modules/secure-json-parse": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz", + "integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==", + "license": "BSD-3-Clause" + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-regex2": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz", + "integrity": "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + }, + "bin": { + "safe-regex2": "bin/safe-regex2.js" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tar-stream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/thread-stream": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", + "integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + } + }, + "node_modules/toad-cache": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.1.tgz", + "integrity": "sha512-5DXWzE4Vz7xNHsv+xQ+MGfJYyC78Aok3tEr0MNwHoRf7vZnga1mQXZ4/Nsodld4VR6Wd+VhfmqnNrsRJyYPfrQ==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/tsx": { + "version": "4.22.3", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.3.tgz", + "integrity": "sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..b07804e --- /dev/null +++ b/package.json @@ -0,0 +1,30 @@ +{ + "name": "wpide-server", + "version": "0.6.3", + "private": true, + "description": "Closed orchestrator server for the WordPress IDE plugin", + "type": "module", + "engines": { + "node": ">=20.0.0" + }, + "scripts": { + "dev": "tsx watch src/server.ts", + "build": "tsc -p tsconfig.json", + "start": "node dist/server.js", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@fastify/cors": "^10.0.1", + "better-sqlite3": "^11.3.0", + "fastify": "^5.0.0", + "pino": "^9.4.0", + "pino-pretty": "^11.2.2", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.11", + "@types/node": "^20.16.10", + "tsx": "^4.19.1", + "typescript": "^5.6.2" + } +} diff --git a/src/accounts/service.ts b/src/accounts/service.ts new file mode 100644 index 0000000..84da9ac --- /dev/null +++ b/src/accounts/service.ts @@ -0,0 +1,103 @@ +/** + * Account orchestration: registration, login, OAuth upsert, and the + * license → subscription resolution the run path uses to gate access. + */ + +import { config } from '../config.js'; +import { hashPassword, verifyPassword } from '../lib/crypto.js'; +import { + type Tier, type SubStatus, type User, + getUserByEmail, getUserById, createUser, setUserPassword, + findOauth, linkOauth, + createLicense, getLicensesForUser, getLicenseByKey, touchLicense, + getSubscription, createSubscription, + touchSite, +} from './store.js'; +import { inheritedTier } from './teams.js'; + +const ACTIVE: SubStatus[] = ['active', 'trialing']; + +/** Scaffolding every new account gets: one license + a subscription row. */ +function scaffoldAccount(user: User): void { + if (getLicensesForUser(user.id).length === 0) createLicense(user.id); + if (!getSubscription(user.id)) { + // Free tier: active basic with no payment, if enabled; else inactive. + createSubscription(user.id, 'basic', config.FREE_TIER_ACTIVE ? 'active' : 'none'); + } +} + +export type AuthResult = { ok: true; user: User } | { ok: false; error: string }; + +export function registerWithPassword(email: string, password: string): AuthResult { + email = email.trim().toLowerCase(); + if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) return { ok: false, error: 'invalid_email' }; + if (password.length < 8) return { ok: false, error: 'weak_password' }; + if (getUserByEmail(email)) return { ok: false, error: 'email_in_use' }; + const user = createUser(email, hashPassword(password)); + scaffoldAccount(user); + return { ok: true, user }; +} + +export function loginWithPassword(email: string, password: string): AuthResult { + const user = getUserByEmail(email.trim().toLowerCase()); + if (!user || !user.password_hash) return { ok: false, error: 'invalid_credentials' }; + if (!verifyPassword(password, user.password_hash)) return { ok: false, error: 'invalid_credentials' }; + scaffoldAccount(user); + return { ok: true, user }; +} + +/** Find-or-create an account from a verified OAuth profile. */ +export function upsertOauthUser(provider: string, uid: string, email: string | null): User { + const existing = findOauth(provider, uid); + if (existing) return getUserById(existing.user_id)!; + + // Link to an existing email account if present, else create one. + let user = email ? getUserByEmail(email) : null; + if (!user) user = createUser(email ?? `${provider}_${uid}@oauth.local`, null); + linkOauth(user.id, provider, uid, email); + scaffoldAccount(user); + return user; +} + +/** Optionally let an OAuth user set a password later. */ +export function setPassword(user_id: string, password: string): { ok: boolean; error?: string } { + if (password.length < 8) return { ok: false, error: 'weak_password' }; + setUserPassword(user_id, hashPassword(password)); + return { ok: true }; +} + +export interface ResolvedAccess { + ok: boolean; + reason?: string; + user_id?: string; + tier?: Tier; + status?: SubStatus; +} + +/** + * Resolve a license key → access decision for a run. Records site + + * touches the license. This is the gate the run path calls. + */ +export function resolveAccess(licenseKey: string, siteUrl?: string): ResolvedAccess { + if (!licenseKey) return { ok: false, reason: 'no_license_key' }; + const lic = getLicenseByKey(licenseKey); + if (!lic || lic.status !== 'active') return { ok: false, reason: 'invalid_license' }; + touchLicense(lic.id); + if (siteUrl) touchSite(lic.user_id, siteUrl); + + const sub = getSubscription(lic.user_id); + const tier = (sub?.tier ?? 'basic') as Tier; + const status = (sub?.status ?? 'none') as SubStatus; + if (ACTIVE.includes(status)) { + return { ok: true, user_id: lic.user_id, tier, status }; + } + + // No active personal subscription — inherit from a team (org) the user + // belongs to whose owner has an active subscription. + const orgTier = inheritedTier(lic.user_id); + if (orgTier) { + return { ok: true, user_id: lic.user_id, tier: orgTier, status: 'active' }; + } + + return { ok: false, reason: 'subscription_required', user_id: lic.user_id, tier, status }; +} diff --git a/src/accounts/store.ts b/src/accounts/store.ts new file mode 100644 index 0000000..274308d --- /dev/null +++ b/src/accounts/store.ts @@ -0,0 +1,106 @@ +/** + * Data access for accounts: users, oauth identities, licenses, + * subscriptions, sites. Thin wrappers over the DB so services and routes + * never write SQL inline. + */ + +import { getDb } from '../db/pool.js'; +import { newId, newLicenseKey } from '../lib/crypto.js'; + +export type Tier = 'basic' | 'pro' | 'max'; +export type SubStatus = 'none' | 'trialing' | 'active' | 'past_due' | 'canceled'; + +export interface User { id: string; email: string; password_hash: string | null; status: string; created_at: string; } +export interface License { id: string; user_id: string; key: string; status: string; created_at: string; last_seen_at: string | null; } +export interface Subscription { + id: string; user_id: string; tier: Tier; status: SubStatus; + stripe_customer_id: string | null; stripe_sub_id: string | null; + current_period_end: string | null; created_at: string; updated_at: string; +} + +export function getUserByEmail(email: string): User | null { + return (getDb().prepare('SELECT * FROM users WHERE email = ?').get(email.toLowerCase()) as User | undefined) ?? null; +} +export function getUserById(id: string): User | null { + return (getDb().prepare('SELECT * FROM users WHERE id = ?').get(id) as User | undefined) ?? null; +} + +export function createUser(email: string, password_hash: string | null): User { + const id = newId(); + getDb().prepare('INSERT INTO users (id, email, password_hash) VALUES (?, ?, ?)') + .run(id, email.toLowerCase(), password_hash); + return getUserById(id)!; +} + +export function setUserPassword(id: string, password_hash: string): void { + getDb().prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(password_hash, id); +} + +// ---- oauth ---- +export function findOauth(provider: string, uid: string): { user_id: string } | null { + return (getDb().prepare('SELECT user_id FROM oauth_identities WHERE provider = ? AND provider_uid = ?') + .get(provider, uid) as { user_id: string } | undefined) ?? null; +} +export function linkOauth(user_id: string, provider: string, uid: string, email: string | null): void { + getDb().prepare('INSERT OR IGNORE INTO oauth_identities (id, user_id, provider, provider_uid, email) VALUES (?,?,?,?,?)') + .run(newId(), user_id, provider, uid, email); +} + +// ---- licenses ---- +export function createLicense(user_id: string): License { + const id = newId(); + getDb().prepare('INSERT INTO licenses (id, user_id, key) VALUES (?, ?, ?)').run(id, user_id, newLicenseKey()); + return (getDb().prepare('SELECT * FROM licenses WHERE id = ?').get(id) as License); +} +export function getLicenseByKey(key: string): License | null { + return (getDb().prepare('SELECT * FROM licenses WHERE key = ?').get(key) as License | undefined) ?? null; +} +export function getLicensesForUser(user_id: string): License[] { + return getDb().prepare('SELECT * FROM licenses WHERE user_id = ? ORDER BY created_at').all(user_id) as License[]; +} +export function touchLicense(id: string): void { + getDb().prepare("UPDATE licenses SET last_seen_at = datetime('now') WHERE id = ?").run(id); +} + +// ---- subscriptions ---- +export function getSubscription(user_id: string): Subscription | null { + return (getDb().prepare('SELECT * FROM subscriptions WHERE user_id = ?').get(user_id) as Subscription | undefined) ?? null; +} +export function createSubscription(user_id: string, tier: Tier, status: SubStatus): Subscription { + getDb().prepare('INSERT INTO subscriptions (id, user_id, tier, status) VALUES (?,?,?,?)') + .run(newId(), user_id, tier, status); + return getSubscription(user_id)!; +} +export function upsertSubscription(user_id: string, fields: Partial): void { + const existing = getSubscription(user_id); + if (!existing) { + createSubscription(user_id, (fields.tier as Tier) ?? 'basic', (fields.status as SubStatus) ?? 'none'); + } + const cols = ['tier', 'status', 'stripe_customer_id', 'stripe_sub_id', 'current_period_end'] as const; + const sets: string[] = []; + const vals: unknown[] = []; + for (const c of cols) { + if (fields[c] !== undefined) { sets.push(`${c} = ?`); vals.push(fields[c]); } + } + if (sets.length === 0) return; + sets.push("updated_at = datetime('now')"); + vals.push(user_id); + getDb().prepare(`UPDATE subscriptions SET ${sets.join(', ')} WHERE user_id = ?`).run(...vals); +} +export function findUserByStripeCustomer(customerId: string): string | null { + const row = getDb().prepare('SELECT user_id FROM subscriptions WHERE stripe_customer_id = ?') + .get(customerId) as { user_id: string } | undefined; + return row?.user_id ?? null; +} + +// ---- sites ---- +export function touchSite(user_id: string, site_url: string): void { + if (!site_url) return; + const db = getDb(); + const existing = db.prepare('SELECT id FROM sites WHERE user_id = ? AND site_url = ?').get(user_id, site_url) as { id: string } | undefined; + if (existing) { + db.prepare("UPDATE sites SET last_seen = datetime('now') WHERE id = ?").run(existing.id); + } else { + db.prepare('INSERT INTO sites (id, user_id, site_url) VALUES (?,?,?)').run(newId(), user_id, site_url); + } +} diff --git a/src/accounts/teams.ts b/src/accounts/teams.ts new file mode 100644 index 0000000..0beec62 --- /dev/null +++ b/src/accounts/teams.ts @@ -0,0 +1,84 @@ +/** + * Multi-tenant teams (orgs). An org has an owner and members; the org's + * subscription tier is inherited by all members (so a team owner pays + * once and seats share the tier). Minimal v1: add existing users by + * email; token-based email invites are a later refinement. + */ + +import { getDb } from '../db/pool.js'; +import { newId } from '../lib/crypto.js'; +import { getUserByEmail, getSubscription, type Tier } from './store.js'; + +export interface Org { id: string; name: string; owner_user_id: string; created_at: string; } +export interface OrgMember { id: string; org_id: string; user_id: string; role: string; created_at: string; email?: string; } + +export function createOrg(name: string, ownerUserId: string): Org { + const id = newId(); + const db = getDb(); + db.prepare('INSERT INTO orgs (id, name, owner_user_id) VALUES (?,?,?)').run(id, name, ownerUserId); + db.prepare('INSERT INTO org_members (id, org_id, user_id, role) VALUES (?,?,?,?)') + .run(newId(), id, ownerUserId, 'owner'); + return getOrgById(id)!; +} + +export function getOrgById(id: string): Org | null { + return (getDb().prepare('SELECT * FROM orgs WHERE id = ?').get(id) as Org | undefined) ?? null; +} + +export function getOrgsForUser(userId: string): (Org & { role: string })[] { + return getDb().prepare(` + SELECT o.*, m.role FROM orgs o + JOIN org_members m ON m.org_id = o.id + WHERE m.user_id = ? ORDER BY o.created_at + `).all(userId) as (Org & { role: string })[]; +} + +export function isOwner(orgId: string, userId: string): boolean { + const row = getDb().prepare('SELECT role FROM org_members WHERE org_id = ? AND user_id = ?') + .get(orgId, userId) as { role: string } | undefined; + return row?.role === 'owner'; +} + +export function isMember(orgId: string, userId: string): boolean { + return !!getDb().prepare('SELECT 1 FROM org_members WHERE org_id = ? AND user_id = ?').get(orgId, userId); +} + +export function getMembers(orgId: string): OrgMember[] { + return getDb().prepare(` + SELECT m.*, u.email FROM org_members m + JOIN users u ON u.id = m.user_id + WHERE m.org_id = ? ORDER BY m.created_at + `).all(orgId) as OrgMember[]; +} + +export function addMemberByEmail(orgId: string, email: string, role = 'member'): { ok: boolean; error?: string } { + const user = getUserByEmail(email.trim().toLowerCase()); + if (!user) return { ok: false, error: 'user_not_found' }; // must have an account (invites: later) + if (isMember(orgId, user.id)) return { ok: false, error: 'already_member' }; + getDb().prepare('INSERT INTO org_members (id, org_id, user_id, role) VALUES (?,?,?,?)') + .run(newId(), orgId, user.id, role); + return { ok: true }; +} + +export function removeMember(orgId: string, userId: string): void { + // Never remove the owner via this path. + getDb().prepare("DELETE FROM org_members WHERE org_id = ? AND user_id = ? AND role != 'owner'") + .run(orgId, userId); +} + +/** + * The best active tier a user inherits from any org they belong to + * (via that org's owner's subscription). Returns null if none active. + */ +export function inheritedTier(userId: string): Tier | null { + const orgs = getOrgsForUser(userId); + let best: Tier | null = null; + const rank: Record = { basic: 1, pro: 2, max: 3 }; + for (const org of orgs) { + const sub = getSubscription(org.owner_user_id); + if (sub && (sub.status === 'active' || sub.status === 'trialing')) { + if (!best || rank[sub.tier] > rank[best]) best = sub.tier; + } + } + return best; +} diff --git a/src/billing/stripe.ts b/src/billing/stripe.ts new file mode 100644 index 0000000..7960dd0 --- /dev/null +++ b/src/billing/stripe.ts @@ -0,0 +1,93 @@ +/** + * Minimal Stripe client over fetch (no SDK dependency). + * Covers: checkout session, billing portal session, webhook signature + * verification. Subscription state is mirrored into our `subscriptions` + * table by the webhook handler. + */ + +import { createHmac, timingSafeEqual } from 'node:crypto'; +import { config } from '../config.js'; +import type { Tier } from '../accounts/store.js'; + +const API = 'https://api.stripe.com/v1'; + +export function isStripeConfigured(): boolean { + return config.STRIPE_SECRET_KEY.length > 0; +} + +function priceForTier(tier: Tier): string { + switch (tier) { + case 'pro': return config.STRIPE_PRICE_PRO; + case 'max': return config.STRIPE_PRICE_MAX; + case 'basic': + default: return config.STRIPE_PRICE_BASIC; + } +} + +function form(obj: Record): string { + return Object.entries(obj).map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&'); +} + +async function stripePost(path: string, body: Record): Promise> { + const res = await fetch(`${API}${path}`, { + method: 'POST', + headers: { + authorization: `Bearer ${config.STRIPE_SECRET_KEY}`, + 'content-type': 'application/x-www-form-urlencoded', + }, + body: form(body), + }); + const data = (await res.json()) as Record; + if (!res.ok) { + const msg = (data.error as { message?: string } | undefined)?.message ?? `HTTP ${res.status}`; + throw new Error(`Stripe: ${msg}`); + } + return data; +} + +export async function createCheckoutSession(opts: { + tier: Tier; userId: string; customerId?: string | null; email?: string; +}): Promise { + const price = priceForTier(opts.tier); + if (!price) throw new Error(`No Stripe price configured for tier "${opts.tier}"`); + const base = config.PUBLIC_BASE_URL; + const body: Record = { + mode: 'subscription', + 'line_items[0][price]': price, + 'line_items[0][quantity]': '1', + success_url: `${base}/app?checkout=success`, + cancel_url: `${base}/app?checkout=cancel`, + client_reference_id: opts.userId, + 'metadata[user_id]': opts.userId, + 'metadata[tier]': opts.tier, + 'subscription_data[metadata][user_id]': opts.userId, + 'subscription_data[metadata][tier]': opts.tier, + }; + if (opts.customerId) body.customer = opts.customerId; + else if (opts.email) body.customer_email = opts.email; + const session = await stripePost('/checkout/sessions', body); + return session.url as string; +} + +export async function createPortalSession(customerId: string): Promise { + const session = await stripePost('/billing_portal/sessions', { + customer: customerId, + return_url: `${config.PUBLIC_BASE_URL}/app`, + }); + return session.url as string; +} + +/** Verify a Stripe webhook signature against the raw request body. */ +export function verifyWebhook(rawBody: Buffer, sigHeader: string): boolean { + if (!config.STRIPE_WEBHOOK_SECRET) return false; + // Header: t=timestamp,v1=signature[,v1=...] + const parts = Object.fromEntries( + sigHeader.split(',').map((kv) => kv.split('=') as [string, string]), + ) as { t?: string; v1?: string }; + if (!parts.t || !parts.v1) return false; + const signedPayload = `${parts.t}.${rawBody.toString('utf8')}`; + const expected = createHmac('sha256', config.STRIPE_WEBHOOK_SECRET).update(signedPayload).digest('hex'); + const a = Buffer.from(expected); + const b = Buffer.from(parts.v1); + return a.length === b.length && timingSafeEqual(a, b); +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..7a774b1 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,94 @@ +import { z } from 'zod'; +import { existsSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const envFile = resolve(process.cwd(), '.env'); +if (existsSync(envFile)) { + try { + process.loadEnvFile(envFile); + } catch { + // Node < 20.12 — fall through; user must pass --env-file or set vars directly. + } +} + +// Parse a boolean from an env string. z.coerce.boolean() is unusable here: +// it does Boolean(v), so the string "false" (any non-empty string) becomes +// true. This treats the usual falsy spellings as false. +const boolish = (def: boolean) => + z.preprocess((v) => { + if (v === undefined || v === null || v === '') return def; + if (typeof v === 'boolean') return v; + const s = String(v).trim().toLowerCase(); + if (['1', 'true', 'yes', 'on'].includes(s)) return true; + if (['0', 'false', 'no', 'off'].includes(s)) return false; + return def; + }, z.boolean()); + +const schema = z.object({ + NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), + PORT: z.coerce.number().int().positive().default(3017), + HOST: z.string().default('0.0.0.0'), + LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'), + + DATABASE_URL: z.string().default('sqlite:./data/wpide.db'), + + OPENAI_API_KEY: z.string().optional().default(''), + ANTHROPIC_API_KEY: z.string().optional().default(''), + XAI_API_KEY: z.string().optional().default(''), + DEEPSEEK_API_KEY: z.string().optional().default(''), + + LICENSE_SIGNING_SECRET: z.string().min(16).default('dev-only-do-not-use-in-prod-xxxxx'), + ALLOWED_ORIGINS: z.string().default('*'), + + // Disable TLS certificate verification for outbound LLM API calls. + // Needed on dev machines behind a VPN that MITMs HTTPS with its own + // root CA (browser trusts it; Node's CA bundle does not). Defaults + // ON in dev because that's the common case here; flip OFF in prod. + ALLOW_INSECURE_TLS: boolish(true), + + // --- SaaS platform --- + // Public base URL of this server (used for OAuth redirects, dashboard + // links). e.g. https://api.yourdomain.com + PUBLIC_BASE_URL: z.string().default('http://127.0.0.1:3017'), + // Session JWT signing secret (dashboard cookies). Set a long random + // value in prod. + JWT_SECRET: z.string().min(16).default('dev-jwt-secret-change-me-in-production'), + // If true, brand-new accounts get an active "basic" subscription with + // no payment (free tier). If false, they must subscribe to use the + // server. + FREE_TIER_ACTIVE: boolish(true), + + // If true, runs require a valid license key with an active + // subscription. If false (dev default), gating is skipped and every + // run uses DEV_DEFAULT_TIER — so local testing needs no account. + REQUIRE_LICENSE: boolish(false), + DEV_DEFAULT_TIER: z.enum(['basic', 'pro', 'max']).default('max'), + + // Stripe (billing). Leave blank to disable billing endpoints. + STRIPE_SECRET_KEY: z.string().optional().default(''), + STRIPE_WEBHOOK_SECRET: z.string().optional().default(''), + STRIPE_PRICE_BASIC: z.string().optional().default(''), + STRIPE_PRICE_PRO: z.string().optional().default(''), + STRIPE_PRICE_MAX: z.string().optional().default(''), + + // OAuth (leave blank to hide that provider's button). + GOOGLE_CLIENT_ID: z.string().optional().default(''), + GOOGLE_CLIENT_SECRET: z.string().optional().default(''), + GITHUB_CLIENT_ID: z.string().optional().default(''), + GITHUB_CLIENT_SECRET: z.string().optional().default(''), +}); + +const parsed = schema.safeParse(process.env); +if (!parsed.success) { + console.error('Invalid environment configuration:'); + console.error(parsed.error.flatten().fieldErrors); + process.exit(1); +} + +export const config = Object.freeze({ + ...parsed.data, + isDev: parsed.data.NODE_ENV === 'development', + isProd: parsed.data.NODE_ENV === 'production', +}); + +export type Config = typeof config; diff --git a/src/db/pool.ts b/src/db/pool.ts new file mode 100644 index 0000000..49b4fb3 --- /dev/null +++ b/src/db/pool.ts @@ -0,0 +1,159 @@ +import Database from 'better-sqlite3'; +import { mkdirSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { config } from '../config.js'; +import { logger } from '../lib/logger.js'; + +export interface DbPool { + driver: 'sqlite' | 'postgres'; + exec(sql: string): void; + prepare(sql: string): Database.Statement; + close(): void; +} + +let pool: DbPool | null = null; + +export function getDb(): DbPool { + if (pool) return pool; + + const url = config.DATABASE_URL; + + if (url.startsWith('sqlite:')) { + const filePath = url.replace(/^sqlite:/, ''); + const abs = resolve(process.cwd(), filePath); + mkdirSync(dirname(abs), { recursive: true }); + const db = new Database(abs); + db.pragma('journal_mode = WAL'); + db.pragma('foreign_keys = ON'); + logger.info({ driver: 'sqlite', path: abs }, 'Database initialised'); + pool = { + driver: 'sqlite', + exec: (sql) => { db.exec(sql); }, + prepare: (sql) => db.prepare(sql), + close: () => db.close(), + }; + return pool; + } + + if (url.startsWith('postgres://') || url.startsWith('postgresql://')) { + throw new Error('Postgres driver not yet wired — install pg and implement in step 8.'); + } + + throw new Error(`Unsupported DATABASE_URL scheme: ${url}`); +} + +export function runMigrations(): void { + const db = getDb(); + db.exec(` + CREATE TABLE IF NOT EXISTS schema_version ( + version INTEGER PRIMARY KEY, + applied_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + `); + const current = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number | null }; + const ver = current.v ?? 0; + + if (ver < 1) { + db.exec(` + CREATE TABLE IF NOT EXISTS runs ( + run_id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + goal TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_runs_session ON runs(session_id); + INSERT INTO schema_version (version) VALUES (1); + `); + logger.info('Applied migration v1'); + } + + if (ver < 2) { + db.exec(` + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + email TEXT UNIQUE NOT NULL, + password_hash TEXT, + status TEXT NOT NULL DEFAULT 'active', + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS oauth_identities ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + provider TEXT NOT NULL, + provider_uid TEXT NOT NULL, + email TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(provider, provider_uid) + ); + CREATE TABLE IF NOT EXISTS licenses ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + key TEXT UNIQUE NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + last_seen_at TEXT + ); + CREATE INDEX IF NOT EXISTS idx_licenses_user ON licenses(user_id); + CREATE TABLE IF NOT EXISTS subscriptions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL UNIQUE, + tier TEXT NOT NULL DEFAULT 'basic', + status TEXT NOT NULL DEFAULT 'none', + stripe_customer_id TEXT, + stripe_sub_id TEXT, + current_period_end TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS sites ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + site_url TEXT NOT NULL, + first_seen TEXT NOT NULL DEFAULT (datetime('now')), + last_seen TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(user_id, site_url) + ); + ALTER TABLE runs ADD COLUMN user_id TEXT; + ALTER TABLE runs ADD COLUMN tier TEXT; + ALTER TABLE runs ADD COLUMN model TEXT; + INSERT INTO schema_version (version) VALUES (2); + `); + logger.info('Applied migration v2 (accounts, licenses, subscriptions, sites)'); + } + + if (ver < 3) { + db.exec(` + CREATE TABLE IF NOT EXISTS conversation_turns ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + user_id TEXT, + role TEXT NOT NULL, + content TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_turns_session ON conversation_turns(session_id, created_at); + -- Teams / multi-tenant + CREATE TABLE IF NOT EXISTS orgs ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + owner_user_id TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS org_members ( + id TEXT PRIMARY KEY, + org_id TEXT NOT NULL, + user_id TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'member', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(org_id, user_id) + ); + CREATE INDEX IF NOT EXISTS idx_members_org ON org_members(org_id); + CREATE INDEX IF NOT EXISTS idx_members_user ON org_members(user_id); + ALTER TABLE subscriptions ADD COLUMN org_id TEXT; + INSERT INTO schema_version (version) VALUES (3); + `); + logger.info('Applied migration v3 (conversation memory + teams)'); + } +} diff --git a/src/db/runs.ts b/src/db/runs.ts new file mode 100644 index 0000000..32dfa87 --- /dev/null +++ b/src/db/runs.ts @@ -0,0 +1,33 @@ +import { randomUUID } from 'node:crypto'; +import { getDb } from './pool.js'; + +export interface RunRow { + run_id: string; + session_id: string; + status: 'pending' | 'running' | 'completed' | 'failed'; + goal: string; + created_at: string; + updated_at: string; +} + +export function createRun(input: { goal: string; session_id?: string; run_id?: string }): RunRow { + const db = getDb(); + const run_id = input.run_id ?? randomUUID(); + const session_id = input.session_id ?? randomUUID(); + db.prepare(` + INSERT INTO runs (run_id, session_id, status, goal) + VALUES (?, ?, 'pending', ?) + `).run(run_id, session_id, input.goal); + return getRun(run_id)!; +} + +export function getRun(run_id: string): RunRow | null { + const row = getDb().prepare(`SELECT * FROM runs WHERE run_id = ?`).get(run_id); + return (row as RunRow | undefined) ?? null; +} + +export function updateRunStatus(run_id: string, status: RunRow['status']): void { + getDb().prepare(` + UPDATE runs SET status = ?, updated_at = datetime('now') WHERE run_id = ? + `).run(status, run_id); +} diff --git a/src/lib/crypto.ts b/src/lib/crypto.ts new file mode 100644 index 0000000..d2b4d10 --- /dev/null +++ b/src/lib/crypto.ts @@ -0,0 +1,81 @@ +/** + * Self-contained crypto helpers — no native deps. + * - Password hashing via scrypt (built-in). + * - HS256 JWTs for dashboard sessions. + * - Random license keys / ids. + */ + +import { + randomBytes, + randomUUID, + scryptSync, + timingSafeEqual, + createHmac, +} from 'node:crypto'; +import { config } from '../config.js'; + +// ---- password hashing (scrypt) ---- + +export function hashPassword(password: string): string { + const salt = randomBytes(16); + const hash = scryptSync(password, salt, 64); + return `scrypt$${salt.toString('hex')}$${hash.toString('hex')}`; +} + +export function verifyPassword(password: string, stored: string): boolean { + const parts = stored.split('$'); + if (parts.length !== 3 || parts[0] !== 'scrypt') return false; + const salt = Buffer.from(parts[1]!, 'hex'); + const expected = Buffer.from(parts[2]!, 'hex'); + const actual = scryptSync(password, salt, expected.length); + return actual.length === expected.length && timingSafeEqual(actual, expected); +} + +// ---- ids / keys ---- + +export function newId(): string { + return randomUUID(); +} + +/** License key like "wpide_live_<32hex>". */ +export function newLicenseKey(): string { + return `wpide_live_${randomBytes(16).toString('hex')}`; +} + +/** Opaque random token (oauth state, etc.). */ +export function randomToken(bytes = 24): string { + return randomBytes(bytes).toString('base64url'); +} + +// ---- minimal HS256 JWT ---- + +function b64url(input: Buffer | string): string { + return Buffer.from(input).toString('base64url'); +} + +export function signJwt(payload: Record, ttlSeconds = 60 * 60 * 24 * 30): string { + const header = { alg: 'HS256', typ: 'JWT' }; + const now = Math.floor(Date.now() / 1000); + const body = { ...payload, iat: now, exp: now + ttlSeconds }; + const head = b64url(JSON.stringify(header)); + const data = b64url(JSON.stringify(body)); + const sig = createHmac('sha256', config.JWT_SECRET).update(`${head}.${data}`).digest('base64url'); + return `${head}.${data}.${sig}`; +} + +export function verifyJwt>(token: string): T | null { + const parts = token.split('.'); + if (parts.length !== 3) return null; + const [head, data, sig] = parts as [string, string, string]; + const expected = createHmac('sha256', config.JWT_SECRET).update(`${head}.${data}`).digest('base64url'); + const a = Buffer.from(sig); + const b = Buffer.from(expected); + if (a.length !== b.length || !timingSafeEqual(a, b)) return null; + try { + const payload = JSON.parse(Buffer.from(data, 'base64url').toString('utf8')) as { exp?: number }; + if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) return null; + return payload as T; + } catch { + return null; + } +} diff --git a/src/lib/logger.ts b/src/lib/logger.ts new file mode 100644 index 0000000..fd6d8c5 --- /dev/null +++ b/src/lib/logger.ts @@ -0,0 +1,16 @@ +import pino from 'pino'; +import { config } from '../config.js'; + +export const logger = pino({ + level: config.LOG_LEVEL, + transport: config.isDev + ? { + target: 'pino-pretty', + options: { + colorize: true, + translateTime: 'HH:MM:ss.l', + ignore: 'pid,hostname', + }, + } + : undefined, +}); diff --git a/src/lib/session.ts b/src/lib/session.ts new file mode 100644 index 0000000..ce6a4bc --- /dev/null +++ b/src/lib/session.ts @@ -0,0 +1,38 @@ +/** + * Dashboard session cookies (JWT in an httpOnly cookie). No cookie + * library — set via Set-Cookie header, read by parsing the Cookie header. + */ + +import type { FastifyReply, FastifyRequest } from 'fastify'; +import { signJwt, verifyJwt } from './crypto.js'; +import { config } from '../config.js'; + +const COOKIE = 'wpide_session'; + +export function setSession(reply: FastifyReply, userId: string): void { + const token = signJwt({ sub: userId }); + const secure = config.PUBLIC_BASE_URL.startsWith('https'); + const parts = [ + `${COOKIE}=${token}`, + 'Path=/', + 'HttpOnly', + 'SameSite=Lax', + `Max-Age=${60 * 60 * 24 * 30}`, + ]; + if (secure) parts.push('Secure'); + reply.header('set-cookie', parts.join('; ')); +} + +export function clearSession(reply: FastifyReply): void { + reply.header('set-cookie', `${COOKIE}=; Path=/; HttpOnly; Max-Age=0`); +} + +export function getSessionUserId(req: FastifyRequest): string | null { + const raw = req.headers.cookie; + if (!raw) return null; + const match = raw.split(';').map((c) => c.trim()).find((c) => c.startsWith(`${COOKIE}=`)); + if (!match) return null; + const token = match.slice(COOKIE.length + 1); + const payload = verifyJwt<{ sub: string }>(token); + return payload?.sub ?? null; +} diff --git a/src/orchestrator/index.ts b/src/orchestrator/index.ts new file mode 100644 index 0000000..48f8e7a --- /dev/null +++ b/src/orchestrator/index.ts @@ -0,0 +1,451 @@ +/** + * Orchestrator entrypoint. Port of WP_IDE_AI_Orchestrator::process(). + * + * Phase 1 (step 4 — this file): greeting + simple paths only. Agentic + * path returns a placeholder result that says "agentic loop lands in + * step 5"; the local PHP orchestrator stays the canonical handler for + * tool-driven runs until step 5 ships. + */ + +import type { + RunRequest, + RunResponse, + ExecutionMeta, + StepHistoryEntry, + ToolResultEntry, +} from './types.js'; +import { classify } from './router.js'; +import { pickProvider, defaultModelFor } from '../providers/index.js'; +import { createRun, updateRunStatus } from '../db/runs.js'; +import { logger } from '../lib/logger.js'; +import { toolsForOpenAI } from '../tools/manifest.js'; +import { runToolOnSite } from '../site-callback/client.js'; +import type { OpenAIChatMessage } from '../providers/openai.js'; +import { createRunState, getRunState, touch, addEvent, awaitToolResult, type RunState } from './registry.js'; +import { randomUUID } from 'node:crypto'; +import { routeModel } from '../routing/policy.js'; +import type { Tier } from '../accounts/store.js'; +import { mergeContext, saveTurn } from './memory.js'; + +const MAX_REACTIVE_STEPS = 40; + +const GREETINGS = [ + 'Hello! How can I help you today?', + 'Hi there! What would you like to do?', + 'Hey! Ready to help — what do you need?', +]; + +export async function process_request(req: RunRequest, liveState?: RunState): Promise { + const started = Date.now(); + const opts = req.options ?? {}; + const goal = (req.goal ?? '').trim(); + + const run = createRun({ + goal, + session_id: liveState?.session_id ?? opts.session_id, + run_id: liveState?.run_id ?? opts.run_id, + }); + updateRunStatus(run.run_id, 'running'); + + // Server-side memory: prepend recalled turns for this session so the + // run has context even if the plugin sent little history. + req.context = mergeContext(run.session_id, req.context ?? []); + + const route = classify(goal, req.context ?? [], req.tools_manifest); + // Share the step/tool arrays with the live registry state so + // GET /v1/runs/:id/status reflects progress as it happens. + const history: StepHistoryEntry[] = liveState ? liveState.steps : []; + const status_messages: ExecutionMeta['status_messages'] = []; + const tool_results: ToolResultEntry[] = []; + const tools_used: string[] = liveState ? liveState.tools_used : []; + + status_messages.push({ + message: 'Classifying request.', + stage: 'planning', + at: new Date().toISOString(), + }); + + let success = true; + let content = ''; + let providerUsed: string | undefined; + let modelUsed: string | undefined; + + try { + if (route === 'greeting') { + content = GREETINGS[Math.floor(Math.random() * GREETINGS.length)]!; + } else if (route === 'simple') { + const result = await handle_simple(req); + content = result.content; + providerUsed = result.provider; + modelUsed = result.model; + history.push({ + step: 1, + type: 'llm_call', + at: new Date().toISOString(), + detail: { provider: result.provider, model: result.model }, + }); + } else { + const result = await handle_agentic(req, run.run_id, history, status_messages, tool_results, tools_used, liveState); + content = result.content; + providerUsed = result.provider; + modelUsed = result.model; + success = result.success; + } + updateRunStatus(run.run_id, success ? 'completed' : 'failed'); + } catch (err) { + success = false; + content = `Server orchestrator error: ${(err as Error).message}`; + updateRunStatus(run.run_id, 'failed'); + logger.error({ err, run_id: run.run_id }, 'process_request failed'); + } + + status_messages.push({ + message: content, + stage: success ? 'completed' : 'failed', + at: new Date().toISOString(), + }); + + const execution: ExecutionMeta = { + runtime_mode: opts.mode ?? 'chat', + super_policy: opts.super_policy ?? 'ask', + carry_forward: opts.carry_forward ?? false, + run_id: run.run_id, + session_id: run.session_id, + mode: route, + steps: history.length, + total_steps: history.length, + history_carried_over: 0, + duration_ms: Date.now() - started, + cache_stats: {}, + tools_used, + files_read: [], + step_history: history, + status_messages, + final_content: content, + provider: providerUsed, + model: modelUsed, + }; + + const response: RunResponse = { + success, + content, + tool_results, + execution, + approval_payload: null, + }; + + // Persist the turn to server-side memory on success. + if (success && content) { + try { + saveTurn(run.session_id, 'user', goal, req.resolved_user_id); + saveTurn(run.session_id, 'assistant', content, req.resolved_user_id); + } catch (err) { + logger.warn({ err }, 'saveTurn failed'); + } + } + + if (liveState) { + liveState.partial_content = content; + liveState.response = response; + liveState.status = success ? 'completed' : 'failed'; + if (!success) liveState.error = content; + if (success) { + addEvent(liveState, 'done', { content, execution }); + } else { + addEvent(liveState, 'error', { content }); + } + touch(liveState); + } + + return response; +} + +/** + * Async entrypoint. Registers a run, kicks off process_request in the + * background (no awaiting), and returns the run_id immediately so the + * caller never blocks. Progress + final result are read via the + * registry (GET /v1/runs/:id/status). This removes the synchronous + * 300s timeout ceiling entirely. + */ +export function start_run_async(req: RunRequest): { run_id: string; session_id: string } { + const opts = req.options ?? {}; + const run_id = opts.run_id && opts.run_id !== '' ? opts.run_id : randomUUID(); + const session_id = opts.session_id && opts.session_id !== '' ? opts.session_id : randomUUID(); + const state = createRunState(run_id, session_id); + + // Fire and forget — errors are captured into the run state. + void process_request({ ...req, options: { ...opts, run_id, session_id } }, state) + .catch((err: unknown) => { + state.status = 'failed'; + state.error = (err as Error)?.message ?? 'unknown error'; + touch(state); + logger.error({ err, run_id }, 'async run crashed'); + }); + + return { run_id, session_id }; +} + +export { getRunState }; + +interface SimpleResult { content: string; provider: string; model: string; } +interface AgenticResult { content: string; provider: string; model: string; success: boolean; } + +async function handle_agentic( + req: RunRequest, + run_id: string, + history: StepHistoryEntry[], + status_messages: ExecutionMeta['status_messages'], + tool_results: ToolResultEntry[], + tools_used: string[], + liveState?: RunState, +): Promise { + const opts = req.options ?? {}; + // Tier-aware model routing when a subscription tier is resolved; + // otherwise fall back to the plugin pick / provider default. + const routed = req.tier ? routeModel(req.tier as Tier, opts, req.goal) : null; + const { name: providerName, client } = routed + ? pickProvider(routed.model, opts.provider) + : pickProvider(opts.model_override, opts.provider); + const model = routed ? routed.model : (opts.model_override ?? defaultModelFor(providerName)); + const thinking = routed + ? (routed.thinking + ? { type: 'enabled' as const, reasoning_effort: routed.reasoning_effort } + : { type: 'disabled' as const }) + : undefined; + + if (!client.isConfigured()) { + throw new Error( + `Provider "${providerName}" has no API key configured on the server. ` + + `Set the corresponding *_API_KEY in wpide-server/.env.`, + ); + } + if (!req.browser_tools && (!req.callback_url || !req.callback_secret)) { + throw new Error( + 'Agentic runs require callback_url + callback_secret (relay mode) ' + + 'or browser_tools=true (browser-direct mode) for tool execution.', + ); + } + + const tools = toolsForOpenAI(req.tools_manifest); + // Built-in server-side `wait` tool. Lets the agent pace long-running + // diagnostics without depending on a DB SLEEP() (SQLite has none). + // Handled in-loop below — never calls back to the plugin. + tools.push({ + type: 'function', + function: { + name: 'wait', + description: 'Pause for a number of seconds (server-side). Use for timed/long-running diagnostics. Max 30s per call.', + parameters: { + type: 'object', + properties: { seconds: { type: 'integer', description: 'Seconds to wait (1-30).' } }, + required: ['seconds'], + }, + }, + }); + + const messages: OpenAIChatMessage[] = [ + ...(req.context ?? []).map((m) => ({ + role: m.role, + content: m.content, + ...(m.name ? { name: m.name } : {}), + ...(m.tool_call_id ? { tool_call_id: m.tool_call_id } : {}), + })), + { role: 'user', content: req.goal }, + ]; + + let step = 0; + let lastContent = ''; + for (; step < MAX_REACTIVE_STEPS; step++) { + status_messages.push({ + message: `LLM step ${step + 1}`, + stage: 'running', + at: new Date().toISOString(), + }); + // Accumulate reasoning per step and emit it as ONE 'thinking' event + // after the call. Emitting per-token makes the chat render each + // reasoning token on its own line (the browser appends one element + // per thought event). One thought per step matches the local + // orchestrator and the browser's renderer. + let stepReasoning = ''; + const completion = await client.chatStream( + { + model, + messages, + temperature: 0.4, + tools: tools.length > 0 ? tools : undefined, + tool_choice: tools.length > 0 ? 'auto' : undefined, + ...(thinking ? { thinking } : {}), + }, + { + onToken: (t) => { + if (liveState) { + liveState.partial_content += t; + addEvent(liveState, 'token', t); + } + }, + onThinking: (t) => { + stepReasoning += t; + }, + }, + ); + if (liveState && stepReasoning.trim() !== '') { + addEvent(liveState, 'thinking', stepReasoning); + } + const choice = completion.choices[0]; + if (!choice) { + throw new Error('LLM returned no choices'); + } + const msg = choice.message; + history.push({ + step: step + 1, + type: 'llm_call', + at: new Date().toISOString(), + detail: { provider: providerName, model, finish_reason: choice.finish_reason }, + }); + + if (msg.content) lastContent = msg.content; + + // No tool calls → done. + if (!msg.tool_calls || msg.tool_calls.length === 0) { + return { content: msg.content ?? '', provider: providerName, model, success: true }; + } + + // Append the assistant message (with tool_calls) so subsequent + // tool messages can reference the call_ids correctly. + messages.push({ + role: 'assistant', + content: msg.content ?? '', + // OpenAI requires tool_calls inline on the assistant message; the + // OpenAIChatMessage interface here doesn't currently model that + // field, so cast through unknown to keep the wire payload valid. + } as OpenAIChatMessage); + // Mutate the just-pushed message to include tool_calls (typed off-path). + const assistantMsg = messages[messages.length - 1] as unknown as Record; + assistantMsg.tool_calls = msg.tool_calls; + // DeepSeek v4 thinking mode: echo reasoning_content back or the next + // request 400s with "reasoning_content ... must be passed back". + if (msg.reasoning_content) { + assistantMsg.reasoning_content = msg.reasoning_content; + } + + // Execute each tool call by POSTing to the plugin. + for (const call of msg.tool_calls) { + let args: Record = {}; + try { + args = JSON.parse(call.function.arguments || '{}') as Record; + } catch { + args = {}; + } + + history.push({ + step: step + 1, + type: 'tool_call', + at: new Date().toISOString(), + detail: { call_id: call.id, name: call.function.name }, + }); + if (!tools_used.includes(call.function.name)) tools_used.push(call.function.name); + logger.info( + { step: step + 1, tool: call.function.name, args: JSON.stringify(args).slice(0, 200) }, + `AGENT_STEP tool_call → ${call.function.name}`, + ); + if (liveState) addEvent(liveState, 'tool_call', { call_id: call.id, name: call.function.name, arguments: args }); + + // Built-in `wait` tool — handled server-side, no plugin callback. + let callRes: { ok: boolean; call_id: string; result?: unknown; error?: string }; + if (call.function.name === 'wait') { + const reqSec = Number(args.seconds ?? 0); + const secs = Math.max(1, Math.min(30, Number.isFinite(reqSec) ? reqSec : 1)); + await new Promise((r) => setTimeout(r, secs * 1000)); + callRes = { ok: true, call_id: call.id, result: { waited_seconds: secs } }; + } else if (req.browser_tools && liveState) { + // Browser-direct mode: the tool_call event was already emitted + // above; wait for the browser to run it and POST the result. + logger.info({ run_id, call_id: call.id, tool: call.function.name }, 'browser-direct: awaiting tool result from browser'); + const r = await awaitToolResult(liveState, call.id, 90_000); + if (!r.ok && r.error === 'tool_result_timeout') { + logger.warn({ run_id, call_id: call.id, tool: call.function.name }, 'browser-direct: tool result TIMED OUT after 90s (browser never posted)'); + } + callRes = { ok: r.ok, call_id: call.id, result: r.result, error: r.error }; + } else { + const callbackUrl = req.callback_url; + const callbackSecret = req.callback_secret; + if (!callbackUrl || !callbackSecret) { + // Already checked above, but TypeScript needs the narrowing here. + throw new Error('Missing callback_url or callback_secret mid-loop'); + } + callRes = await runToolOnSite(callbackUrl, run_id, callbackSecret, { + call_id: call.id, + name: call.function.name, + arguments: args, + }); + } + + tool_results.push({ + call_id: call.id, + name: call.function.name, + arguments: args, + ok: callRes.ok === true, + result: callRes.result, + error: callRes.error, + }); + if (liveState) addEvent(liveState, 'tool_result', { call_id: call.id, name: call.function.name, ok: callRes.ok === true }); + + messages.push({ + role: 'tool', + tool_call_id: call.id, + content: typeof callRes.result === 'string' + ? callRes.result + : JSON.stringify(callRes.ok ? (callRes.result ?? null) : { error: callRes.error }), + }); + } + } + + // Hit the step cap. + status_messages.push({ + message: `Reactive loop hit step cap (${MAX_REACTIVE_STEPS}).`, + stage: 'failed', + at: new Date().toISOString(), + }); + return { + content: lastContent || `Reactive loop hit the ${MAX_REACTIVE_STEPS}-step cap without finishing.`, + provider: providerName, + model, + success: false, + }; +} + +async function handle_simple(req: RunRequest): Promise { + const opts = req.options ?? {}; + const routed = req.tier ? routeModel(req.tier as Tier, opts, req.goal) : null; + const { name: providerName, client } = routed + ? pickProvider(routed.model, opts.provider) + : pickProvider(opts.model_override, opts.provider); + const model = routed ? routed.model : (opts.model_override ?? defaultModelFor(providerName)); + + if (!client.isConfigured()) { + throw new Error( + `Provider "${providerName}" has no API key configured on the server. ` + + `Set the corresponding *_API_KEY in wpide-server/.env.`, + ); + } + + const messages = [ + ...(req.context ?? []).map((m) => ({ + role: m.role, + content: m.content, + ...(m.name ? { name: m.name } : {}), + ...(m.tool_call_id ? { tool_call_id: m.tool_call_id } : {}), + })), + { role: 'user' as const, content: req.goal }, + ]; + + const completion = await client.chat({ + model, + messages, + temperature: 0.7, + }); + + const choice = completion.choices[0]; + const content = choice?.message?.content ?? ''; + return { content, provider: providerName, model }; +} diff --git a/src/orchestrator/memory.ts b/src/orchestrator/memory.ts new file mode 100644 index 0000000..756507d --- /dev/null +++ b/src/orchestrator/memory.ts @@ -0,0 +1,43 @@ +/** + * Server-side conversation memory. Persists each run's user goal + final + * assistant answer per session, and recalls the recent turns so a run has + * context even when the plugin sends little/no history. Independent of the + * plugin's own carry-forward. + */ + +import { getDb } from '../db/pool.js'; +import { newId } from '../lib/crypto.js'; +import type { RunContextMessage } from './types.js'; + +const MAX_RECALL = 12; // turns to prepend +const MAX_CONTENT = 4000; // clip very long turns + +export function saveTurn(session_id: string, role: 'user' | 'assistant', content: string, user_id?: string): void { + if (!session_id || !content) return; + getDb().prepare( + 'INSERT INTO conversation_turns (id, session_id, user_id, role, content) VALUES (?,?,?,?,?)', + ).run(newId(), session_id, user_id ?? null, role, content.slice(0, MAX_CONTENT)); +} + +export function recallTurns(session_id: string, limit = MAX_RECALL): RunContextMessage[] { + if (!session_id) return []; + const rows = getDb().prepare( + 'SELECT role, content FROM conversation_turns WHERE session_id = ? ORDER BY created_at DESC LIMIT ?', + ).all(session_id, limit) as { role: string; content: string }[]; + return rows + .reverse() + .map((r) => ({ role: r.role === 'assistant' ? 'assistant' : 'user', content: r.content }) as RunContextMessage); +} + +/** + * Merge recalled memory with the context the plugin sent, de-duplicating + * so we don't double-feed turns the plugin already included. Memory is + * prepended (older), then the plugin's context (newer). + */ +export function mergeContext(session_id: string, pluginContext: RunContextMessage[]): RunContextMessage[] { + const recalled = recallTurns(session_id); + if (recalled.length === 0) return pluginContext; + const seen = new Set(pluginContext.map((m) => `${m.role}:${m.content.slice(0, 80)}`)); + const fromMemory = recalled.filter((m) => !seen.has(`${m.role}:${m.content.slice(0, 80)}`)); + return [...fromMemory, ...pluginContext]; +} diff --git a/src/orchestrator/registry.ts b/src/orchestrator/registry.ts new file mode 100644 index 0000000..4853395 --- /dev/null +++ b/src/orchestrator/registry.ts @@ -0,0 +1,119 @@ +/** + * In-memory registry of async runs. Lets POST /v1/runs/start return a + * run_id immediately while the orchestrator works in the background, and + * lets GET /v1/runs/:id/status report live progress + the final result. + * + * In-memory is fine for a single-process dev server; a multi-instance + * prod deploy would back this with Redis/Postgres. Old finished runs are + * pruned so the map doesn't grow unbounded. + */ + +import type { RunResponse, StepHistoryEntry } from './types.js'; + +export type RunStatus = 'running' | 'completed' | 'failed'; + +export type RunEventType = 'token' | 'thinking' | 'status' | 'tool_call' | 'tool_result' | 'done' | 'error'; + +export interface RunEvent { + seq: number; + type: RunEventType; + data: unknown; + at: number; +} + +export interface RunState { + run_id: string; + session_id: string; + status: RunStatus; + started_at: number; + updated_at: number; + steps: StepHistoryEntry[]; + tools_used: string[]; + /** Latest assistant text seen so far (partial until done). */ + partial_content: string; + /** Final payload once status !== 'running'. */ + response?: RunResponse; + error?: string; + /** Ordered event buffer for SSE streaming + replay on reconnect. */ + events: RunEvent[]; + _seq: number; + /** Browser-direct tool mode: resolvers awaiting a tool_result POST. */ + pendingTools: Map void>; +} + +export interface ToolCbResult { ok: boolean; result?: unknown; error?: string } + +/** Append an event to a run's buffer (consumed by the SSE endpoint). */ +export function addEvent(state: RunState, type: RunEventType, data: unknown): void { + state._seq += 1; + state.events.push({ seq: state._seq, type, data, at: Date.now() }); + // Cap the buffer so a very long run doesn't grow unbounded; keep the + // tail (SSE consumers read incrementally, slow late-joiners lose head). + if (state.events.length > 5000) { + state.events.splice(0, state.events.length - 5000); + } + state.updated_at = Date.now(); +} + +const runs = new Map(); +const TTL_MS = 30 * 60 * 1000; // keep finished runs 30 min for late polls + +export function createRunState(run_id: string, session_id: string): RunState { + const now = Date.now(); + const state: RunState = { + run_id, + session_id, + status: 'running', + started_at: now, + updated_at: now, + steps: [], + tools_used: [], + partial_content: '', + events: [], + _seq: 0, + pendingTools: new Map(), + }; + runs.set(run_id, state); + pruneOld(); + return state; +} + +export function getRunState(run_id: string): RunState | undefined { + return runs.get(run_id); +} + +export function touch(state: RunState): void { + state.updated_at = Date.now(); +} + +/** + * Browser-direct tool mode: the loop emits a tool_call SSE event and + * awaits this promise; the browser runs the tool locally and POSTs the + * result to /v1/runs/:id/tool_result, which resolves it. + */ +export function awaitToolResult(state: RunState, callId: string, timeoutMs: number): Promise { + return new Promise((resolve) => { + const timer = setTimeout(() => { + state.pendingTools.delete(callId); + resolve({ ok: false, error: 'tool_result_timeout' }); + }, timeoutMs); + state.pendingTools.set(callId, (r) => { clearTimeout(timer); resolve(r); }); + }); +} + +export function resolveToolResult(state: RunState, callId: string, result: ToolCbResult): boolean { + const fn = state.pendingTools.get(callId); + if (!fn) return false; + state.pendingTools.delete(callId); + fn(result); + return true; +} + +function pruneOld(): void { + const cutoff = Date.now() - TTL_MS; + for (const [id, s] of runs) { + if (s.status !== 'running' && s.updated_at < cutoff) { + runs.delete(id); + } + } +} diff --git a/src/orchestrator/router.ts b/src/orchestrator/router.ts new file mode 100644 index 0000000..945f67c --- /dev/null +++ b/src/orchestrator/router.ts @@ -0,0 +1,39 @@ +/** + * Tiny classifier — port of WP_IDE_AI_Router::classify(). + * Decides greeting / simple / agentic based on the goal string and the + * presence of a tools manifest. Cheap heuristics; no LLM call. + */ + +import type { RunContextMessage, ToolDescriptor } from './types.js'; + +const GREETING_RE = /^\s*(hi|hello|hey|howdy|hola|yo|sup|good\s+(morning|afternoon|evening))[\s!?.,]*$/i; + +const AGENTIC_KEYWORDS = [ + 'read', 'write', 'edit', 'modify', 'create', 'update', 'delete', + 'fix', 'add', 'remove', 'install', 'configure', + 'file', 'plugin', 'theme', 'post', 'page', 'option', 'database', + 'wp-config', 'functions.php', +]; + +export type RouteKind = 'greeting' | 'simple' | 'agentic'; + +export function classify( + goal: string, + _context: RunContextMessage[], + tools?: ToolDescriptor[], +): RouteKind { + const trimmed = goal.trim(); + if (trimmed.length === 0) return 'simple'; + if (GREETING_RE.test(trimmed)) return 'greeting'; + + const hasTools = Array.isArray(tools) && tools.length > 0; + if (!hasTools) return 'simple'; + + const lower = trimmed.toLowerCase(); + for (const kw of AGENTIC_KEYWORDS) { + if (lower.includes(kw)) return 'agentic'; + } + // Long requests with tools available default to agentic. + if (trimmed.length > 120) return 'agentic'; + return 'simple'; +} diff --git a/src/orchestrator/types.ts b/src/orchestrator/types.ts new file mode 100644 index 0000000..5f74304 --- /dev/null +++ b/src/orchestrator/types.ts @@ -0,0 +1,97 @@ +/** + * Mirrors the shape contract that `wp_ide_process_agentic($goal, $context, $options)` + * uses in the plugin. Keep these typed in sync with PHP — the array + * keys must match exactly so the plugin's callers see identical data + * from both backends. + */ + +export interface RunContextMessage { + role: 'system' | 'user' | 'assistant' | 'tool'; + content: string; + name?: string; + tool_call_id?: string; +} + +export interface RunOptions { + session_id?: string; + run_id?: string; + provider?: string; + model_override?: string; + mode?: 'chat' | 'plan' | 'super'; + super_policy?: 'auto_apply' | 'ask' | 'plan_only'; + attachments?: unknown[]; + carry_forward?: boolean; + is_continuation?: boolean; + agent_id?: string; +} + +/** Schema the plugin sends so the server knows what tools exist locally. */ +export interface ToolDescriptor { + name: string; + description?: string; + input_schema?: unknown; +} + +export interface RunRequest { + goal: string; + context: RunContextMessage[]; + options?: RunOptions; + tools_manifest?: ToolDescriptor[]; + callback_url?: string; // plugin's /wp-json/wp-ide/v1/tool-exec + callback_secret?: string; // per-run HMAC secret issued by plugin + license_key?: string; + site_url?: string; + // Browser-direct tool mode: when true, the server emits tool_call SSE + // events and waits for the browser to POST results, instead of calling + // back into the plugin's REST endpoint. Removes the long-lived request + // from the WP host entirely (cap-immune on any shared host). + browser_tools?: boolean; + // Server-injected after license resolution (not sent by the plugin). + tier?: 'basic' | 'pro' | 'max'; + resolved_user_id?: string; +} + +export interface ToolResultEntry { + call_id: string; + name: string; + arguments: Record; + ok: boolean; + result?: unknown; + error?: string; +} + +export interface StepHistoryEntry { + step: number; + type: 'llm_call' | 'tool_call' | 'status'; + at: string; + detail?: Record; +} + +export interface ExecutionMeta { + runtime_mode: string; + super_policy: string; + carry_forward: boolean; + run_id: string; + session_id: string; + mode: 'greeting' | 'simple' | 'agentic'; + steps: number; + total_steps: number; + history_carried_over: number; + duration_ms: number; + cache_stats: Record; + tools_used: string[]; + files_read: string[]; + step_history: StepHistoryEntry[]; + status_messages: { message: string; stage: string; at: string }[]; + final_content: string; + provider?: string; + model?: string; +} + +export interface RunResponse { + success: boolean; + content: string; + tool_results: ToolResultEntry[]; + execution: ExecutionMeta; + approval_payload: null | Record; +} diff --git a/src/providers/index.ts b/src/providers/index.ts new file mode 100644 index 0000000..f9d7e6d --- /dev/null +++ b/src/providers/index.ts @@ -0,0 +1,43 @@ +/** + * Provider router. Maps a model name (or explicit provider override) to + * the right concrete client. Phase 1: OpenAI only. Phase 2+: Anthropic, + * xAI, etc. + */ + +import { openai, deepseek, xai, type OpenAIClient } from './openai.js'; +import { config } from '../config.js'; +import { logger } from '../lib/logger.js'; + +export type ProviderName = 'openai' | 'anthropic' | 'xai' | 'deepseek'; + +export function pickProvider(model?: string, override?: string): { name: ProviderName; client: OpenAIClient } { + const explicit = (override ?? '').toLowerCase(); + if (explicit === 'openai') return { name: 'openai', client: openai }; + if (explicit === 'deepseek') return { name: 'deepseek', client: deepseek }; + if (explicit === 'xai' || explicit === 'grok') return { name: 'xai', client: xai }; + + if (model && /^deepseek/i.test(model)) return { name: 'deepseek', client: deepseek }; + if (model && /^grok/i.test(model)) return { name: 'xai', client: xai }; + if (model && /^(gpt-|o\d|chatgpt-)/i.test(model)) return { name: 'openai', client: openai }; + + // No explicit pick → first provider that has a key configured. + // Preference order: deepseek (cheap+capable) → xai → openai. + if (config.DEEPSEEK_API_KEY) return { name: 'deepseek', client: deepseek }; + if (config.XAI_API_KEY) return { name: 'xai', client: xai }; + if (config.OPENAI_API_KEY) return { name: 'openai', client: openai }; + + logger.warn('No provider API keys configured — defaulting to deepseek (will fail-fast).'); + return { name: 'deepseek', client: deepseek }; +} + +export function defaultModelFor(name: ProviderName): string { + switch (name) { + case 'deepseek': return 'deepseek-chat'; + case 'openai': return 'gpt-4o-mini'; + case 'anthropic': return 'claude-sonnet-4-6'; + case 'xai': return 'grok-4'; + } +} + +// Kept for backwards-compat with code that imported DEFAULT_MODEL. +export const DEFAULT_MODEL = 'deepseek-chat'; diff --git a/src/providers/openai.ts b/src/providers/openai.ts new file mode 100644 index 0000000..e7ed911 --- /dev/null +++ b/src/providers/openai.ts @@ -0,0 +1,287 @@ +/** + * Minimal OpenAI Chat Completions client over fetch. No SDK dependency. + * Supports the two things the simple path needs: synchronous completion + * and (later, in step 5) streaming + tool calls. + */ + +import { config } from '../config.js'; +import { logger } from '../lib/logger.js'; + +export interface OpenAIChatMessage { + role: 'system' | 'user' | 'assistant' | 'tool'; + content: string; + name?: string; + tool_call_id?: string; +} + +export interface OpenAIToolDef { + type: 'function'; + function: { + name: string; + description?: string; + parameters?: unknown; + }; +} + +export interface OpenAIChatRequest { + model: string; + messages: OpenAIChatMessage[]; + temperature?: number; + max_tokens?: number; + tools?: OpenAIToolDef[]; + tool_choice?: 'auto' | 'required' | 'none'; + // DeepSeek v4 thinking control (passthrough; ignored by other providers). + thinking?: { type: 'enabled' | 'disabled'; reasoning_effort?: 'high' | 'max' }; +} + +export interface OpenAIToolCall { + id: string; + type: 'function'; + function: { name: string; arguments: string }; +} + +export interface OpenAIChatChoice { + index: number; + message: { + role: 'assistant'; + content: string | null; + tool_calls?: OpenAIToolCall[]; + // DeepSeek v4 thinking mode returns this and REQUIRES it to be echoed + // back in the assistant message on the next turn, or it rejects the + // follow-up with HTTP 400 "reasoning_content ... must be passed back". + reasoning_content?: string | null; + }; + finish_reason: string; +} + +export interface OpenAIChatResponse { + id: string; + model: string; + choices: OpenAIChatChoice[]; + usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number }; +} + +const DEFAULT_BASE_URL = 'https://api.openai.com/v1'; + +export class OpenAIClient { + constructor( + private readonly apiKey: string = config.OPENAI_API_KEY, + private readonly baseUrl: string = DEFAULT_BASE_URL, + private readonly label: string = 'openai', + ) {} + + isConfigured(): boolean { + return this.apiKey.length > 0; + } + + async chat(req: OpenAIChatRequest, signal?: AbortSignal): Promise { + if (!this.isConfigured()) { + throw new Error(`${this.label.toUpperCase()}_API_KEY is not configured on the server.`); + } + const url = `${this.baseUrl}/chat/completions`; + const maxAttempts = 6; + const perAttemptTimeoutMs = 20_000; + let lastErr: unknown; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + const start = Date.now(); + // Per-attempt timeout: if the VPN hangs the connection, abort fast + // and retry rather than waiting on undici's long default. + const ac = new AbortController(); + const timer = setTimeout(() => ac.abort(), perAttemptTimeoutMs); + const combinedSignal = signal + ? AbortSignal.any([signal, ac.signal]) + : ac.signal; + try { + const res = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${this.apiKey}`, + }, + body: JSON.stringify(req), + signal: combinedSignal, + }); + clearTimeout(timer); + const ms = Date.now() - start; + if (!res.ok) { + const body = await res.text(); + // 4xx are real API errors (bad key, bad schema) — don't retry. + // 5xx and 429 are transient — retry. + const retryable = res.status >= 500 || res.status === 429; + logger.error({ provider: this.label, status: res.status, ms, attempt, body: body.slice(0, 500) }, 'chat error'); + if (retryable && attempt < maxAttempts) { + await delay(attempt * 1000); + continue; + } + throw new Error(`${this.label} HTTP ${res.status}: ${body.slice(0, 200)}`); + } + const data = (await res.json()) as OpenAIChatResponse; + logger.debug({ provider: this.label, ms, attempt, model: data.model, choices: data.choices.length }, 'chat ok'); + return data; + } catch (err) { + clearTimeout(timer); + lastErr = err; + // Network-level failure (connect timeout, DNS, TLS, per-attempt + // abort) — common on flaky VPNs. Retry with backoff. Don't retry + // if the caller's own signal aborted (real cancellation). + const msg = (err as Error)?.message ?? ''; + const name = (err as Error)?.name ?? ''; + const callerAborted = signal?.aborted === true; + const isNetwork = + !callerAborted && + (msg.includes('fetch failed') || msg.includes('timeout') || + msg.includes('ECONN') || name === 'AbortError' || name === 'TimeoutError'); + logger.warn({ provider: this.label, attempt, err: msg || name }, 'chat network error'); + if (isNetwork && attempt < maxAttempts) { + await delay(attempt * 1000); + continue; + } + throw err; + } + } + throw lastErr instanceof Error ? lastErr : new Error('chat failed'); + } + + /** + * Streaming chat. Parses SSE token deltas and calls handlers as tokens + * arrive; accumulates the full response (content, reasoning_content, + * tool_calls, usage) and returns it in the same shape as chat() so the + * orchestrator loop is unchanged. Connect-only retry: safe to retry + * while zero tokens have been received; never retries mid-stream. + */ + async chatStream( + req: OpenAIChatRequest, + handlers: { onToken?: (t: string) => void; onThinking?: (t: string) => void }, + signal?: AbortSignal, + ): Promise { + if (!this.isConfigured()) { + throw new Error(`${this.label.toUpperCase()}_API_KEY is not configured on the server.`); + } + const url = `${this.baseUrl}/chat/completions`; + const body = { ...req, stream: true, stream_options: { include_usage: true } }; + const maxAttempts = 4; + let lastErr: unknown; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + let receivedAny = false; + const ac = new AbortController(); + // Abort if the first byte doesn't arrive within 20s (connect hang). + let connectTimer: ReturnType | null = setTimeout(() => ac.abort(), 20_000); + const combined = signal ? AbortSignal.any([signal, ac.signal]) : ac.signal; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: `Bearer ${this.apiKey}` }, + body: JSON.stringify(body), + signal: combined, + }); + if (!res.ok) { + const errBody = await res.text(); + if (connectTimer) { clearTimeout(connectTimer); connectTimer = null; } + const retryable = res.status >= 500 || res.status === 429; + logger.error({ provider: this.label, status: res.status, attempt, body: errBody.slice(0, 500) }, 'chatStream error'); + if (retryable && attempt < maxAttempts) { await delay(attempt * 1000); continue; } + throw new Error(`${this.label} HTTP ${res.status}: ${errBody.slice(0, 200)}`); + } + if (!res.body) throw new Error('no response body for stream'); + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buf = ''; + let content = ''; + let reasoning = ''; + let model = req.model; + let usage: OpenAIChatResponse['usage']; + const toolAcc: Record = {}; + + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (!receivedAny) { receivedAny = true; if (connectTimer) { clearTimeout(connectTimer); connectTimer = null; } } + buf += decoder.decode(value, { stream: true }); + let nl: number; + while ((nl = buf.indexOf('\n')) >= 0) { + const line = buf.slice(0, nl).trim(); + buf = buf.slice(nl + 1); + if (!line.startsWith('data:')) continue; + const dataStr = line.slice(5).trim(); + if (dataStr === '' || dataStr === '[DONE]') continue; + let evt: { + model?: string; + usage?: OpenAIChatResponse['usage']; + choices?: { delta?: { content?: string; reasoning_content?: string; tool_calls?: { index: number; id?: string; function?: { name?: string; arguments?: string } }[] } }[]; + }; + try { evt = JSON.parse(dataStr); } catch { continue; } + if (evt.model) model = evt.model; + if (evt.usage) usage = evt.usage; + const delta = evt.choices?.[0]?.delta; + if (!delta) continue; + if (delta.content) { content += delta.content; handlers.onToken?.(delta.content); } + if (delta.reasoning_content) { reasoning += delta.reasoning_content; handlers.onThinking?.(delta.reasoning_content); } + if (Array.isArray(delta.tool_calls)) { + for (const tc of delta.tool_calls) { + const idx = tc.index ?? 0; + if (!toolAcc[idx]) toolAcc[idx] = { id: '', name: '', args: '' }; + if (tc.id) toolAcc[idx].id = tc.id; + if (tc.function?.name) toolAcc[idx].name += tc.function.name; + if (tc.function?.arguments) toolAcc[idx].args += tc.function.arguments; + } + } + } + } + if (connectTimer) { clearTimeout(connectTimer); connectTimer = null; } + + const tool_calls: OpenAIToolCall[] = Object.keys(toolAcc) + .sort((a, b) => Number(a) - Number(b)) + .map((k) => { + const t = toolAcc[Number(k)]!; + return { id: t.id, type: 'function' as const, function: { name: t.name, arguments: t.args } }; + }); + + const message: OpenAIChatChoice['message'] = { + role: 'assistant', + content: content || null, + ...(reasoning ? { reasoning_content: reasoning } : {}), + ...(tool_calls.length > 0 ? { tool_calls } : {}), + }; + return { + id: `stream-${Date.now()}`, + model, + choices: [{ index: 0, message, finish_reason: tool_calls.length > 0 ? 'tool_calls' : 'stop' }], + usage, + }; + } catch (err) { + if (connectTimer) { clearTimeout(connectTimer); connectTimer = null; } + lastErr = err; + const name = (err as Error)?.name ?? ''; + const msg = (err as Error)?.message ?? ''; + const callerAborted = signal?.aborted === true; + // Only retry if nothing was streamed yet — safe to restart. + const retryable = !receivedAny && !callerAborted && + (msg.includes('fetch failed') || msg.includes('timeout') || msg.includes('ECONN') || + name === 'AbortError' || name === 'TimeoutError'); + logger.warn({ provider: this.label, attempt, err: msg || name, receivedAny }, 'chatStream network error'); + if (retryable && attempt < maxAttempts) { await delay(attempt * 1000); continue; } + throw err; + } + } + throw lastErr instanceof Error ? lastErr : new Error('chatStream failed'); + } +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export const openai = new OpenAIClient(); +export const deepseek = new OpenAIClient( + config.DEEPSEEK_API_KEY, + 'https://api.deepseek.com/v1', + 'deepseek', +); +export const xai = new OpenAIClient( + config.XAI_API_KEY, + 'https://api.x.ai/v1', + 'xai', +); diff --git a/src/routes/auth.ts b/src/routes/auth.ts new file mode 100644 index 0000000..00db2f3 --- /dev/null +++ b/src/routes/auth.ts @@ -0,0 +1,52 @@ +import type { FastifyInstance } from 'fastify'; +import { registerWithPassword, loginWithPassword, setPassword } from '../accounts/service.js'; +import { getUserById, getLicensesForUser, getSubscription } from '../accounts/store.js'; +import { setSession, clearSession, getSessionUserId } from '../lib/session.js'; + +export async function authRoutes(app: FastifyInstance): Promise { + app.post('/v1/auth/register', async (req, reply) => { + const { email, password } = (req.body ?? {}) as { email?: string; password?: string }; + const res = registerWithPassword(String(email ?? ''), String(password ?? '')); + if (!res.ok) { reply.status(400); return { ok: false, error: res.error }; } + setSession(reply, res.user.id); + return { ok: true, user: { id: res.user.id, email: res.user.email } }; + }); + + app.post('/v1/auth/login', async (req, reply) => { + const { email, password } = (req.body ?? {}) as { email?: string; password?: string }; + const res = loginWithPassword(String(email ?? ''), String(password ?? '')); + if (!res.ok) { reply.status(401); return { ok: false, error: res.error }; } + setSession(reply, res.user.id); + return { ok: true, user: { id: res.user.id, email: res.user.email } }; + }); + + app.post('/v1/auth/logout', async (_req, reply) => { + clearSession(reply); + return { ok: true }; + }); + + app.post('/v1/auth/set-password', async (req, reply) => { + const uid = getSessionUserId(req); + if (!uid) { reply.status(401); return { ok: false, error: 'not_authenticated' }; } + const { password } = (req.body ?? {}) as { password?: string }; + const res = setPassword(uid, String(password ?? '')); + if (!res.ok) { reply.status(400); return { ok: false, error: res.error }; } + return { ok: true }; + }); + + // Current account: profile, license keys, subscription. Powers the dashboard. + app.get('/v1/auth/me', async (req, reply) => { + const uid = getSessionUserId(req); + if (!uid) { reply.status(401); return { ok: false, error: 'not_authenticated' }; } + const user = getUserById(uid); + if (!user) { reply.status(404); return { ok: false, error: 'user_not_found' }; } + const licenses = getLicensesForUser(uid).map((l) => ({ key: l.key, status: l.status })); + const sub = getSubscription(uid); + return { + ok: true, + user: { id: user.id, email: user.email, has_password: !!user.password_hash }, + licenses, + subscription: sub ? { tier: sub.tier, status: sub.status, current_period_end: sub.current_period_end } : null, + }; + }); +} diff --git a/src/routes/billing.ts b/src/routes/billing.ts new file mode 100644 index 0000000..b177133 --- /dev/null +++ b/src/routes/billing.ts @@ -0,0 +1,100 @@ +import type { FastifyInstance, FastifyRequest } from 'fastify'; +import { getSessionUserId } from '../lib/session.js'; +import { getUserById, getSubscription, upsertSubscription, findUserByStripeCustomer } from '../accounts/store.js'; +import type { Tier, SubStatus } from '../accounts/store.js'; +import { createCheckoutSession, createPortalSession, verifyWebhook, isStripeConfigured } from '../billing/stripe.js'; +import { logger } from '../lib/logger.js'; + +const VALID_TIERS: Tier[] = ['basic', 'pro', 'max']; + +export async function billingRoutes(app: FastifyInstance): Promise { + // Start a subscription checkout for a tier. + app.post('/v1/billing/checkout', async (req, reply) => { + if (!isStripeConfigured()) { reply.status(503); return { ok: false, error: 'billing_not_configured' }; } + const uid = getSessionUserId(req); + if (!uid) { reply.status(401); return { ok: false, error: 'not_authenticated' }; } + const user = getUserById(uid); + if (!user) { reply.status(404); return { ok: false, error: 'user_not_found' }; } + const { tier } = (req.body ?? {}) as { tier?: string }; + if (!VALID_TIERS.includes(tier as Tier)) { reply.status(400); return { ok: false, error: 'invalid_tier' }; } + const sub = getSubscription(uid); + try { + const url = await createCheckoutSession({ + tier: tier as Tier, userId: uid, customerId: sub?.stripe_customer_id, email: user.email, + }); + return { ok: true, url }; + } catch (err) { + logger.error({ err }, 'checkout failed'); + reply.status(500); return { ok: false, error: (err as Error).message }; + } + }); + + // Open the Stripe customer portal (manage/cancel). + app.post('/v1/billing/portal', async (req, reply) => { + if (!isStripeConfigured()) { reply.status(503); return { ok: false, error: 'billing_not_configured' }; } + const uid = getSessionUserId(req); + if (!uid) { reply.status(401); return { ok: false, error: 'not_authenticated' }; } + const sub = getSubscription(uid); + if (!sub?.stripe_customer_id) { reply.status(400); return { ok: false, error: 'no_customer' }; } + try { + const url = await createPortalSession(sub.stripe_customer_id); + return { ok: true, url }; + } catch (err) { + reply.status(500); return { ok: false, error: (err as Error).message }; + } + }); + + // Stripe webhook → mirror subscription state into our DB. + app.post('/v1/billing/webhook', async (req: FastifyRequest, reply) => { + const raw = (req as FastifyRequest & { rawBody?: Buffer }).rawBody; + const sig = req.headers['stripe-signature']; + if (!raw || typeof sig !== 'string' || !verifyWebhook(raw, sig)) { + reply.status(400); return { ok: false, error: 'invalid_signature' }; + } + const event = JSON.parse(raw.toString('utf8')) as { type: string; data: { object: Record } }; + const obj = event.data.object; + + try { + switch (event.type) { + case 'checkout.session.completed': { + const userId = (obj.client_reference_id as string) || ((obj.metadata as Record | undefined)?.user_id ?? ''); + const tier = ((obj.metadata as Record | undefined)?.tier ?? 'basic') as Tier; + if (userId) { + upsertSubscription(userId, { + tier, status: 'active', + stripe_customer_id: (obj.customer as string) ?? null, + stripe_sub_id: (obj.subscription as string) ?? null, + }); + } + break; + } + case 'customer.subscription.updated': + case 'customer.subscription.created': + case 'customer.subscription.deleted': { + const customerId = obj.customer as string; + const userId = findUserByStripeCustomer(customerId) + || ((obj.metadata as Record | undefined)?.user_id ?? ''); + if (userId) { + const stripeStatus = obj.status as string; // active|past_due|canceled|trialing|... + const status: SubStatus = event.type === 'customer.subscription.deleted' + ? 'canceled' + : (['active', 'trialing', 'past_due', 'canceled'].includes(stripeStatus) ? stripeStatus as SubStatus : 'none'); + const tier = ((obj.metadata as Record | undefined)?.tier) as Tier | undefined; + const periodEnd = obj.current_period_end ? new Date((obj.current_period_end as number) * 1000).toISOString() : undefined; + upsertSubscription(userId, { + ...(tier ? { tier } : {}), + status, + stripe_customer_id: customerId, + stripe_sub_id: obj.id as string, + ...(periodEnd ? { current_period_end: periodEnd } : {}), + }); + } + break; + } + } + } catch (err) { + logger.error({ err, type: event.type }, 'webhook handler error'); + } + return { ok: true, received: true }; + }); +} diff --git a/src/routes/dashboard.ts b/src/routes/dashboard.ts new file mode 100644 index 0000000..a49e916 --- /dev/null +++ b/src/routes/dashboard.ts @@ -0,0 +1,128 @@ +import type { FastifyInstance } from 'fastify'; +import { config } from '../config.js'; +import { isStripeConfigured } from '../billing/stripe.js'; + +/** + * Self-contained dashboard served at /app — one HTML page with inline + * CSS/JS. Talks to the /v1/auth and /v1/billing JSON APIs. No build step, + * no framework, no static-file dependency. + */ +export async function dashboardRoutes(app: FastifyInstance): Promise { + app.get('/app', async (_req, reply) => { + reply.header('content-type', 'text/html; charset=utf-8'); + return PAGE; + }); +} + +const FLAGS = JSON.stringify({ + google: !!config.GOOGLE_CLIENT_ID, + github: !!config.GITHUB_CLIENT_ID, + billing: isStripeConfigured(), + serverUrl: config.PUBLIC_BASE_URL, +}); + +const PAGE = ` + + +WP IDE — Account + +
+

🦅 WP IDE — Account

+
+
+ +`; diff --git a/src/routes/health.ts b/src/routes/health.ts new file mode 100644 index 0000000..e9f0df1 --- /dev/null +++ b/src/routes/health.ts @@ -0,0 +1,30 @@ +import type { FastifyInstance } from 'fastify'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const pkg = JSON.parse(readFileSync(resolve(process.cwd(), 'package.json'), 'utf8')) as { + version: string; + name: string; +}; + +export async function healthRoutes(app: FastifyInstance): Promise { + app.get('/', async () => ({ + ok: true, + name: pkg.name, + version: pkg.version, + note: 'closed orchestrator server for the WordPress IDE plugin', + endpoints: { + health: 'GET /v1/health', + runs: 'POST /v1/runs (orchestrator entrypoint — step 4+)', + }, + })); + + app.get('/v1/health', async () => ({ + ok: true, + name: pkg.name, + version: pkg.version, + uptime_s: Math.round(process.uptime()), + node: process.version, + ts: new Date().toISOString(), + })); +} diff --git a/src/routes/oauth.ts b/src/routes/oauth.ts new file mode 100644 index 0000000..8392467 --- /dev/null +++ b/src/routes/oauth.ts @@ -0,0 +1,99 @@ +import type { FastifyInstance } from 'fastify'; +import { config } from '../config.js'; +import { upsertOauthUser } from '../accounts/service.js'; +import { setSession } from '../lib/session.js'; +import { signJwt, verifyJwt, randomToken } from '../lib/crypto.js'; +import { logger } from '../lib/logger.js'; + +function redirectUri(provider: string): string { + return `${config.PUBLIC_BASE_URL}/v1/auth/oauth/${provider}/callback`; +} + +function providerEnabled(provider: string): boolean { + if (provider === 'google') return !!config.GOOGLE_CLIENT_ID && !!config.GOOGLE_CLIENT_SECRET; + if (provider === 'github') return !!config.GITHUB_CLIENT_ID && !!config.GITHUB_CLIENT_SECRET; + return false; +} + +export async function oauthRoutes(app: FastifyInstance): Promise { + // Begin OAuth: set a signed state cookie, redirect to the provider. + app.get('/v1/auth/oauth/:provider/start', async (req, reply) => { + const { provider } = req.params as { provider: string }; + if (!providerEnabled(provider)) { reply.status(404); return { ok: false, error: 'provider_not_configured' }; } + const state = randomToken(); + const stateToken = signJwt({ st: state, p: provider }, 600); + reply.header('set-cookie', `wpide_oauth=${stateToken}; Path=/; HttpOnly; SameSite=Lax; Max-Age=600`); + + let url = ''; + if (provider === 'google') { + const p = new URLSearchParams({ + client_id: config.GOOGLE_CLIENT_ID, redirect_uri: redirectUri('google'), + response_type: 'code', scope: 'openid email profile', state, access_type: 'online', + }); + url = `https://accounts.google.com/o/oauth2/v2/auth?${p}`; + } else if (provider === 'github') { + const p = new URLSearchParams({ + client_id: config.GITHUB_CLIENT_ID, redirect_uri: redirectUri('github'), + scope: 'read:user user:email', state, + }); + url = `https://github.com/login/oauth/authorize?${p}`; + } + reply.redirect(url); + }); + + // OAuth callback: verify state, exchange code, fetch profile, sign in. + app.get('/v1/auth/oauth/:provider/callback', async (req, reply) => { + const { provider } = req.params as { provider: string }; + const { code, state } = req.query as { code?: string; state?: string }; + const cookie = (req.headers.cookie ?? '').split(';').map((c) => c.trim()).find((c) => c.startsWith('wpide_oauth=')); + const stateToken = cookie?.slice('wpide_oauth='.length); + const payload = stateToken ? verifyJwt<{ st: string; p: string }>(stateToken) : null; + if (!code || !state || !payload || payload.st !== state || payload.p !== provider) { + reply.status(400); return { ok: false, error: 'invalid_oauth_state' }; + } + + try { + let uid = ''; + let email: string | null = null; + if (provider === 'google') { + const tok = await (await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + code, client_id: config.GOOGLE_CLIENT_ID, client_secret: config.GOOGLE_CLIENT_SECRET, + redirect_uri: redirectUri('google'), grant_type: 'authorization_code', + }), + })).json() as { access_token?: string }; + const profile = await (await fetch('https://www.googleapis.com/oauth2/v2/userinfo', { + headers: { authorization: `Bearer ${tok.access_token}` }, + })).json() as { id?: string; email?: string }; + uid = String(profile.id ?? ''); email = profile.email ?? null; + } else if (provider === 'github') { + const tok = await (await fetch('https://github.com/login/oauth/access_token', { + method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' }, + body: new URLSearchParams({ + code, client_id: config.GITHUB_CLIENT_ID, client_secret: config.GITHUB_CLIENT_SECRET, + redirect_uri: redirectUri('github'), + }), + })).json() as { access_token?: string }; + const user = await (await fetch('https://api.github.com/user', { + headers: { authorization: `Bearer ${tok.access_token}`, 'user-agent': 'wpide-server' }, + })).json() as { id?: number; email?: string | null }; + uid = String(user.id ?? ''); email = user.email ?? null; + if (!email) { + const emails = await (await fetch('https://api.github.com/user/emails', { + headers: { authorization: `Bearer ${tok.access_token}`, 'user-agent': 'wpide-server' }, + })).json() as { email: string; primary: boolean; verified: boolean }[]; + email = emails.find((e) => e.primary && e.verified)?.email ?? emails[0]?.email ?? null; + } + } + if (!uid) { reply.status(400); return { ok: false, error: 'oauth_no_profile' }; } + + const user = upsertOauthUser(provider, uid, email); + setSession(reply, user.id); + reply.redirect('/app'); + } catch (err) { + logger.error({ err, provider }, 'oauth callback failed'); + reply.status(500); return { ok: false, error: 'oauth_failed' }; + } + }); +} diff --git a/src/routes/runs.ts b/src/routes/runs.ts new file mode 100644 index 0000000..e06d7e7 --- /dev/null +++ b/src/routes/runs.ts @@ -0,0 +1,190 @@ +import type { FastifyInstance } from 'fastify'; +import { process_request, start_run_async, getRunState } from '../orchestrator/index.js'; +import type { RunRequest } from '../orchestrator/types.js'; +import { getRun } from '../db/runs.js'; +import { logger } from '../lib/logger.js'; +import { config } from '../config.js'; +import { resolveAccess } from '../accounts/service.js'; +import { resolveToolResult } from '../orchestrator/registry.js'; + +function buildRunReq(body: Partial): RunRequest { + return { + goal: body.goal as string, + context: Array.isArray(body.context) ? body.context : [], + options: body.options, + tools_manifest: Array.isArray(body.tools_manifest) ? body.tools_manifest : [], + callback_url: body.callback_url, + callback_secret: body.callback_secret, + license_key: body.license_key, + site_url: body.site_url, + browser_tools: body.browser_tools === true, + }; +} + +/** + * Gate + resolve tier. When REQUIRE_LICENSE is off (dev), every run runs + * at DEV_DEFAULT_TIER with no account. When on (prod), the license must + * map to an active subscription. Returns null on success (req mutated + * with tier) or an error object to send back. + */ +function gate(req: RunRequest): { error: string; reason: string; tier?: string } | null { + if (!config.REQUIRE_LICENSE) { + req.tier = config.DEV_DEFAULT_TIER; + return null; + } + const access = resolveAccess(req.license_key ?? '', req.site_url); + if (!access.ok) { + return { error: 'access_denied', reason: access.reason ?? 'unknown', tier: access.tier }; + } + req.tier = access.tier; + req.resolved_user_id = access.user_id; + return null; +} + +export async function runsRoutes(app: FastifyInstance): Promise { + // Synchronous run — blocks until done. Kept for non-browser callers + // (cron, Telegram) that tolerate the wait. Browser path uses /start. + app.post('/v1/runs', async (req, reply) => { + const body = (req.body ?? {}) as Partial; + if (typeof body.goal !== 'string' || body.goal.trim() === '') { + reply.status(400); + return { ok: false, error: 'goal is required (non-empty string)' }; + } + const runReq = buildRunReq(body); + const denied = gate(runReq); + if (denied) { + reply.status(402); + return { ok: false, ...denied }; + } + try { + return await process_request(runReq); + } catch (err) { + logger.error({ err }, 'POST /v1/runs failed'); + reply.status(500); + return { ok: false, error: (err as Error).message }; + } + }); + + // Async run — returns a run_id immediately; the agent runs in the + // background. Poll GET /v1/runs/:id/status. No timeout ceiling. + app.post('/v1/runs/start', async (req, reply) => { + const body = (req.body ?? {}) as Partial; + if (typeof body.goal !== 'string' || body.goal.trim() === '') { + reply.status(400); + return { ok: false, error: 'goal is required (non-empty string)' }; + } + const runReq = buildRunReq(body); + const denied = gate(runReq); + if (denied) { + reply.status(402); + return { ok: false, ...denied }; + } + const { run_id, session_id } = start_run_async(runReq); + return { ok: true, run_id, session_id, status: 'running' }; + }); + + // Live status + final result of an async run. + app.get('/v1/runs/:run_id/status', async (req, reply) => { + const { run_id } = req.params as { run_id: string }; + const state = getRunState(run_id); + if (!state) { + reply.status(404); + return { ok: false, error: 'run not found (unknown or expired run_id)' }; + } + return { + ok: true, + run_id: state.run_id, + session_id: state.session_id, + status: state.status, + steps_done: state.steps.length, + tools_used: state.tools_used, + elapsed_ms: Date.now() - state.started_at, + partial_content: state.partial_content, + done: state.status !== 'running', + // Full PHP-shape payload, present once finished. + response: state.status === 'running' ? null : state.response, + error: state.error, + }; + }); + + // Browser-direct tool mode: the browser POSTs a tool's result here + // after running it locally; this resolves the loop's await. + app.post('/v1/runs/:run_id/tool_result', async (req, reply) => { + const { run_id } = req.params as { run_id: string }; + const state = getRunState(run_id); + const body = (req.body ?? {}) as { call_id?: string; ok?: boolean; result?: unknown; error?: string }; + if (!state) { + logger.warn({ run_id, call_id: body.call_id, origin: req.headers.origin }, 'tool_result POST: run not found'); + reply.status(404); return { ok: false, error: 'run not found' }; + } + if (!body.call_id) { + logger.warn({ run_id }, 'tool_result POST: missing call_id'); + reply.status(400); return { ok: false, error: 'call_id required' }; + } + const accepted = resolveToolResult(state, body.call_id, { + ok: body.ok !== false, result: body.result, error: body.error, + }); + logger.info({ run_id, call_id: body.call_id, ok: body.ok, matched: accepted }, 'tool_result POST received'); + return { ok: accepted, matched: accepted }; + }); + + // SSE stream of a run's live events (tokens, thinking, tool_call, + // tool_result, status, done, error). The plugin consumes this and + // relays to the browser. `?since=` resumes after a reconnect. + app.get('/v1/runs/:run_id/stream', (req, reply) => { + const { run_id } = req.params as { run_id: string }; + const state = getRunState(run_id); + if (!state) { + reply.status(404).send({ ok: false, error: 'run not found (unknown or expired run_id)' }); + return; + } + const since = Number((req.query as { since?: string })?.since ?? 0) || 0; + let lastSeq = since; + + reply.hijack(); + const raw = reply.raw; + raw.writeHead(200, { + 'content-type': 'text/event-stream; charset=utf-8', + 'cache-control': 'no-cache, no-transform', + connection: 'keep-alive', + 'x-accel-buffering': 'no', + // Browser-direct mode: the browser opens this stream cross-origin + // (from the WP site to the server). The reply is hijacked, so the + // cors plugin doesn't run — set the header manually. + 'access-control-allow-origin': (req.headers.origin as string) || '*', + 'access-control-allow-credentials': 'true', + }); + raw.write(': connected\n\n'); + + let closed = false; + raw.on('close', () => { closed = true; }); + + const tick = setInterval(() => { + if (closed) { clearInterval(tick); return; } + const pending = state.events.filter((e) => e.seq > lastSeq); + for (const e of pending) { + lastSeq = e.seq; + raw.write(`event: ${e.type}\n`); + raw.write(`id: ${e.seq}\n`); + raw.write(`data: ${JSON.stringify(e.data)}\n\n`); + } + if (pending.length === 0) raw.write(': hb\n\n'); // heartbeat + if (state.status !== 'running' && lastSeq >= state._seq) { + raw.write('event: end\n'); + raw.write(`data: ${JSON.stringify({ status: state.status, run_id: state.run_id })}\n\n`); + clearInterval(tick); + raw.end(); + } + }, 150); + }); + + app.get('/v1/runs/:run_id', async (req, reply) => { + const { run_id } = req.params as { run_id: string }; + const row = getRun(run_id); + if (!row) { + reply.status(404); + return { ok: false, error: 'run not found' }; + } + return { ok: true, run: row }; + }); +} diff --git a/src/routes/teams.ts b/src/routes/teams.ts new file mode 100644 index 0000000..79281cd --- /dev/null +++ b/src/routes/teams.ts @@ -0,0 +1,52 @@ +import type { FastifyInstance, FastifyRequest } from 'fastify'; +import { getSessionUserId } from '../lib/session.js'; +import { + createOrg, getOrgsForUser, getOrgById, getMembers, + addMemberByEmail, removeMember, isOwner, isMember, +} from '../accounts/teams.js'; + +export async function teamRoutes(app: FastifyInstance): Promise { + const uid = (req: FastifyRequest): string | null => getSessionUserId(req); + + // List my orgs. + app.get('/v1/teams', async (req, reply) => { + const u = uid(req); if (!u) { reply.status(401); return { ok: false, error: 'not_authenticated' }; } + return { ok: true, teams: getOrgsForUser(u) }; + }); + + // Create an org (I become owner). + app.post('/v1/teams', async (req, reply) => { + const u = uid(req); if (!u) { reply.status(401); return { ok: false, error: 'not_authenticated' }; } + const { name } = (req.body ?? {}) as { name?: string }; + if (!name || name.trim() === '') { reply.status(400); return { ok: false, error: 'name_required' }; } + return { ok: true, team: createOrg(name.trim(), u) }; + }); + + // List members (members can view). + app.get('/v1/teams/:id/members', async (req, reply) => { + const u = uid(req); if (!u) { reply.status(401); return { ok: false, error: 'not_authenticated' }; } + const { id } = req.params as { id: string }; + if (!getOrgById(id) || !isMember(id, u)) { reply.status(403); return { ok: false, error: 'forbidden' }; } + return { ok: true, members: getMembers(id).map((m) => ({ user_id: m.user_id, email: m.email, role: m.role })) }; + }); + + // Add a member by email (owner only; the person must already have an account). + app.post('/v1/teams/:id/members', async (req, reply) => { + const u = uid(req); if (!u) { reply.status(401); return { ok: false, error: 'not_authenticated' }; } + const { id } = req.params as { id: string }; + if (!isOwner(id, u)) { reply.status(403); return { ok: false, error: 'owner_only' }; } + const { email, role } = (req.body ?? {}) as { email?: string; role?: string }; + const res = addMemberByEmail(id, String(email ?? ''), role === 'admin' ? 'admin' : 'member'); + if (!res.ok) { reply.status(400); return { ok: false, error: res.error }; } + return { ok: true }; + }); + + // Remove a member (owner only). + app.delete('/v1/teams/:id/members/:userId', async (req, reply) => { + const u = uid(req); if (!u) { reply.status(401); return { ok: false, error: 'not_authenticated' }; } + const { id, userId } = req.params as { id: string; userId: string }; + if (!isOwner(id, u)) { reply.status(403); return { ok: false, error: 'owner_only' }; } + removeMember(id, userId); + return { ok: true }; + }); +} diff --git a/src/routing/policy.ts b/src/routing/policy.ts new file mode 100644 index 0000000..6e39b71 --- /dev/null +++ b/src/routing/policy.ts @@ -0,0 +1,77 @@ +/** + * Model routing by subscription tier + request mode/complexity. + * + * The tier sets a ceiling on which models the router may pick; the mode + * and a code-heavy heuristic decide whether to use thinking / the pro + * model within that ceiling. An explicit plugin model pick is honored + * only if the tier allows it, else downgraded to the tier ceiling. + */ + +import type { Tier } from '../accounts/store.js'; +import type { RunOptions } from '../orchestrator/types.js'; + +export interface RoutedModel { + model: string; // e.g. 'deepseek-v4-flash' + thinking: boolean; // request thinking mode + reasoning_effort?: 'high' | 'max'; + downgraded?: boolean; // true if the user's pick was capped by tier + reason: string; +} + +const FLASH = 'deepseek-v4-flash'; +const PRO = 'deepseek-v4-pro'; + +function isComplexCoding(opts: RunOptions, goal: string): boolean { + const agent = (opts.agent_id ?? '').toLowerCase(); + if (agent.includes('coder') || agent.includes('page') || agent.includes('builder')) return true; + const g = goal.toLowerCase(); + return /\b(refactor|implement|debug|fix the|write a (function|class|plugin|component)|migrat|architecture)\b/.test(g); +} + +function isElevated(opts: RunOptions): boolean { + const mode = (opts.mode ?? 'chat').toLowerCase(); + return mode === 'ask' || mode === 'plan' || mode === 'super'; +} + +/** What model may this tier use at most? */ +function tierCeiling(tier: Tier): { allowPro: boolean; allowThinking: boolean } { + switch (tier) { + case 'max': return { allowPro: true, allowThinking: true }; + case 'pro': return { allowPro: false, allowThinking: true }; + case 'basic': + default: return { allowPro: false, allowThinking: false }; + } +} + +export function routeModel(tier: Tier, opts: RunOptions, goal: string): RoutedModel { + const ceil = tierCeiling(tier); + const complex = isComplexCoding(opts, goal); + const elevated = isElevated(opts); + + // Desired (uncapped) routing: + let wantPro = complex; // complex coding → pro + let wantThinking = complex || elevated; // complex or ask/plan/super → thinking + + // Honor an explicit model pick if the tier allows it. + const pick = (opts.model_override ?? '').toLowerCase(); + if (pick) { + if (/pro/.test(pick)) wantPro = true; + if (/flash/.test(pick)) wantPro = false; + } + + // Apply tier ceiling. + let downgraded = false; + if (wantPro && !ceil.allowPro) { wantPro = false; downgraded = true; } + if (wantThinking && !ceil.allowThinking) { wantThinking = false; downgraded = true; } + + const model = wantPro ? PRO : FLASH; + const effort: 'high' | 'max' | undefined = wantThinking ? (wantPro ? 'max' : 'high') : undefined; + + return { + model, + thinking: wantThinking, + reasoning_effort: effort, + downgraded, + reason: `tier=${tier} complex=${complex} elevated=${elevated}${downgraded ? ' (capped by tier)' : ''}`, + }; +} diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..3fabb6e --- /dev/null +++ b/src/server.ts @@ -0,0 +1,83 @@ +import Fastify, { type FastifyError } from 'fastify'; +import cors from '@fastify/cors'; +import { config } from './config.js'; +import { logger } from './lib/logger.js'; +import { getDb, runMigrations } from './db/pool.js'; +import { healthRoutes } from './routes/health.js'; +import { runsRoutes } from './routes/runs.js'; +import { authRoutes } from './routes/auth.js'; +import { billingRoutes } from './routes/billing.js'; +import { oauthRoutes } from './routes/oauth.js'; +import { dashboardRoutes } from './routes/dashboard.js'; +import { teamRoutes } from './routes/teams.js'; + +async function main(): Promise { + if (config.ALLOW_INSECURE_TLS) { + process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; + logger.warn( + 'TLS verification DISABLED for outbound HTTPS (ALLOW_INSECURE_TLS=true). ' + + 'Required for dev machines behind VPN/MITM. Do not use this in prod.', + ); + } + + getDb(); + runMigrations(); + + const app = Fastify({ + loggerInstance: logger, + disableRequestLogging: !config.isDev, + bodyLimit: 10 * 1024 * 1024, + }); + + await app.register(cors, { + origin: config.ALLOWED_ORIGINS === '*' ? true : config.ALLOWED_ORIGINS.split(',').map(s => s.trim()), + credentials: true, + }); + + // Parse JSON but also keep the raw body — the Stripe webhook needs the + // exact bytes for signature verification. + app.addContentTypeParser('application/json', { parseAs: 'buffer' }, (req, body, done) => { + (req as unknown as { rawBody?: Buffer }).rawBody = body as Buffer; + try { + const str = (body as Buffer).toString('utf8'); + done(null, str ? JSON.parse(str) : {}); + } catch (err) { + done(err as Error); + } + }); + + await app.register(healthRoutes); + await app.register(runsRoutes); + await app.register(authRoutes); + await app.register(billingRoutes); + await app.register(oauthRoutes); + await app.register(teamRoutes); + await app.register(dashboardRoutes); + + app.setErrorHandler((err: FastifyError, _req, reply) => { + logger.error({ err }, 'Request failed'); + const statusCode = typeof err.statusCode === 'number' ? err.statusCode : 500; + reply.status(statusCode).send({ + ok: false, + error: err.message || 'Internal Server Error', + }); + }); + + try { + await app.listen({ port: config.PORT, host: config.HOST }); + logger.info({ url: `http://${config.HOST}:${config.PORT}` }, 'wpide-server listening'); + } catch (err) { + logger.fatal({ err }, 'Failed to start server'); + process.exit(1); + } + + const shutdown = async (signal: string): Promise => { + logger.info({ signal }, 'Shutting down'); + await app.close(); + process.exit(0); + }; + process.on('SIGINT', () => void shutdown('SIGINT')); + process.on('SIGTERM', () => void shutdown('SIGTERM')); +} + +void main(); diff --git a/src/site-callback/client.ts b/src/site-callback/client.ts new file mode 100644 index 0000000..a34891f --- /dev/null +++ b/src/site-callback/client.ts @@ -0,0 +1,79 @@ +/** + * HMAC-signed POSTs to the plugin's /wp-json/wp-ide/v1/tool-exec + * endpoint. The plugin issued the secret when it called POST /v1/runs; + * we use it to sign every tool callback so the plugin can verify the + * caller is us. + */ + +import { createHmac } from 'node:crypto'; +import { logger } from '../lib/logger.js'; + +export interface ToolExecRequest { + call_id: string; + name: string; + arguments: Record; +} + +export interface ToolExecResponse { + ok: boolean; + call_id: string; + result?: unknown; + error?: string; +} + +export async function runToolOnSite( + callbackUrl: string, + runId: string, + secret: string, + payload: ToolExecRequest, + timeoutMs = 60_000, +): Promise { + const body = JSON.stringify(payload); + const signature = createHmac('sha256', secret).update(body).digest('hex'); + const url = callbackUrl; + + const controller = new AbortController(); + const t = setTimeout(() => controller.abort(), timeoutMs); + const started = Date.now(); + try { + const res = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'accept': 'application/json', + 'accept-encoding': 'identity', + 'user-agent': 'wpide-server/0.2', + 'x-wpide-run-id': runId, + 'x-wpide-signature': signature, + }, + body, + signal: controller.signal, + }); + const ms = Date.now() - started; + const text = await res.text(); + let parsed: ToolExecResponse; + try { + parsed = JSON.parse(text) as ToolExecResponse; + } catch { + const respHeaders: Record = {}; + for (const [k, v] of res.headers.entries()) respHeaders[k] = v; + logger.error({ + url, + status: res.status, + ms, + bodyLength: text.length, + body: text.slice(0, 500), + headers: respHeaders, + }, 'tool-exec: non-JSON response'); + return { ok: false, call_id: payload.call_id, error: `tool-exec non-JSON (HTTP ${res.status})` }; + } + if (!res.ok) { + logger.warn({ status: res.status, ms, error: parsed.error }, 'tool-exec returned error status'); + } + return parsed; + } catch (err) { + return { ok: false, call_id: payload.call_id, error: `tool-exec fetch failed: ${(err as Error).message}` }; + } finally { + clearTimeout(t); + } +} diff --git a/src/tools/manifest.ts b/src/tools/manifest.ts new file mode 100644 index 0000000..43d0c69 --- /dev/null +++ b/src/tools/manifest.ts @@ -0,0 +1,115 @@ +/** + * Convert the plugin's Anthropic-shape tools manifest into the OpenAI + * tools shape. The plugin emits both formats internally; we accept the + * canonical Anthropic shape because that's what the plugin uses with + * its current registry. + */ + +import type { ToolDescriptor } from '../orchestrator/types.js'; +import type { OpenAIToolDef } from '../providers/openai.js'; + +/** + * Make a JSON Schema safe for OpenAI/DeepSeek/xAI consumption. + * + * The plugin generates schemas from PHP, and `json_encode([])` produces + * `[]` (array) even when the receiver wants `{}` (object). DeepSeek + * rejects with HTTP 400 the moment any "object"-typed slot contains an + * empty array. We sanitize recursively: wherever an object is expected, + * coerce empty arrays to empty objects, and ensure the top-level shape + * has `type=object` + `properties=object`. + */ +/** + * Walk an object recursively and delete keys whose value is null. + * JSON Schema treats absent optional fields as valid; explicit nulls + * (which PHP sometimes emits for unset descriptions, formats, etc.) + * cause strict validators like DeepSeek to reject the whole schema + * with "null is not of type 'string'". + */ +function stripNullDeep(v: unknown): unknown { + if (v === null) return undefined; + if (Array.isArray(v)) { + return v.map((x) => stripNullDeep(x)); + } + if (typeof v === 'object') { + const out: Record = {}; + for (const [k, val] of Object.entries(v as Record)) { + const cleaned = stripNullDeep(val); + if (cleaned !== undefined) out[k] = cleaned; + } + return out; + } + return v; +} + +function sanitizeSchema(raw: unknown): Record { + const stripped = stripNullDeep(raw); + const base: Record = + typeof stripped === 'object' && stripped !== null && !Array.isArray(stripped) + ? { ...(stripped as Record) } + : {}; + + if (typeof base.type !== 'string') { + base.type = 'object'; + } + + // properties: must be an object map, never [] + if (Array.isArray(base.properties) || base.properties === undefined || base.properties === null) { + base.properties = {}; + } else if (typeof base.properties === 'object') { + const cleaned: Record = {}; + for (const [k, v] of Object.entries(base.properties as Record)) { + cleaned[k] = sanitizePropertyValue(v); + } + base.properties = cleaned; + } + + // required: must be an array of strings + if (base.required === undefined || base.required === null) { + base.required = []; + } else if (!Array.isArray(base.required)) { + base.required = []; + } else { + base.required = (base.required as unknown[]).filter((x): x is string => typeof x === 'string'); + } + + return base; +} + +function sanitizePropertyValue(v: unknown): unknown { + if (typeof v !== 'object' || v === null) return v; + if (Array.isArray(v)) return v; // arrays are valid as enum lists, defaults, etc. + + const obj = { ...(v as Record) }; + // Nested object schemas need the same treatment. + if (obj.type === 'object') { + if (Array.isArray(obj.properties) || obj.properties === undefined) { + obj.properties = {}; + } else if (typeof obj.properties === 'object' && obj.properties !== null) { + const nested: Record = {}; + for (const [k, val] of Object.entries(obj.properties as Record)) { + nested[k] = sanitizePropertyValue(val); + } + obj.properties = nested; + } + if (Array.isArray(obj.required)) { + obj.required = (obj.required as unknown[]).filter((x): x is string => typeof x === 'string'); + } + } + // Array schemas: ensure items is an object + if (obj.type === 'array' && obj.items !== undefined && !Array.isArray(obj.items)) { + obj.items = sanitizePropertyValue(obj.items); + } + return obj; +} + +export function toolsForOpenAI(tools: ToolDescriptor[] | undefined): OpenAIToolDef[] { + if (!Array.isArray(tools) || tools.length === 0) return []; + return tools.map((t) => ({ + type: 'function' as const, + function: { + name: t.name, + description: t.description ?? '', + parameters: sanitizeSchema(t.input_schema), + }, + })); +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..7581908 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "noUncheckedIndexedAccess": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "forceConsistentCasingInFileNames": true, + "declaration": false, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "data"] +}