Initial import: live state on api.qbirr.com (server v0.6.3)
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
data/
|
||||
*.bak
|
||||
.env
|
||||
.env.local
|
||||
+236
@@ -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=<seq>` 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 → <name>` 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`.
|
||||
+28
@@ -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"]
|
||||
@@ -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/*`.
|
||||
@@ -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
|
||||
Executable
+28
@@ -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"
|
||||
@@ -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
|
||||
Generated
+1870
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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<Subscription>): 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);
|
||||
}
|
||||
}
|
||||
@@ -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<Tier, number> = { 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;
|
||||
}
|
||||
@@ -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, string>): string {
|
||||
return Object.entries(obj).map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&');
|
||||
}
|
||||
|
||||
async function stripePost(path: string, body: Record<string, string>): Promise<Record<string, unknown>> {
|
||||
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<string, unknown>;
|
||||
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<string> {
|
||||
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<string, string> = {
|
||||
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<string> {
|
||||
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);
|
||||
}
|
||||
@@ -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;
|
||||
+159
@@ -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)');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<string, unknown>, 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<T = Record<string, unknown>>(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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<RunResponse> {
|
||||
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<AgenticResult> {
|
||||
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<string, unknown>;
|
||||
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<string, unknown> = {};
|
||||
try {
|
||||
args = JSON.parse(call.function.arguments || '{}') as Record<string, unknown>;
|
||||
} 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<SimpleResult> {
|
||||
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 };
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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<string, (r: ToolCbResult) => 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<string, RunState>();
|
||||
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<ToolCbResult> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
ok: boolean;
|
||||
result?: unknown;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface StepHistoryEntry {
|
||||
step: number;
|
||||
type: 'llm_call' | 'tool_call' | 'status';
|
||||
at: string;
|
||||
detail?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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<OpenAIChatResponse> {
|
||||
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<OpenAIChatResponse> {
|
||||
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<typeof setTimeout> | 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<number, { id: string; name: string; args: string }> = {};
|
||||
|
||||
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<void> {
|
||||
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',
|
||||
);
|
||||
@@ -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<void> {
|
||||
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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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<void> {
|
||||
// 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<string, unknown> } };
|
||||
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<string, string> | undefined)?.user_id ?? '');
|
||||
const tier = ((obj.metadata as Record<string, string> | 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<string, string> | 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<string, string> | 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 };
|
||||
});
|
||||
}
|
||||
@@ -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<void> {
|
||||
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 = `<!DOCTYPE html>
|
||||
<html lang="en"><head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>WP IDE — Account</title>
|
||||
<style>
|
||||
:root { --bg:#0f1419; --card:#1a2230; --line:#2a3547; --fg:#e6edf3; --mut:#8b98a8; --accent:#50a8eb; --ok:#3fb950; --warn:#d29922; }
|
||||
* { box-sizing:border-box; } body { margin:0; font-family:Inter,system-ui,Arial,sans-serif; background:var(--bg); color:var(--fg); }
|
||||
.wrap { max-width:640px; margin:0 auto; padding:32px 20px; }
|
||||
h1 { font-size:22px; } h2 { font-size:15px; color:var(--mut); text-transform:uppercase; letter-spacing:.05em; margin-top:28px; }
|
||||
.card { background:var(--card); border:1px solid var(--line); border-radius:12px; padding:20px; margin:14px 0; }
|
||||
label { display:block; font-size:13px; color:var(--mut); margin:10px 0 4px; }
|
||||
input { width:100%; padding:10px 12px; border-radius:8px; border:1px solid var(--line); background:#0d1117; color:var(--fg); font-size:14px; }
|
||||
button { cursor:pointer; border:0; border-radius:8px; padding:10px 16px; font-size:14px; font-weight:600; background:var(--accent); color:#04121f; }
|
||||
button.ghost { background:transparent; color:var(--fg); border:1px solid var(--line); }
|
||||
button.oauth { width:100%; margin:6px 0; background:#fff; color:#111; }
|
||||
button.gh { background:#24292e; color:#fff; }
|
||||
.row { display:flex; gap:10px; flex-wrap:wrap; align-items:center; }
|
||||
.tabs { display:flex; gap:8px; margin-bottom:14px; }
|
||||
.tabs button { background:transparent; color:var(--mut); border:1px solid var(--line); }
|
||||
.tabs button.active { background:var(--accent); color:#04121f; }
|
||||
.key { font-family:ui-monospace,monospace; background:#0d1117; padding:10px 12px; border-radius:8px; border:1px solid var(--line); word-break:break-all; font-size:13px; }
|
||||
.pill { display:inline-block; padding:3px 10px; border-radius:999px; font-size:12px; font-weight:600; }
|
||||
.pill.active { background:rgba(63,185,80,.15); color:var(--ok); } .pill.none,.pill.canceled,.pill.past_due { background:rgba(210,153,34,.15); color:var(--warn); }
|
||||
.err { color:#f85149; font-size:13px; min-height:18px; } .muted { color:var(--mut); font-size:13px; }
|
||||
.tier { border:1px solid var(--line); border-radius:10px; padding:14px; flex:1; min-width:150px; text-align:center; }
|
||||
a { color:var(--accent); }
|
||||
</style></head>
|
||||
<body><div class="wrap">
|
||||
<h1>🦅 WP IDE — Account</h1>
|
||||
<div id="view"></div>
|
||||
</div>
|
||||
<script>
|
||||
const FLAGS = ${FLAGS};
|
||||
const $ = (s)=>document.querySelector(s);
|
||||
const view = $('#view');
|
||||
async function api(path, body){ const r = await fetch(path,{method:body?'POST':'GET',headers:body?{'content-type':'application/json'}:{},body:body?JSON.stringify(body):undefined,credentials:'same-origin'}); return {status:r.status, data: await r.json().catch(()=>({}))}; }
|
||||
|
||||
function authView(mode){
|
||||
const isLogin = mode!=='register';
|
||||
view.innerHTML = \`
|
||||
<div class="card">
|
||||
<div class="tabs">
|
||||
<button class="\${isLogin?'active':''}" onclick="authView('login')">Sign in</button>
|
||||
<button class="\${!isLogin?'active':''}" onclick="authView('register')">Create account</button>
|
||||
</div>
|
||||
<label>Email</label><input id="email" type="email" autocomplete="email"/>
|
||||
<label>Password</label><input id="password" type="password" autocomplete="\${isLogin?'current-password':'new-password'}"/>
|
||||
<div class="err" id="err"></div>
|
||||
<button onclick="doAuth('\${isLogin?'login':'register'}')">\${isLogin?'Sign in':'Create account'}</button>
|
||||
<div style="margin-top:14px">
|
||||
\${FLAGS.google?'<button class="oauth" onclick="location.href=\\'/v1/auth/oauth/google/start\\'">Continue with Google</button>':''}
|
||||
\${FLAGS.github?'<button class="oauth gh" onclick="location.href=\\'/v1/auth/oauth/github/start\\'">Continue with GitHub</button>':''}
|
||||
</div>
|
||||
</div>\`;
|
||||
}
|
||||
async function doAuth(mode){
|
||||
const email=$('#email').value, password=$('#password').value;
|
||||
const {status,data}=await api('/v1/auth/'+mode,{email,password});
|
||||
if(status>=200&&status<300&&data.ok){ load(); } else { $('#err').textContent = friendly(data.error); }
|
||||
}
|
||||
function friendly(e){ return ({invalid_credentials:'Wrong email or password.',email_in_use:'That email is already registered.',weak_password:'Password must be at least 8 characters.',invalid_email:'Enter a valid email.'})[e]||e||'Something went wrong.'; }
|
||||
|
||||
async function load(){
|
||||
const {status,data}=await api('/v1/auth/me');
|
||||
if(status!==200||!data.ok){ authView('login'); return; }
|
||||
const sub=data.subscription||{tier:'basic',status:'none'};
|
||||
const key=(data.licenses[0]||{}).key||'(none)';
|
||||
view.innerHTML = \`
|
||||
<div class="card">
|
||||
<div class="row" style="justify-content:space-between">
|
||||
<div><strong>\${data.user.email}</strong></div>
|
||||
<button class="ghost" onclick="logout()">Sign out</button>
|
||||
</div>
|
||||
</div>
|
||||
<h2>Your license key</h2>
|
||||
<div class="card">
|
||||
<div class="key" id="key">\${key}</div>
|
||||
<div class="row" style="margin-top:10px">
|
||||
<button onclick="copyKey()">Copy key</button>
|
||||
<span class="muted">Paste into WordPress IDE → AI Brain (Server). Server URL: <code>\${FLAGS.serverUrl}</code></span>
|
||||
</div>
|
||||
</div>
|
||||
<h2>Plan</h2>
|
||||
<div class="card">
|
||||
<div class="row" style="justify-content:space-between">
|
||||
<div>Tier: <strong>\${sub.tier}</strong> <span class="pill \${sub.status}">\${sub.status}</span></div>
|
||||
\${FLAGS.billing && sub.stripe ? '<button class="ghost" onclick="portal()">Manage billing</button>':''}
|
||||
</div>
|
||||
\${FLAGS.billing? \`
|
||||
<div class="row" style="margin-top:14px">
|
||||
<div class="tier"><strong>Basic</strong><div class="muted">flash</div><button onclick="checkout('basic')" style="margin-top:8px">Choose</button></div>
|
||||
<div class="tier"><strong>Pro</strong><div class="muted">+ thinking</div><button onclick="checkout('pro')" style="margin-top:8px">Choose</button></div>
|
||||
<div class="tier"><strong>Max</strong><div class="muted">+ pro max</div><button onclick="checkout('max')" style="margin-top:8px">Choose</button></div>
|
||||
</div>
|
||||
<button class="ghost" style="margin-top:10px" onclick="portal()">Manage billing</button>
|
||||
\`:'<div class="muted" style="margin-top:8px">Billing not configured on this server.</div>'}
|
||||
</div>\`;
|
||||
}
|
||||
function copyKey(){ navigator.clipboard.writeText($('#key').textContent.trim()); }
|
||||
async function logout(){ await api('/v1/auth/logout',{}); authView('login'); }
|
||||
async function checkout(tier){ const {data}=await api('/v1/billing/checkout',{tier}); if(data.url) location.href=data.url; else alert(friendly(data.error)); }
|
||||
async function portal(){ const {data}=await api('/v1/billing/portal',{}); if(data.url) location.href=data.url; else alert(friendly(data.error)); }
|
||||
load();
|
||||
</script>
|
||||
</body></html>`;
|
||||
@@ -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<void> {
|
||||
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(),
|
||||
}));
|
||||
}
|
||||
@@ -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<void> {
|
||||
// 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' };
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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>): 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<void> {
|
||||
// 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<RunRequest>;
|
||||
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<RunRequest>;
|
||||
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=<seq>` 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 };
|
||||
});
|
||||
}
|
||||
@@ -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<void> {
|
||||
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 };
|
||||
});
|
||||
}
|
||||
@@ -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)' : ''}`,
|
||||
};
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<void> => {
|
||||
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();
|
||||
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
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<ToolExecResponse> {
|
||||
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<string, string> = {};
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown> = {};
|
||||
for (const [k, val] of Object.entries(v as Record<string, unknown>)) {
|
||||
const cleaned = stripNullDeep(val);
|
||||
if (cleaned !== undefined) out[k] = cleaned;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
function sanitizeSchema(raw: unknown): Record<string, unknown> {
|
||||
const stripped = stripNullDeep(raw);
|
||||
const base: Record<string, unknown> =
|
||||
typeof stripped === 'object' && stripped !== null && !Array.isArray(stripped)
|
||||
? { ...(stripped as Record<string, unknown>) }
|
||||
: {};
|
||||
|
||||
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<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(base.properties as Record<string, unknown>)) {
|
||||
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<string, unknown>) };
|
||||
// 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<string, unknown> = {};
|
||||
for (const [k, val] of Object.entries(obj.properties as Record<string, unknown>)) {
|
||||
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),
|
||||
},
|
||||
}));
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user