Initial import: live state on api.qbirr.com (server v0.6.3)

This commit is contained in:
2026-05-26 16:06:29 +02:00
commit 7ba4cb4a31
38 changed files with 5242 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
node_modules/
dist/
data/
*.bak
.env
.env.local
+236
View File
@@ -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
View File
@@ -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"]
+54
View File
@@ -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/*`.
+40
View File
@@ -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
+28
View File
@@ -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"
+40
View File
@@ -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
+1870
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -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"
}
}
+103
View File
@@ -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 };
}
+106
View File
@@ -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);
}
}
+84
View File
@@ -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;
}
+93
View File
@@ -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);
}
+94
View File
@@ -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
View File
@@ -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)');
}
}
+33
View File
@@ -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);
}
+81
View File
@@ -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;
}
}
+16
View File
@@ -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,
});
+38
View File
@@ -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;
}
+451
View File
@@ -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 };
}
+43
View File
@@ -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];
}
+119
View File
@@ -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);
}
}
}
+39
View File
@@ -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';
}
+97
View File
@@ -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>;
}
+43
View File
@@ -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';
+287
View File
@@ -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',
);
+52
View File
@@ -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,
};
});
}
+100
View File
@@ -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 };
});
}
+128
View File
@@ -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>`;
+30
View File
@@ -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(),
}));
}
+99
View File
@@ -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' };
}
});
}
+190
View File
@@ -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 };
});
}
+52
View File
@@ -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 };
});
}
+77
View File
@@ -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)' : ''}`,
};
}
+83
View File
@@ -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();
+79
View File
@@ -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);
}
}
+115
View File
@@ -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),
},
}));
}
+19
View File
@@ -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"]
}