mango-cms 0.3.34 → 0.3.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/cli.js +113 -23
  2. package/default/infra/vibe/README.md +43 -0
  3. package/default/infra/vibe/cloudflare.ini.template +26 -0
  4. package/default/infra/vibe/ecosystem.vibe.config.cjs +44 -0
  5. package/default/infra/vibe/nginx-vibe-orchestrator.conf.template +50 -0
  6. package/default/infra/vibe/nginx-vibe-staging.conf.template +73 -0
  7. package/default/infra/vibe/vibe-gateway.service +38 -0
  8. package/default/infra/vibe/vibe-orchestrator.service +44 -0
  9. package/default/infra/vibe/vibe.env.template +24 -0
  10. package/default/mango/config/settings.json +40 -1
  11. package/default/package.json +1 -1
  12. package/default/vite.config.js +46 -0
  13. package/lib/vibe-orchestrator/README.md +76 -0
  14. package/lib/vibe-orchestrator/scripts/fake-claude.mjs +35 -0
  15. package/lib/vibe-orchestrator/scripts/path-guard-hook.mjs +70 -0
  16. package/lib/vibe-orchestrator/scripts/vibe-recover.sh +63 -0
  17. package/lib/vibe-orchestrator/server.js +344 -0
  18. package/lib/vibe-orchestrator/src/attachments.js +98 -0
  19. package/lib/vibe-orchestrator/src/claudeRunner.js +233 -0
  20. package/lib/vibe-orchestrator/src/config.js +227 -0
  21. package/lib/vibe-orchestrator/src/costMirror.js +64 -0
  22. package/lib/vibe-orchestrator/src/costStore.js +209 -0
  23. package/lib/vibe-orchestrator/src/ownerToken.js +113 -0
  24. package/lib/vibe-orchestrator/src/pathGuard.js +114 -0
  25. package/lib/vibe-orchestrator/src/preamble.js +139 -0
  26. package/lib/vibe-orchestrator/src/publisher.js +376 -0
  27. package/lib/vibe-orchestrator/src/recovery.js +199 -0
  28. package/lib/vibe-orchestrator/src/screenshot.js +38 -0
  29. package/lib/vibe-orchestrator/src/sessionManager.js +291 -0
  30. package/lib/vibe-orchestrator/src/streamParser.js +188 -0
  31. package/lib/vibe-orchestrator/src/tokenMeter.js +64 -0
  32. package/package.json +1 -1
  33. package/readme.md +6 -0
@@ -25,9 +25,50 @@ const settingsPath = path.resolve(configPath, 'config/settings.json')
25
25
 
26
26
  const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'))
27
27
 
28
+ /*
29
+ * Race-safe loader for the regenerated config (Vibe / HAP-1096, HAP-1203).
30
+ *
31
+ * `@collections` / `@endpoints` resolve to `mango/config/.collections.json` and
32
+ * `.endpoints.json`, which mango REWRITES on every rebuild. For a brief window
33
+ * during that rewrite the file is empty/truncated. The front imports them
34
+ * (`src/helpers/mango.js`) and the watch plugin below full-reloads on change —
35
+ * so a reload that lands mid-rewrite makes Vite's `vite:json` throw
36
+ * "Failed to parse JSON file ... position -1", breaking the module graph and
37
+ * blanking the app. This `enforce: 'pre'` loader intercepts those two ids before
38
+ * `vite:json`, caches the last good parse, and serves it whenever the file is
39
+ * transiently unreadable, so a mid-write read can never break the page. Once the
40
+ * file settles, the watch -> full-reload re-runs this loader and the fresh
41
+ * content is picked up. It returns RAW JSON text (not `export default …`) so
42
+ * vite's built-in `vite:json` transform still converts it to ESM normally —
43
+ * returning JS here causes a 500 (HAP-1203). Dev/serve only; prod serves a
44
+ * static `vite build` and is immune.
45
+ */
46
+ function resilientConfigJson(targets) {
47
+ const lastGood = new Map() // absolute path -> last successfully parsed value
48
+ const fallback = (p) => (p === collectionsPath ? [] : {})
49
+ return {
50
+ name: 'vibe-resilient-config-json',
51
+ enforce: 'pre',
52
+ load(id) {
53
+ const clean = id.split('?')[0]
54
+ if (!targets.includes(clean)) return null
55
+ let value
56
+ try {
57
+ value = JSON.parse(fs.readFileSync(clean, 'utf8'))
58
+ lastGood.set(clean, value)
59
+ } catch (e) {
60
+ value = lastGood.has(clean) ? lastGood.get(clean) : fallback(clean)
61
+ this.warn(`[vibe] ${path.basename(clean)} mid-rewrite/unparseable; served ${lastGood.has(clean) ? 'last-good' : 'empty'} config (${e.message})`)
62
+ }
63
+ return JSON.stringify(value)
64
+ },
65
+ }
66
+ }
67
+
28
68
  // https://vitejs.dev/config/
29
69
  export default defineConfig({
30
70
  plugins: [
71
+ resilientConfigJson([collectionsPath, endpointsPath]),
31
72
  vue(),
32
73
  // VitePWA({
33
74
  // registerType: 'autoUpdate',
@@ -50,6 +91,11 @@ export default defineConfig({
50
91
  server.watcher.add(endpointsPath)
51
92
  server.watcher.on('change', (file) => {
52
93
  if (file === collectionsPath || file === endpointsPath) {
94
+ // Only reload once the rewrite has SETTLED (file parses). The
95
+ // 'change' event also fires for the transient empty/truncated
96
+ // state mid-rewrite; reloading then would just bounce off the
97
+ // resilient loader's last-good and reload again. Skip those.
98
+ try { JSON.parse(fs.readFileSync(file, 'utf8')) } catch { return }
53
99
  server.ws.send({
54
100
  type: 'full-reload',
55
101
  })
@@ -0,0 +1,76 @@
1
+ # Vibe Orchestrator (bundled with Mango · HAP-1253)
2
+
3
+ The **vibe-orchestrator** ships inside the `mango-cms` package so a Mango project
4
+ can run it **straight from the installed dependency — no copied source**. It turns
5
+ a ⌘K chat prompt into live edits on a branch worktree by driving **Claude Code
6
+ headless** (no Anthropic API key), streams normalized progress back to the drawer
7
+ over SSE, and verifies the owner token that Mango mints.
8
+
9
+ It is dependency-free (`node:http` only), so it runs in-process from the Mango CLI.
10
+
11
+ ## Running it
12
+
13
+ From a Mango project root (preferred — picks up `siteName` from `mango/config/settings.json`):
14
+
15
+ ```bash
16
+ mango vibe-orchestrator # listens on 127.0.0.1:7130
17
+ mango vibe-orchestrator --port 7130 # explicit port
18
+ ```
19
+
20
+ Or directly from the installed package (e.g. under a process manager / nginx vhost):
21
+
22
+ ```bash
23
+ node node_modules/mango-cms/lib/vibe-orchestrator/server.js
24
+ ```
25
+
26
+ The orchestrator listens on **loopback only**; front it with the staging/site
27
+ nginx vhost (or the Mango staging gateway) for browser access.
28
+
29
+ ## Environment contract
30
+
31
+ | Var | Required | Default | Purpose |
32
+ | --- | --- | --- | --- |
33
+ | `VIBE_ORCH_TOKEN_SECRET` | **yes** | – | HMAC secret **shared with Mango**, which mints per-owner tokens from an authenticated admin session. **Unset ⇒ fails closed**: every gated endpoint returns `503`. |
34
+ | `VIBE_STAGING_ROOT` | recommended | `/root/Staging` | Root dir under which each `<site>` is a clone the orchestrator edits. |
35
+ | `VIBE_ALLOWED_SITES` | recommended | project `siteName` (via `mango vibe-orchestrator`), else `generations-vibe` | Comma-separated allow-list of site slugs. |
36
+ | `CLAUDE_CODE_OAUTH_TOKEN` | one of these | – | Claude OAuth token. If unset, the orchestrator falls back to the Claude CLI's own login at… |
37
+ | `VIBE_CLAUDE_CREDENTIALS` | – | `~/.claude/.credentials.json` | …this auto-refreshing credentials file. One of the two must be present for **live** runs (`/health` reports `canRunLive`). |
38
+ | `VIBE_CLAUDE_BIN` | – | `claude` | Path to the `claude` CLI binary. Point at `scripts/fake-claude.mjs` for a token-free pipeline smoke test. |
39
+ | `VIBE_ORCH_PORT` | – | `7130` | Loopback listen port (`--port` overrides). |
40
+ | `VIBE_MODEL` | – | CLI default | Optional model override (e.g. `claude-opus-4-8`). |
41
+ | `VIBE_TOKEN_CAP` | – | `2000000` | Per-session cumulative token safety cap (`0` = unlimited). |
42
+ | `VIBE_PERMISSION_MODE` | – | `acceptEdits` | Headless permission mode (deliberately **not** `bypassPermissions`). |
43
+ | `VIBE_ALLOWED_ORIGINS` | – | staging/prod site + `localhost:7121` | Browser origins allowed to call cross-origin (CORS). |
44
+ | `VIBE_PUBLISH_*`, `VIBE_PATH_GUARD*`, `VIBE_AUTO_COMMIT`, `VIBE_COST_*`, `VIBE_SESSION_IDLE_MS` | – | see `src/config.js` | Publish, self-protection, cost-mirror and reaper tuning. |
45
+
46
+ The owner token is HMAC-verified against `VIBE_ORCH_TOKEN_SECRET` — Mango mints it
47
+ (`POST /endpoints/vibe/token`) and the orchestrator verifies it (`src/ownerToken.js`).
48
+ Client-side role flags are never trusted; the Claude credentials are never exposed
49
+ by any endpoint.
50
+
51
+ ## Endpoints
52
+
53
+ `GET /health` (public liveness, leaks no config) · `GET /health/details` ·
54
+ `GET /sessions` · `POST /prompt` (SSE) · `POST /abort` · `POST /reset` ·
55
+ `GET /publish/diff` · `POST /publish` · `GET /costs` · `GET /costs/rollup` ·
56
+ `GET /recovery/status` · `POST /recovery/revert-last` · `POST /recovery/reset-baseline`.
57
+
58
+ Every route except `GET /health` requires `Authorization: Bearer <owner-token>`.
59
+
60
+ ## Smoke test (no Claude token)
61
+
62
+ ```bash
63
+ VIBE_ORCH_TOKEN_SECRET=dev-secret \
64
+ VIBE_STAGING_ROOT=/path/to/clones VIBE_ALLOWED_SITES=mysite \
65
+ VIBE_CLAUDE_BIN="$PWD/node_modules/mango-cms/lib/vibe-orchestrator/scripts/fake-claude.mjs" \
66
+ CLAUDE_CODE_OAUTH_TOKEN=dummy \
67
+ mango vibe-orchestrator --port 7130
68
+ # then POST /prompt with a minted bearer token and watch accepted → progress* → end
69
+ ```
70
+
71
+ ## Source of truth
72
+
73
+ The runtime here (`server.js`, `src/`, `scripts/`) is the distributed copy. The
74
+ canonical development tree (with the full test suite) lives in the
75
+ `generations-vibe` repo under `orchestrator/`; this bundle is kept byte-identical
76
+ on release. See `docs/RELEASING.md`.
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env node
2
+ // fake-claude.mjs
3
+ //
4
+ // A stand-in for the real `claude` binary that emits a realistic stream-json
5
+ // sequence. Use it to validate the orchestrator's spawn → parse → SSE pipeline
6
+ // end-to-end WITHOUT a Claude OAuth token (so the live-token dependency does not
7
+ // block integration testing):
8
+ //
9
+ // VIBE_CLAUDE_BIN="$PWD/scripts/fake-claude.mjs" \
10
+ // CLAUDE_CODE_OAUTH_TOKEN=dummy node server.js
11
+ //
12
+ // then POST /prompt and watch the SSE events stream back.
13
+
14
+ const args = process.argv.slice(2)
15
+ const pIdx = args.indexOf('-p')
16
+ const prompt = pIdx >= 0 ? args[pIdx + 1] : '(no prompt)'
17
+ const resumeIdx = args.indexOf('--resume')
18
+ const sessionId = resumeIdx >= 0 ? args[resumeIdx + 1] : 'fake-sess-0001'
19
+
20
+ const lines = [
21
+ { type: 'system', subtype: 'init', session_id: sessionId, model: 'fake-model', tools: ['Read', 'Edit', 'Bash'], cwd: process.cwd() },
22
+ { type: 'assistant', message: { content: [{ type: 'text', text: `Working on: ${prompt}` }], usage: { input_tokens: 1200, output_tokens: 40 } } },
23
+ { type: 'assistant', message: { content: [{ type: 'tool_use', id: 'tu1', name: 'Edit', input: { file_path: 'src/App.vue' } }], usage: { input_tokens: 1300, output_tokens: 90 } } },
24
+ { type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: 'tu1', is_error: false, content: 'edited' }] } },
25
+ { type: 'assistant', message: { content: [{ type: 'text', text: 'Done. The hero heading is now blue.' }], usage: { input_tokens: 1400, output_tokens: 130 } } },
26
+ { type: 'result', subtype: 'success', session_id: sessionId, total_cost_usd: 0.018, duration_ms: 5200, num_turns: 3, usage: { input_tokens: 1400, output_tokens: 130 }, result: 'Done. The hero heading is now blue.' },
27
+ ]
28
+
29
+ let i = 0
30
+ const tick = () => {
31
+ if (i >= lines.length) return process.exit(0)
32
+ process.stdout.write(JSON.stringify(lines[i++]) + '\n')
33
+ setTimeout(tick, 250)
34
+ }
35
+ tick()
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env node
2
+ // path-guard-hook.mjs
3
+ //
4
+ // PreToolUse hook (HAP-1124, protection #1). Wired into every headless claude
5
+ // run via `--settings` (see claudeRunner.js) so it is enforced SERVER-SIDE,
6
+ // independent of the prompt. Claude invokes this before each Edit/Write/
7
+ // MultiEdit/NotebookEdit with a JSON payload on stdin:
8
+ //
9
+ // { cwd, tool_name, tool_input: { file_path | notebook_path, ... } }
10
+ //
11
+ // We resolve the target to a repo-root-relative path and, if it hits the
12
+ // protected denylist (or escapes the staging clone), block the tool by exiting
13
+ // with code 2 — the documented PreToolUse "deny" signal: the tool call is
14
+ // stopped and stderr is fed back to Claude as the reason, which it relays to the
15
+ // owner in the drawer. Anything else exits 0 (allow). The hook is fail-safe:
16
+ // any internal error allows the edit rather than wedging the agent, because the
17
+ // post-run git auto-commit + rollback (protection #2) is the backstop.
18
+
19
+ import path from 'node:path'
20
+ import { isProtectedPath, PROTECTED_MESSAGE } from '../src/pathGuard.js'
21
+
22
+ function readStdin() {
23
+ return new Promise((resolve) => {
24
+ let raw = ''
25
+ process.stdin.setEncoding('utf8')
26
+ process.stdin.on('data', (c) => { raw += c })
27
+ process.stdin.on('end', () => resolve(raw))
28
+ process.stdin.on('error', () => resolve(raw))
29
+ })
30
+ }
31
+
32
+ /** Collect every filesystem path a tool_input may target. */
33
+ function targetPaths(toolInput) {
34
+ if (!toolInput || typeof toolInput !== 'object') return []
35
+ const out = []
36
+ for (const key of ['file_path', 'notebook_path', 'path']) {
37
+ if (typeof toolInput[key] === 'string' && toolInput[key]) out.push(toolInput[key])
38
+ }
39
+ return out
40
+ }
41
+
42
+ /** Map an absolute-or-relative tool path to a clone-root-relative POSIX path. */
43
+ function toRel(cwd, p) {
44
+ const abs = path.isAbsolute(p) ? p : path.resolve(cwd || process.cwd(), p)
45
+ return path.relative(cwd || process.cwd(), abs)
46
+ }
47
+
48
+ async function main() {
49
+ let payload = {}
50
+ try { payload = JSON.parse((await readStdin()) || '{}') } catch { payload = {} }
51
+
52
+ const cwd = payload.cwd || process.cwd()
53
+ const toolInput = payload.tool_input || payload.toolInput || {}
54
+
55
+ for (const raw of targetPaths(toolInput)) {
56
+ const rel = toRel(cwd, raw)
57
+ // An edit that resolves outside the staging clone is never legitimate.
58
+ const escapes = rel.startsWith('..') || path.isAbsolute(rel)
59
+ if (escapes || isProtectedPath(rel)) {
60
+ process.stderr.write(
61
+ `${PROTECTED_MESSAGE} (${escapes ? 'outside the staging clone' : rel})`,
62
+ )
63
+ process.exit(2) // PreToolUse: block the tool, reason → Claude
64
+ }
65
+ }
66
+
67
+ process.exit(0) // allow
68
+ }
69
+
70
+ main().catch(() => process.exit(0)) // fail-open: rollback is the backstop
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env bash
2
+ # vibe-recover.sh — one-command rollback for the Vibe staging clone (HAP-1124).
3
+ #
4
+ # A recovery path usable WITHOUT an engineer. Operates directly on the staging
5
+ # clone's git, so it works even if the orchestrator process is down.
6
+ #
7
+ # vibe-recover.sh status # show baseline + recent vibe edits
8
+ # vibe-recover.sh revert-last # undo the most recent vibe edit (git revert)
9
+ # vibe-recover.sh reset-baseline # hard-reset to the known-good baseline
10
+ #
11
+ # Env (override as needed):
12
+ # VIBE_STAGING_ROOT (default /root/Staging)
13
+ # VIBE_SITE (default generations-vibe)
14
+ # VIBE_BASELINE_TAG (default vibe-baseline)
15
+ set -euo pipefail
16
+
17
+ ROOT="${VIBE_STAGING_ROOT:-/root/Staging}"
18
+ SITE="${VIBE_SITE:-generations-vibe}"
19
+ TAG="${VIBE_BASELINE_TAG:-vibe-baseline}"
20
+ SETTINGS="mango/config/settings.json"
21
+ DIR="$ROOT/$SITE"
22
+ CMD="${1:-status}"
23
+
24
+ cd "$DIR" || { echo "staging clone not found: $DIR" >&2; exit 1; }
25
+
26
+ case "$CMD" in
27
+ status)
28
+ echo "site: $SITE ($DIR)"
29
+ echo "HEAD: $(git rev-parse --short HEAD 2>/dev/null || echo '(none)')"
30
+ if git rev-parse --verify --quiet "${TAG}^{commit}" >/dev/null; then
31
+ echo "baseline: $TAG -> $(git rev-parse --short "$TAG")"
32
+ else
33
+ echo "baseline: (none yet — created on first vibe edit)"
34
+ fi
35
+ echo "recent vibe edits:"
36
+ git log -n 10 --pretty=format:' %h %cs %s' | grep -E ' vibe:' || echo " (none)"
37
+ echo
38
+ ;;
39
+
40
+ revert-last)
41
+ last_msg="$(git log -1 --pretty=format:'%s')"
42
+ case "$last_msg" in
43
+ vibe:*) ;;
44
+ *) echo "the most recent commit is not a vibe edit — nothing to revert" >&2; exit 1 ;;
45
+ esac
46
+ git revert --no-edit HEAD
47
+ echo "reverted last vibe edit; HEAD is now $(git rev-parse --short HEAD)"
48
+ ;;
49
+
50
+ reset-baseline)
51
+ if ! git rev-parse --verify --quiet "${TAG}^{commit}" >/dev/null; then
52
+ echo "no baseline tag ($TAG) to reset to" >&2; exit 1
53
+ fi
54
+ git reset --hard "$TAG"
55
+ git clean -fd -e "$SETTINGS"
56
+ echo "reset clone to baseline $TAG ($(git rev-parse --short "$TAG"))"
57
+ ;;
58
+
59
+ *)
60
+ echo "usage: vibe-recover.sh {status|revert-last|reset-baseline}" >&2
61
+ exit 2
62
+ ;;
63
+ esac
@@ -0,0 +1,344 @@
1
+ // server.js
2
+ //
3
+ // Orchestrator HTTP entrypoint. Dependency-free (node:http). Exposes:
4
+ //
5
+ // GET /health -> { ok, canRunLive, config summary }
6
+ // GET /sessions -> [ { site, sessionId, cumulativeTokens, status } ]
7
+ // POST /prompt -> SSE stream of progress events body: { site, prompt, pageContext?, attachments? }
8
+ // POST /abort -> { ok } body: { site }
9
+ // POST /reset -> { ok } body: { site }
10
+ // POST /publish -> { ... } body: { site, confirm }
11
+ //
12
+ // Owner auth (HAP-1109): every endpoint except `GET /health` requires a real
13
+ // per-owner token in `Authorization: Bearer <token>`. The token is minted by
14
+ // Mango from an authenticated admin session and verified here against the shared
15
+ // VIBE_ORCH_TOKEN_SECRET (see src/ownerToken.js). Without that secret the server
16
+ // fails CLOSED — gated endpoints return 503, never open access. Client-side role
17
+ // flags are never trusted. The Claude OAuth token is never exposed by any
18
+ // endpoint. Intended to listen on localhost only and be fronted by the staging
19
+ // nginx vhost (see README).
20
+
21
+ import http from 'node:http'
22
+ import { SessionManager } from './src/sessionManager.js'
23
+ import { config, canRunLive } from './src/config.js'
24
+ import { verifyOwnerToken } from './src/ownerToken.js'
25
+ import * as publisher from './src/publisher.js'
26
+ import * as recovery from './src/recovery.js'
27
+ import { readCosts, rollup } from './src/costStore.js'
28
+
29
+ const manager = new SessionManager()
30
+
31
+ /** Extract the bearer token from the Authorization header. */
32
+ function bearer(req) {
33
+ const h = req.headers['authorization'] || ''
34
+ const m = /^Bearer\s+(.+)$/i.exec(h.trim())
35
+ return m ? m[1].trim() : ''
36
+ }
37
+
38
+ /**
39
+ * Resolve owner auth for a request. Returns the verifyOwnerToken result:
40
+ * { ok:true, payload } or { ok:false, reason, status }. Fails closed when no
41
+ * token secret is configured (status 503).
42
+ */
43
+ function requireOwner(req) {
44
+ return verifyOwnerToken(bearer(req), {
45
+ secret: config.tokenSecret,
46
+ ownerRoles: config.ownerRoles,
47
+ })
48
+ }
49
+
50
+ function sendJson(res, status, body) {
51
+ const data = JSON.stringify(body)
52
+ res.writeHead(status, { 'content-type': 'application/json' })
53
+ res.end(data)
54
+ }
55
+
56
+ /**
57
+ * Apply CORS headers for an allowed browser origin (HAP-1121). The ⌘K drawer is
58
+ * served from the site origin (e.g. https://staging-vibe.generations.org) and
59
+ * calls this orchestrator on the -api origin cross-origin. Without an allow-list
60
+ * + preflight handling the browser blocks every call and the drawer shows
61
+ * "Failed to fetch" (a transport failure with no HTTP status). Headers are set
62
+ * with setHeader so they merge into later writeHead() calls (JSON + SSE alike).
63
+ * Returns true when the request Origin is allowed.
64
+ */
65
+ function applyCors(req, res) {
66
+ const origin = req.headers['origin']
67
+ if (!origin) return false
68
+ const list = config.allowedOrigins
69
+ const allowed = list.length === 0 || list.includes(origin)
70
+ if (!allowed) return false
71
+ res.setHeader('Access-Control-Allow-Origin', origin)
72
+ res.setHeader('Vary', 'Origin')
73
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
74
+ res.setHeader('Access-Control-Allow-Headers', 'authorization, content-type')
75
+ res.setHeader('Access-Control-Max-Age', '86400')
76
+ return true
77
+ }
78
+
79
+ /**
80
+ * Resolve a { from, to } ms window from query params (HAP-1123). Each of `from`
81
+ * and `to` may be epoch-ms or an ISO date (YYYY-MM-DD). With neither present we
82
+ * default to the current calendar month-to-date so a bare `/costs/rollup` answers
83
+ * "this month's spend". An ISO date with no time is treated as UTC midnight.
84
+ */
85
+ function parseRange(params) {
86
+ const parse = (v) => {
87
+ if (!v) return undefined
88
+ if (/^\d+$/.test(v)) return Number.parseInt(v, 10)
89
+ const t = Date.parse(v)
90
+ return Number.isNaN(t) ? undefined : t
91
+ }
92
+ let from = parse(params.get('from'))
93
+ let to = parse(params.get('to'))
94
+ if (from === undefined && to === undefined) {
95
+ const now = new Date()
96
+ from = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)
97
+ }
98
+ return { from, to }
99
+ }
100
+
101
+ function readBody(req) {
102
+ return new Promise((resolve, reject) => {
103
+ let raw = ''
104
+ req.on('data', (c) => {
105
+ raw += c
106
+ // 80MB ceiling: prompt bodies may carry a base64 viewport screenshot
107
+ // (HAP-1111) plus up to 5 user attachments of ≤10MB each (HAP-1126). The
108
+ // per-file size/mime/count guard runs again server-side in attachments.js;
109
+ // this is just the gross transport cap.
110
+ if (raw.length > 80_000_000) reject(new Error('body too large'))
111
+ })
112
+ req.on('end', () => {
113
+ if (!raw) return resolve({})
114
+ try { resolve(JSON.parse(raw)) } catch { reject(new Error('invalid JSON body')) }
115
+ })
116
+ req.on('error', reject)
117
+ })
118
+ }
119
+
120
+ function startSse(res, { onPing } = {}) {
121
+ res.writeHead(200, {
122
+ 'content-type': 'text/event-stream',
123
+ 'cache-control': 'no-cache',
124
+ connection: 'keep-alive',
125
+ 'x-accel-buffering': 'no', // disable nginx proxy buffering
126
+ })
127
+ const write = (event, data) => {
128
+ res.write(`event: ${event}\n`)
129
+ res.write(`data: ${JSON.stringify(data)}\n\n`)
130
+ }
131
+ // keepalive comment ping so idle proxies don't drop the connection. The ping
132
+ // also counts as session activity (HAP-1110) so an actively-read but idle
133
+ // session is kept warm rather than reaped mid-read.
134
+ const ping = setInterval(() => {
135
+ res.write(': ping\n\n')
136
+ onPing?.()
137
+ }, 15000)
138
+ if (ping.unref) ping.unref()
139
+ return { write, close: () => clearInterval(ping) }
140
+ }
141
+
142
+ async function handlePrompt(req, res) {
143
+ let body
144
+ try { body = await readBody(req) } catch (e) { return sendJson(res, 400, { error: e.message }) }
145
+ const { site, prompt, pageContext, attachments } = body
146
+
147
+ let handle
148
+ try {
149
+ handle = manager.submit(site, prompt, { pageContext, attachments })
150
+ } catch (e) {
151
+ return sendJson(res, 400, { error: e.message })
152
+ }
153
+
154
+ // Track this connection as an active reader and keep the session warm on ping.
155
+ manager.addReader(site)
156
+ const sse = startSse(res, { onPing: () => manager.touch(site) })
157
+ sse.write('accepted', { site })
158
+
159
+ handle.emitter.on('event', (ev) => sse.write('progress', ev))
160
+ handle.emitter.on('error', (err) => sse.write('error', { message: err.message }))
161
+ handle.emitter.on('end', (summary) => {
162
+ sse.write('end', summary)
163
+ sse.close()
164
+ manager.removeReader(site)
165
+ res.end()
166
+ })
167
+
168
+ // If the client disconnects, abort the run to free the slot.
169
+ req.on('close', () => {
170
+ if (!res.writableEnded) {
171
+ handle.abort()
172
+ manager.removeReader(site)
173
+ }
174
+ sse.close()
175
+ })
176
+ }
177
+
178
+ const server = http.createServer(async (req, res) => {
179
+ const url = new URL(req.url, 'http://localhost')
180
+ const route = `${req.method} ${url.pathname}`
181
+
182
+ // CORS (HAP-1121): set allow-origin headers for an allowed browser origin and
183
+ // answer the preflight BEFORE the auth gate. Browsers never send the
184
+ // Authorization header on an OPTIONS preflight, so gating it on auth returned
185
+ // 401 and the whole cross-origin call failed in-browser as "Failed to fetch".
186
+ applyCors(req, res)
187
+ if (req.method === 'OPTIONS') {
188
+ res.writeHead(204)
189
+ return res.end()
190
+ }
191
+
192
+ // `GET /health` is the only unauthenticated route: a minimal liveness probe
193
+ // for ops/nginx that leaks no config. Everything else requires an owner token.
194
+ if (route === 'GET /health') {
195
+ return sendJson(res, 200, { ok: true, ownerAuth: !!config.tokenSecret })
196
+ }
197
+
198
+ // Real per-owner gate. 401 = no/invalid token, 403 = valid but not an owner,
199
+ // 503 = server not configured for owner auth (fails closed).
200
+ const auth = requireOwner(req)
201
+ if (!auth.ok) return sendJson(res, auth.status || 401, { error: auth.reason || 'unauthorized' })
202
+ req.owner = auth.payload
203
+
204
+ try {
205
+ switch (route) {
206
+ case 'GET /health/details':
207
+ return sendJson(res, 200, {
208
+ ok: true,
209
+ canRunLive: canRunLive(),
210
+ stagingRoot: config.stagingRoot,
211
+ allowedSites: config.allowedSites,
212
+ tokenCap: config.tokenCap,
213
+ permissionMode: config.permissionMode,
214
+ publishEnabled: config.publishEnabled,
215
+ publishRemote: config.publishRemote,
216
+ settingsPath: config.settingsPath,
217
+ })
218
+
219
+ case 'GET /sessions':
220
+ return sendJson(res, 200, { sessions: manager.list() })
221
+
222
+ // ---- Cost tracking (HAP-1123) ----
223
+ // GET /costs — raw per-turn records in a window (for export/audit)
224
+ // GET /costs/rollup — summed totals (by day/session/model) to invoice from
225
+ // Both default to the current calendar month when no range is given, and
226
+ // accept ?from / ?to as epoch-ms or YYYY-MM-DD, plus an optional ?site.
227
+ case 'GET /costs': {
228
+ const range = parseRange(url.searchParams)
229
+ const site = url.searchParams.get('site') || undefined
230
+ return sendJson(res, 200, { ...range, site: site || null, records: readCosts({ ...range, site }) })
231
+ }
232
+
233
+ case 'GET /costs/rollup': {
234
+ const range = parseRange(url.searchParams)
235
+ const site = url.searchParams.get('site') || undefined
236
+ return sendJson(res, 200, rollup({ ...range, site }))
237
+ }
238
+
239
+ case 'POST /prompt':
240
+ return await handlePrompt(req, res)
241
+
242
+ case 'GET /publish/diff': {
243
+ const site = url.searchParams.get('site') || config.allowedSites[0]
244
+ try {
245
+ const d = await publisher.diff(site)
246
+ return sendJson(res, 200, d)
247
+ } catch (e) {
248
+ return sendJson(res, e.statusCode || 400, { error: e.message })
249
+ }
250
+ }
251
+
252
+ case 'POST /publish': {
253
+ let body
254
+ try { body = await readBody(req) } catch (e) { return sendJson(res, 400, { error: e.message }) }
255
+ const { site, message, confirm } = body
256
+ try {
257
+ const result = await publisher.publish(site || config.allowedSites[0], { message, confirm })
258
+ return sendJson(res, 200, result)
259
+ } catch (e) {
260
+ // 409 ⇒ merge conflict publishing the branch into the deploy branch;
261
+ // surface the conflicting files so the owner can resolve them.
262
+ return sendJson(res, e.statusCode || 500, {
263
+ error: e.message,
264
+ steps: e.steps,
265
+ conflicts: e.conflicts,
266
+ deployBranch: e.deployBranch,
267
+ })
268
+ }
269
+ }
270
+
271
+ // ---- Recovery / rollback (HAP-1124) ----
272
+ // GET /recovery/status — baseline + recent vibe commits + what's available
273
+ // POST /recovery/revert-last — undo the most recent vibe edit (git revert)
274
+ // POST /recovery/reset-baseline— hard-reset to the known-good baseline (confirm:true)
275
+ // A recovery path usable without an engineer; also wrapped by scripts/vibe-recover.sh.
276
+ case 'GET /recovery/status': {
277
+ const site = url.searchParams.get('site') || config.allowedSites[0]
278
+ try {
279
+ return sendJson(res, 200, await recovery.status(site))
280
+ } catch (e) {
281
+ return sendJson(res, e.statusCode || 400, { error: e.message })
282
+ }
283
+ }
284
+
285
+ case 'POST /recovery/revert-last': {
286
+ const { site } = await readBody(req)
287
+ const target = site || config.allowedSites[0]
288
+ if (manager.get(target)?.status === 'running') {
289
+ return sendJson(res, 409, { error: 'cannot recover while a run is in progress' })
290
+ }
291
+ try {
292
+ return sendJson(res, 200, await recovery.revertLast(target))
293
+ } catch (e) {
294
+ return sendJson(res, e.statusCode || 400, { error: e.message })
295
+ }
296
+ }
297
+
298
+ case 'POST /recovery/reset-baseline': {
299
+ const { site, confirm } = await readBody(req)
300
+ const target = site || config.allowedSites[0]
301
+ if (confirm !== true) {
302
+ return sendJson(res, 400, { error: 'reset to baseline requires explicit confirm:true' })
303
+ }
304
+ if (manager.get(target)?.status === 'running') {
305
+ return sendJson(res, 409, { error: 'cannot recover while a run is in progress' })
306
+ }
307
+ try {
308
+ return sendJson(res, 200, await recovery.resetToBaseline(target))
309
+ } catch (e) {
310
+ return sendJson(res, e.statusCode || 400, { error: e.message })
311
+ }
312
+ }
313
+
314
+ case 'POST /abort': {
315
+ const { site } = await readBody(req)
316
+ manager.abort(site)
317
+ return sendJson(res, 200, { ok: true })
318
+ }
319
+
320
+ case 'POST /reset': {
321
+ const { site } = await readBody(req)
322
+ try { manager.reset(site) } catch (e) { return sendJson(res, 409, { error: e.message }) }
323
+ return sendJson(res, 200, { ok: true })
324
+ }
325
+
326
+ default:
327
+ return sendJson(res, 404, { error: 'not found' })
328
+ }
329
+ } catch (e) {
330
+ if (!res.headersSent) sendJson(res, 500, { error: e.message })
331
+ }
332
+ })
333
+
334
+ // Listen on loopback only; nginx terminates TLS and proxies.
335
+ server.listen(config.port, '127.0.0.1', () => {
336
+ // eslint-disable-next-line no-console
337
+ console.log(`[vibe-orchestrator] listening on 127.0.0.1:${config.port} (live=${canRunLive()}, ownerAuth=${!!config.tokenSecret})`)
338
+ if (!config.tokenSecret) {
339
+ // eslint-disable-next-line no-console
340
+ console.warn('[vibe-orchestrator] WARNING: VIBE_ORCH_TOKEN_SECRET is unset — owner auth is NOT configured; all gated endpoints will return 503 (fail closed).')
341
+ }
342
+ })
343
+
344
+ export { server }