mango-cms 0.3.33 → 0.3.35
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.
- package/cli.js +57 -0
- package/default/infra/vibe/README.md +43 -0
- package/default/infra/vibe/cloudflare.ini.template +26 -0
- package/default/infra/vibe/ecosystem.vibe.config.cjs +44 -0
- package/default/infra/vibe/nginx-vibe-orchestrator.conf.template +50 -0
- package/default/infra/vibe/nginx-vibe-staging.conf.template +73 -0
- package/default/infra/vibe/vibe-gateway.service +38 -0
- package/default/infra/vibe/vibe-orchestrator.service +44 -0
- package/default/infra/vibe/vibe.env.template +24 -0
- package/default/mango/config/settings.json +35 -1
- package/default/vite.config.js +46 -0
- package/lib/devProxyGateway.js +173 -0
- package/lib/staging-gateway.js +23 -1
- package/lib/vibe-orchestrator/README.md +76 -0
- package/lib/vibe-orchestrator/scripts/fake-claude.mjs +35 -0
- package/lib/vibe-orchestrator/scripts/path-guard-hook.mjs +70 -0
- package/lib/vibe-orchestrator/scripts/vibe-recover.sh +63 -0
- package/lib/vibe-orchestrator/server.js +344 -0
- package/lib/vibe-orchestrator/src/attachments.js +98 -0
- package/lib/vibe-orchestrator/src/claudeRunner.js +233 -0
- package/lib/vibe-orchestrator/src/config.js +227 -0
- package/lib/vibe-orchestrator/src/costMirror.js +64 -0
- package/lib/vibe-orchestrator/src/costStore.js +209 -0
- package/lib/vibe-orchestrator/src/ownerToken.js +113 -0
- package/lib/vibe-orchestrator/src/pathGuard.js +114 -0
- package/lib/vibe-orchestrator/src/preamble.js +139 -0
- package/lib/vibe-orchestrator/src/publisher.js +376 -0
- package/lib/vibe-orchestrator/src/recovery.js +199 -0
- package/lib/vibe-orchestrator/src/screenshot.js +38 -0
- package/lib/vibe-orchestrator/src/sessionManager.js +291 -0
- package/lib/vibe-orchestrator/src/streamParser.js +188 -0
- package/lib/vibe-orchestrator/src/tokenMeter.js +64 -0
- package/package.json +1 -1
- package/readme.md +6 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
// config.js
|
|
2
|
+
//
|
|
3
|
+
// Centralized env reading + validation for the orchestrator. The OAuth token is
|
|
4
|
+
// never read from git — it lives in /root/.vibe-orchestrator.env on the droplet
|
|
5
|
+
// (chmod 600) and is sourced into the process env before launch (see README).
|
|
6
|
+
|
|
7
|
+
import path from 'node:path'
|
|
8
|
+
import fs from 'node:fs'
|
|
9
|
+
import os from 'node:os'
|
|
10
|
+
import { fileURLToPath } from 'node:url'
|
|
11
|
+
|
|
12
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
13
|
+
|
|
14
|
+
function envInt(name, fallback) {
|
|
15
|
+
const v = process.env[name]
|
|
16
|
+
if (v == null || v === '') return fallback
|
|
17
|
+
const n = Number.parseInt(v, 10)
|
|
18
|
+
return Number.isFinite(n) ? n : fallback
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const config = {
|
|
22
|
+
// HTTP/SSE listen port for the orchestrator service.
|
|
23
|
+
port: envInt('VIBE_ORCH_PORT', 7130),
|
|
24
|
+
|
|
25
|
+
// Root under which each <site> is a clone, e.g. /root/Staging/<site>.
|
|
26
|
+
stagingRoot: process.env.VIBE_STAGING_ROOT || '/root/Staging',
|
|
27
|
+
|
|
28
|
+
// Whitelist of site slugs the orchestrator is allowed to operate on. Empty =>
|
|
29
|
+
// any directory directly under stagingRoot is permitted (still path-guarded).
|
|
30
|
+
allowedSites: (process.env.VIBE_ALLOWED_SITES || 'generations-vibe')
|
|
31
|
+
.split(',')
|
|
32
|
+
.map((s) => s.trim())
|
|
33
|
+
.filter(Boolean),
|
|
34
|
+
|
|
35
|
+
// claude CLI binary (v2.1.157 installed at /usr/local/bin/claude on droplet).
|
|
36
|
+
claudeBin: process.env.VIBE_CLAUDE_BIN || 'claude',
|
|
37
|
+
|
|
38
|
+
// Per-session cumulative token cap. 0 = unlimited. Raised from 200k → 2M
|
|
39
|
+
// (HAP-1096): a real vibe session reads files + viewport screenshots (images are
|
|
40
|
+
// token-heavy) and 200k was tripping mid-demo. Cost is still tracked + billed per
|
|
41
|
+
// turn, so this is a runaway-safety rail, not a budget — tune via VIBE_TOKEN_CAP.
|
|
42
|
+
tokenCap: envInt('VIBE_TOKEN_CAP', 2_000_000),
|
|
43
|
+
|
|
44
|
+
// Headless permission mode. Deliberately NOT bypassPermissions: this droplet is
|
|
45
|
+
// shared (it hosts ~40 other production sites) and the orchestrator runs as root,
|
|
46
|
+
// so blanket-allowing Bash would let an errant run `rm -rf` a sibling tenant — a
|
|
47
|
+
// blast radius the HAP-1124 path guard does NOT cover (it only matches Edit/Write,
|
|
48
|
+
// not Bash). acceptEdits keeps that destructive/network Bash gated while still
|
|
49
|
+
// auto-accepting file edits on the throwaway clone. The HAP-1096 web-fetch +
|
|
50
|
+
// screenshot-read failures are fixed surgically instead via `allowedTools` +
|
|
51
|
+
// `toolExtraDirs` below — read-only capabilities with no shared-droplet risk.
|
|
52
|
+
permissionMode: process.env.VIBE_PERMISSION_MODE || 'acceptEdits',
|
|
53
|
+
|
|
54
|
+
// Tools pre-approved for headless runs (no interactive approver exists, so
|
|
55
|
+
// otherwise-gated tools are auto-DENIED). WebFetch/WebSearch are read-only network
|
|
56
|
+
// reads with no filesystem/destructive risk — enabling them lets the owner say
|
|
57
|
+
// "use our real logo + brand blue from generations.org" and have it work (HAP-1096,
|
|
58
|
+
// where WebFetch showed "isn't permitted here"). Space/comma separated override.
|
|
59
|
+
allowedTools: (process.env.VIBE_ALLOWED_TOOLS || 'WebFetch WebSearch')
|
|
60
|
+
.split(/[\s,]+/)
|
|
61
|
+
.map((s) => s.trim())
|
|
62
|
+
.filter(Boolean),
|
|
63
|
+
|
|
64
|
+
// Extra directories the run's tools may access (--add-dir). The viewport
|
|
65
|
+
// screenshot and any pasted/uploaded attachments are persisted OUTSIDE the cwd
|
|
66
|
+
// (os.tmpdir()/vibe-orchestrator, so they never land in the editable clone); under
|
|
67
|
+
// acceptEdits the agent otherwise can't Read them — that is the HAP-1096 "screenshot
|
|
68
|
+
// file gated behind approval" failure. Allowing this one temp dir restores the read.
|
|
69
|
+
toolExtraDirs: (
|
|
70
|
+
process.env.VIBE_TOOL_EXTRA_DIRS
|
|
71
|
+
|| path.join(os.tmpdir(), 'vibe-orchestrator')
|
|
72
|
+
)
|
|
73
|
+
.split(/[\s,]+/)
|
|
74
|
+
.map((s) => s.trim())
|
|
75
|
+
.filter(Boolean),
|
|
76
|
+
|
|
77
|
+
// Optional model override (e.g. claude-opus-4-8). Empty => CLI default.
|
|
78
|
+
model: process.env.VIBE_MODEL || '',
|
|
79
|
+
|
|
80
|
+
// The OAuth token. An EXPLICIT token is optional: when empty, live runs use
|
|
81
|
+
// the claude CLI's own auto-refreshing login credentials instead (the Phase-1
|
|
82
|
+
// decision — "Claude Code CLI not API key"). Never required for the server to
|
|
83
|
+
// boot — scaffold/stream-parse/session work runs token-free.
|
|
84
|
+
oauthToken: process.env.CLAUDE_CODE_OAUTH_TOKEN || '',
|
|
85
|
+
|
|
86
|
+
// Path to the claude CLI's auto-refreshing login credentials. When present,
|
|
87
|
+
// live runs work without an explicit oauthToken. Override only in tests.
|
|
88
|
+
claudeCredentialsPath:
|
|
89
|
+
process.env.VIBE_CLAUDE_CREDENTIALS
|
|
90
|
+
|| path.join(os.homedir(), '.claude', '.credentials.json'),
|
|
91
|
+
|
|
92
|
+
// Wall-clock ceiling for a single claude invocation (ms). 0 = no timeout.
|
|
93
|
+
runTimeoutMs: envInt('VIBE_RUN_TIMEOUT_MS', 10 * 60 * 1000),
|
|
94
|
+
|
|
95
|
+
// ---- Publish (staging → prod via the existing pipeline; HAP-1103) ----
|
|
96
|
+
|
|
97
|
+
// Master switch for the publish endpoints. The actual `git push` is also
|
|
98
|
+
// gated by an explicit per-call confirm; this is the operator-level kill switch.
|
|
99
|
+
publishEnabled: String(process.env.VIBE_PUBLISH_ENABLED ?? 'true').toLowerCase() !== 'false',
|
|
100
|
+
|
|
101
|
+
// Remote the publish push targets (push to its tracked branch → ssh-deploy).
|
|
102
|
+
publishRemote: process.env.VIBE_PUBLISH_REMOTE || 'origin',
|
|
103
|
+
|
|
104
|
+
// Deploy branch each workspace branch merges INTO on publish. ssh-deploy only
|
|
105
|
+
// triggers on a push to this branch (`.github/workflows/ssh-deploy.yml` →
|
|
106
|
+
// update.sh on prod), so a per-branch workspace must merge here to reach prod.
|
|
107
|
+
// When a workspace is already on this branch, publish pushes it directly.
|
|
108
|
+
publishBranch: process.env.VIBE_PUBLISH_BRANCH || 'main',
|
|
109
|
+
|
|
110
|
+
// Env-specific file that must NEVER be pushed. The staging overlay materializes
|
|
111
|
+
// the staging block into it; publish restores it (`git checkout`) before commit.
|
|
112
|
+
settingsPath: process.env.VIBE_SETTINGS_PATH || 'mango/config/settings.json',
|
|
113
|
+
|
|
114
|
+
// ---- Self-protection: path guard + rollback (HAP-1124) ----
|
|
115
|
+
|
|
116
|
+
// Server-side denylist enforcement. When true, every live run is launched with
|
|
117
|
+
// a PreToolUse hook (scripts/path-guard-hook.mjs) that blocks edits to the Vibe
|
|
118
|
+
// system itself (drawer, orchestrator, staging spine, settings, deploy/CI). This
|
|
119
|
+
// is enforced regardless of the prompt. Disable only for tests/debugging.
|
|
120
|
+
pathGuardEnabled: String(process.env.VIBE_PATH_GUARD ?? 'true').toLowerCase() !== 'false',
|
|
121
|
+
|
|
122
|
+
// Absolute path to the PreToolUse hook script. Lives in the orchestrator tree
|
|
123
|
+
// (itself protected), never inside the editable site clone.
|
|
124
|
+
pathGuardHook:
|
|
125
|
+
process.env.VIBE_PATH_GUARD_HOOK
|
|
126
|
+
|| path.resolve(__dirname, '..', 'scripts', 'path-guard-hook.mjs'),
|
|
127
|
+
|
|
128
|
+
// Auto-commit every applied vibe edit to the staging clone's git history and
|
|
129
|
+
// keep a known-good baseline tag, so there is always a way back (revert last /
|
|
130
|
+
// reset to baseline). Disable only for tests that drive a fake runner.
|
|
131
|
+
autoCommitEnabled: String(process.env.VIBE_AUTO_COMMIT ?? 'true').toLowerCase() !== 'false',
|
|
132
|
+
|
|
133
|
+
// Annotated tag marking the known-good baseline a "reset to baseline" returns to.
|
|
134
|
+
baselineTag: process.env.VIBE_BASELINE_TAG || 'vibe-baseline',
|
|
135
|
+
|
|
136
|
+
// ---- Owner auth (HAP-1109) ----
|
|
137
|
+
|
|
138
|
+
// HMAC secret shared ONLY between Mango (which mints owner tokens from an
|
|
139
|
+
// authenticated admin session) and this orchestrator (which verifies them).
|
|
140
|
+
// Lives in /root/.vibe-orchestrator.env on the droplet (chmod 600), never in
|
|
141
|
+
// git. UNSET ⇒ the orchestrator fails CLOSED: every gated endpoint returns 503
|
|
142
|
+
// until it is configured. This is the real per-owner gate that replaces the old
|
|
143
|
+
// optional shared secret.
|
|
144
|
+
tokenSecret: process.env.VIBE_ORCH_TOKEN_SECRET || '',
|
|
145
|
+
|
|
146
|
+
// Roles that count as a site owner (any match grants access).
|
|
147
|
+
ownerRoles: (process.env.VIBE_OWNER_ROLES || 'admin')
|
|
148
|
+
.split(',')
|
|
149
|
+
.map((s) => s.trim())
|
|
150
|
+
.filter(Boolean),
|
|
151
|
+
|
|
152
|
+
// ---- Cost tracking (HAP-1123) ----
|
|
153
|
+
|
|
154
|
+
// Client-facing markup on the RAW API cost, as a percentage (board request on
|
|
155
|
+
// HAP-1123). 100 ⇒ a 100% markup ⇒ the client is shown/billed 2× the raw cost.
|
|
156
|
+
// The raw CLI cost stays the persisted source of truth; the markup is applied
|
|
157
|
+
// only at display + rollup time so the rate can change without rewriting history.
|
|
158
|
+
markupPct: envInt('VIBE_COST_MARKUP_PCT', 100),
|
|
159
|
+
|
|
160
|
+
// Mango ingest endpoint that mirrors each per-turn cost record into the
|
|
161
|
+
// `vibeCosts` collection (queryable in the CMS for invoicing). UNSET ⇒ the
|
|
162
|
+
// mirror is a no-op and the orchestrator's local NDJSON log is the only store
|
|
163
|
+
// (the rollup endpoint reads from it either way). Authenticated with the shared
|
|
164
|
+
// VIBE_ORCH_TOKEN_SECRET as a bearer; intended to point at localhost Mango.
|
|
165
|
+
costMirrorUrl: process.env.VIBE_COST_MIRROR_URL || '',
|
|
166
|
+
|
|
167
|
+
// ---- CORS (HAP-1121) ----
|
|
168
|
+
|
|
169
|
+
// Browser origins allowed to call the orchestrator cross-origin. The ⌘K drawer
|
|
170
|
+
// is served from the SITE origin (e.g. https://staging-vibe.generations.org)
|
|
171
|
+
// and calls the orchestrator on the -api origin, so the browser issues a CORS
|
|
172
|
+
// preflight. Without an allow-list + preflight handling every call surfaces as
|
|
173
|
+
// "Failed to fetch". Empty list ⇒ reflect any origin (dev only). Prod + staging
|
|
174
|
+
// site origins are the defaults.
|
|
175
|
+
allowedOrigins: (
|
|
176
|
+
process.env.VIBE_ALLOWED_ORIGINS
|
|
177
|
+
|| 'https://staging-vibe.generations.org,https://vibe.generations.org,http://localhost:7121'
|
|
178
|
+
)
|
|
179
|
+
.split(',')
|
|
180
|
+
.map((s) => s.trim())
|
|
181
|
+
.filter(Boolean),
|
|
182
|
+
|
|
183
|
+
// ---- Idle session reaping (HAP-1110) ----
|
|
184
|
+
|
|
185
|
+
// How long a session may sit idle (no prompts, no keepalive touches, no open
|
|
186
|
+
// readers) before it is reaped so the next prompt starts a fresh conversation.
|
|
187
|
+
// A running run or an open SSE reader always keeps it warm. 0 = never reap.
|
|
188
|
+
sessionIdleMs: envInt('VIBE_SESSION_IDLE_MS', 30 * 60 * 1000),
|
|
189
|
+
|
|
190
|
+
// How often the reaper sweeps. Kept well below sessionIdleMs.
|
|
191
|
+
reaperIntervalMs: envInt('VIBE_REAPER_INTERVAL_MS', 60 * 1000),
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Resolve + validate a site slug to an absolute, in-root working directory. */
|
|
195
|
+
function resolveSiteDir(site) {
|
|
196
|
+
if (!site || typeof site !== 'string' || !/^[a-zA-Z0-9._-]+$/.test(site)) {
|
|
197
|
+
throw new Error(`invalid site slug: ${JSON.stringify(site)}`)
|
|
198
|
+
}
|
|
199
|
+
if (config.allowedSites.length && !config.allowedSites.includes(site)) {
|
|
200
|
+
throw new Error(`site not in allowlist: ${site}`)
|
|
201
|
+
}
|
|
202
|
+
const dir = path.resolve(config.stagingRoot, site)
|
|
203
|
+
// Guard against traversal escaping the staging root.
|
|
204
|
+
if (dir !== path.join(config.stagingRoot, site)) {
|
|
205
|
+
throw new Error(`resolved path escapes staging root: ${site}`)
|
|
206
|
+
}
|
|
207
|
+
return dir
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** True when the claude CLI's auto-refreshing login credentials are present. */
|
|
211
|
+
function hasCliCredentials() {
|
|
212
|
+
try {
|
|
213
|
+
return fs.existsSync(config.claudeCredentialsPath)
|
|
214
|
+
} catch {
|
|
215
|
+
return false
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* True when a live claude run is possible: either an explicit OAuth token is
|
|
221
|
+
* configured, OR the claude CLI has its own login credentials to fall back on.
|
|
222
|
+
*/
|
|
223
|
+
function canRunLive() {
|
|
224
|
+
return !!config.oauthToken || hasCliCredentials()
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export { config, resolveSiteDir, canRunLive, hasCliCredentials }
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// costMirror.js
|
|
2
|
+
//
|
|
3
|
+
// Best-effort mirror of a per-turn cost record into the Mango `vibeCosts`
|
|
4
|
+
// collection (HAP-1123). The orchestrator's NDJSON log (costStore) is the durable
|
|
5
|
+
// system of record; this mirror makes the same record queryable in the CMS so the
|
|
6
|
+
// board can invoice from it. A mango outage NEVER loses data — the local log
|
|
7
|
+
// already has every record and the orchestrator rollup endpoint reads from it.
|
|
8
|
+
//
|
|
9
|
+
// Transport: a plain POST to the Mango ingest endpoint on localhost, authenticated
|
|
10
|
+
// with the shared VIBE_ORCH_TOKEN_SECRET as a bearer. Disabled (no-op) unless
|
|
11
|
+
// VIBE_COST_MIRROR_URL is configured, so it is fail-safe by default.
|
|
12
|
+
|
|
13
|
+
import http from 'node:http'
|
|
14
|
+
import https from 'node:https'
|
|
15
|
+
import { config } from './config.js'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* POST one normalized cost record to the Mango ingest endpoint. Resolves to
|
|
19
|
+
* `{ ok }`; never rejects (callers treat it as fire-and-forget). No-op when the
|
|
20
|
+
* mirror URL or shared secret is unset.
|
|
21
|
+
*
|
|
22
|
+
* @param {object} entry normalized cost record (from costStore)
|
|
23
|
+
* @param {object} [opts]
|
|
24
|
+
* @param {string} [opts.url] override the mirror URL (tests)
|
|
25
|
+
* @param {string} [opts.secret] override the bearer secret (tests)
|
|
26
|
+
* @param {function} [opts.requestFn] injectable transport (tests)
|
|
27
|
+
*/
|
|
28
|
+
function mirrorCost(entry, opts = {}) {
|
|
29
|
+
const url = opts.url || config.costMirrorUrl
|
|
30
|
+
const secret = opts.secret || config.tokenSecret
|
|
31
|
+
if (!url || !secret || !entry) return Promise.resolve({ ok: false, skipped: true })
|
|
32
|
+
|
|
33
|
+
return new Promise((resolve) => {
|
|
34
|
+
let target
|
|
35
|
+
try { target = new URL(url) } catch { return resolve({ ok: false, error: 'bad mirror url' }) }
|
|
36
|
+
const lib = target.protocol === 'https:' ? https : http
|
|
37
|
+
const requestFn = opts.requestFn || lib.request
|
|
38
|
+
const body = JSON.stringify(entry)
|
|
39
|
+
const req = requestFn(
|
|
40
|
+
{
|
|
41
|
+
hostname: target.hostname,
|
|
42
|
+
port: target.port,
|
|
43
|
+
path: target.pathname + target.search,
|
|
44
|
+
method: 'POST',
|
|
45
|
+
headers: {
|
|
46
|
+
'content-type': 'application/json',
|
|
47
|
+
'content-length': Buffer.byteLength(body),
|
|
48
|
+
authorization: `Bearer ${secret}`,
|
|
49
|
+
},
|
|
50
|
+
timeout: 5000,
|
|
51
|
+
},
|
|
52
|
+
(res) => {
|
|
53
|
+
res.resume() // drain
|
|
54
|
+
resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, status: res.statusCode })
|
|
55
|
+
},
|
|
56
|
+
)
|
|
57
|
+
req.on('error', () => resolve({ ok: false }))
|
|
58
|
+
req.on('timeout', () => { req.destroy(); resolve({ ok: false, error: 'timeout' }) })
|
|
59
|
+
req.write(body)
|
|
60
|
+
req.end()
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export { mirrorCost }
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// costStore.js
|
|
2
|
+
//
|
|
3
|
+
// Durable per-turn cost log for the vibe assistant (HAP-1123). Append-only NDJSON
|
|
4
|
+
// so it survives orchestrator restarts and stays queryable without standing up a
|
|
5
|
+
// DB inside this dependency-free service. One line per completed turn:
|
|
6
|
+
//
|
|
7
|
+
// { ts, site, path, sessionId, model, costUsd, tokens:{input,output,cacheRead,
|
|
8
|
+
// total,billable}, promptSummary, ok }
|
|
9
|
+
//
|
|
10
|
+
// The CLI's reported `total_cost_usd` is the source of truth for `costUsd`
|
|
11
|
+
// (captured in claudeRunner). When the CLI omits a cost we fall back to a
|
|
12
|
+
// model→price table (estimateCostUsd) so a record is never cost-less.
|
|
13
|
+
//
|
|
14
|
+
// Pure of the clock: callers pass `ts`. fs is touched only on append/read so the
|
|
15
|
+
// rollup math stays unit-testable against an injected path.
|
|
16
|
+
|
|
17
|
+
import fs from 'node:fs'
|
|
18
|
+
import path from 'node:path'
|
|
19
|
+
import { config } from './config.js'
|
|
20
|
+
|
|
21
|
+
/** Default NDJSON log location. Override with VIBE_COST_LOG (absolute path). */
|
|
22
|
+
function defaultLogPath() {
|
|
23
|
+
return process.env.VIBE_COST_LOG || path.join(config.stagingRoot, '.vibe-costs.ndjson')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Per-MTok USD prices (input / output) used ONLY when the CLI omits a cost. These
|
|
27
|
+
// are coarse list prices for sanity, not billing truth — the CLI's
|
|
28
|
+
// total_cost_usd is authoritative and is what we persist when present.
|
|
29
|
+
const PRICE_TABLE = [
|
|
30
|
+
{ match: /opus/i, inPerMTok: 15, outPerMTok: 75 },
|
|
31
|
+
{ match: /sonnet/i, inPerMTok: 3, outPerMTok: 15 },
|
|
32
|
+
{ match: /haiku/i, inPerMTok: 0.8, outPerMTok: 4 },
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Estimate a turn's cost from tokens when the CLI didn't report one. Cache-read
|
|
37
|
+
* input is billed at the same coarse input rate here (good enough for a fallback;
|
|
38
|
+
* the CLI's real number already accounts for cache discounts when available).
|
|
39
|
+
* @returns {number|null} USD, or null when the model is unknown
|
|
40
|
+
*/
|
|
41
|
+
function estimateCostUsd({ model, inputTokens = 0, outputTokens = 0 } = {}) {
|
|
42
|
+
if (!model) return null
|
|
43
|
+
const row = PRICE_TABLE.find((r) => r.match.test(model))
|
|
44
|
+
if (!row) return null
|
|
45
|
+
return (inputTokens / 1e6) * row.inPerMTok + (outputTokens / 1e6) * row.outPerMTok
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function num(v) {
|
|
49
|
+
return typeof v === 'number' && Number.isFinite(v) ? v : 0
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Apply the client-facing markup to a raw cost. `pct` is a percentage (100 ⇒ 2×).
|
|
54
|
+
* Returns null when the cost is absent so a missing cost never becomes $0.00.
|
|
55
|
+
* @param {number|null} cost raw USD
|
|
56
|
+
* @param {number} pct markup percent
|
|
57
|
+
*/
|
|
58
|
+
function applyMarkup(cost, pct) {
|
|
59
|
+
if (typeof cost !== 'number' || !Number.isFinite(cost)) return null
|
|
60
|
+
return cost * (1 + num(pct) / 100)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Normalize a raw record into the durable shape. Coerces token numbers and
|
|
65
|
+
* back-fills `costUsd` from the price table when absent. `ts` defaults to 0 so
|
|
66
|
+
* the module never reads the clock — real callers pass Date.now().
|
|
67
|
+
*/
|
|
68
|
+
function normalizeEntry(raw = {}) {
|
|
69
|
+
const t = raw.tokens || {}
|
|
70
|
+
const tokens = {
|
|
71
|
+
input: num(t.input ?? t.inputTokens),
|
|
72
|
+
output: num(t.output ?? t.outputTokens),
|
|
73
|
+
cacheRead: num(t.cacheRead ?? t.cacheReadTokens),
|
|
74
|
+
total: num(t.total ?? t.totalTokens),
|
|
75
|
+
billable: num(t.billable ?? t.billableTokens),
|
|
76
|
+
}
|
|
77
|
+
let costUsd = typeof raw.costUsd === 'number' && Number.isFinite(raw.costUsd) ? raw.costUsd : null
|
|
78
|
+
let costEstimated = false
|
|
79
|
+
if (costUsd == null) {
|
|
80
|
+
const est = estimateCostUsd({ model: raw.model, inputTokens: tokens.input, outputTokens: tokens.output })
|
|
81
|
+
if (est != null) { costUsd = est; costEstimated = true }
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
ts: num(raw.ts),
|
|
85
|
+
site: raw.site || null,
|
|
86
|
+
path: raw.path || null,
|
|
87
|
+
sessionId: raw.sessionId || null,
|
|
88
|
+
model: raw.model || null,
|
|
89
|
+
costUsd,
|
|
90
|
+
costEstimated,
|
|
91
|
+
tokens,
|
|
92
|
+
promptSummary: raw.promptSummary || '',
|
|
93
|
+
ok: raw.ok !== false,
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Append one cost record as an NDJSON line. Creates the parent dir if needed.
|
|
99
|
+
* Returns the normalized record. Throws on fs failure — callers wrap this in a
|
|
100
|
+
* best-effort try/catch so cost logging never breaks a run.
|
|
101
|
+
*/
|
|
102
|
+
function appendCost(raw, logPath = defaultLogPath()) {
|
|
103
|
+
const entry = normalizeEntry(raw)
|
|
104
|
+
fs.mkdirSync(path.dirname(logPath), { recursive: true })
|
|
105
|
+
fs.appendFileSync(logPath, JSON.stringify(entry) + '\n')
|
|
106
|
+
return entry
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Read all records, tolerating partial/corrupt trailing lines. */
|
|
110
|
+
function readAll(logPath = defaultLogPath()) {
|
|
111
|
+
let text
|
|
112
|
+
try {
|
|
113
|
+
text = fs.readFileSync(logPath, 'utf8')
|
|
114
|
+
} catch (e) {
|
|
115
|
+
if (e.code === 'ENOENT') return []
|
|
116
|
+
throw e
|
|
117
|
+
}
|
|
118
|
+
const out = []
|
|
119
|
+
for (const line of text.split('\n')) {
|
|
120
|
+
const s = line.trim()
|
|
121
|
+
if (!s) continue
|
|
122
|
+
try { out.push(JSON.parse(s)) } catch { /* skip a torn line */ }
|
|
123
|
+
}
|
|
124
|
+
return out
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Read records filtered to the [from, to] ms window (inclusive). */
|
|
128
|
+
function readCosts({ from, to, site } = {}, logPath = defaultLogPath()) {
|
|
129
|
+
return readAll(logPath).filter((r) => {
|
|
130
|
+
if (typeof from === 'number' && num(r.ts) < from) return false
|
|
131
|
+
if (typeof to === 'number' && num(r.ts) > to) return false
|
|
132
|
+
if (site && r.site !== site) return false
|
|
133
|
+
return true
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** UTC YYYY-MM-DD for a ms timestamp. */
|
|
138
|
+
function dayKey(ts) {
|
|
139
|
+
return new Date(num(ts)).toISOString().slice(0, 10)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function emptyBucket() {
|
|
143
|
+
return { count: 0, costUsd: 0, billedUsd: 0, inputTokens: 0, outputTokens: 0, totalTokens: 0 }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function addTo(bucket, r, pct) {
|
|
147
|
+
bucket.count += 1
|
|
148
|
+
bucket.costUsd += num(r.costUsd)
|
|
149
|
+
bucket.billedUsd += num(applyMarkup(num(r.costUsd), pct))
|
|
150
|
+
const t = r.tokens || {}
|
|
151
|
+
bucket.inputTokens += num(t.input)
|
|
152
|
+
bucket.outputTokens += num(t.output)
|
|
153
|
+
bucket.totalTokens += num(t.total)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Sum a date window into a rollup the board can invoice from. Groups by UTC day,
|
|
158
|
+
* by session, and by model. `costUsd` is the RAW API cost (CLI's total_cost_usd);
|
|
159
|
+
* `billedUsd` is that cost with the client-facing markup applied (HAP-1123 board
|
|
160
|
+
* request). The rollup carries both plus the `markupPct` used, so the board sees
|
|
161
|
+
* raw and client-billed side by side.
|
|
162
|
+
*
|
|
163
|
+
* @param {{from?,to?,site?,markupPct?}} [opts] markupPct defaults to config
|
|
164
|
+
* @returns {{
|
|
165
|
+
* from, to, markupPct, count, totalCostUsd, totalBilledUsd, totalTokens,
|
|
166
|
+
* byDay: Object, bySession: Object, byModel: Object
|
|
167
|
+
* }}
|
|
168
|
+
*/
|
|
169
|
+
function rollup({ from, to, site, markupPct } = {}, logPath = defaultLogPath()) {
|
|
170
|
+
const pct = num(markupPct != null ? markupPct : config.markupPct)
|
|
171
|
+
const rows = readCosts({ from, to, site }, logPath)
|
|
172
|
+
const byDay = {}
|
|
173
|
+
const bySession = {}
|
|
174
|
+
const byModel = {}
|
|
175
|
+
let totalCostUsd = 0
|
|
176
|
+
let totalTokens = 0
|
|
177
|
+
for (const r of rows) {
|
|
178
|
+
totalCostUsd += num(r.costUsd)
|
|
179
|
+
totalTokens += num(r.tokens?.total)
|
|
180
|
+
const d = dayKey(r.ts)
|
|
181
|
+
;(byDay[d] ||= emptyBucket()) && addTo(byDay[d], r, pct)
|
|
182
|
+
const sid = r.sessionId || 'unknown'
|
|
183
|
+
;(bySession[sid] ||= emptyBucket()) && addTo(bySession[sid], r, pct)
|
|
184
|
+
const m = r.model || 'unknown'
|
|
185
|
+
;(byModel[m] ||= emptyBucket()) && addTo(byModel[m], r, pct)
|
|
186
|
+
}
|
|
187
|
+
const round = (n) => Math.round(n * 1e6) / 1e6
|
|
188
|
+
totalCostUsd = round(totalCostUsd)
|
|
189
|
+
const totalBilledUsd = round(num(applyMarkup(totalCostUsd, pct)))
|
|
190
|
+
for (const b of [...Object.values(byDay), ...Object.values(bySession), ...Object.values(byModel)]) {
|
|
191
|
+
b.costUsd = round(b.costUsd)
|
|
192
|
+
b.billedUsd = round(b.billedUsd)
|
|
193
|
+
}
|
|
194
|
+
return {
|
|
195
|
+
from: typeof from === 'number' ? from : null,
|
|
196
|
+
to: typeof to === 'number' ? to : null,
|
|
197
|
+
site: site || null,
|
|
198
|
+
markupPct: pct,
|
|
199
|
+
count: rows.length,
|
|
200
|
+
totalCostUsd,
|
|
201
|
+
totalBilledUsd,
|
|
202
|
+
totalTokens,
|
|
203
|
+
byDay,
|
|
204
|
+
bySession,
|
|
205
|
+
byModel,
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export { appendCost, readCosts, readAll, rollup, normalizeEntry, estimateCostUsd, applyMarkup, defaultLogPath }
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// ownerToken.js
|
|
2
|
+
//
|
|
3
|
+
// Real per-owner auth for the orchestrator (HAP-1109). Replaces the old
|
|
4
|
+
// optional shared-secret gate, which failed OPEN when unset and could not tell
|
|
5
|
+
// one caller from another.
|
|
6
|
+
//
|
|
7
|
+
// A vibe owner token is a compact, dependency-free, JWT-like string:
|
|
8
|
+
//
|
|
9
|
+
// base64url(JSON(payload)) + "." + base64url(HMAC_SHA256(part1, secret))
|
|
10
|
+
//
|
|
11
|
+
// payload = { v:1, sub:<memberId>, roles:[...], site:<slug>, iat, exp } (epoch seconds)
|
|
12
|
+
//
|
|
13
|
+
// The token is MINTED server-side by Mango from an authenticated admin session
|
|
14
|
+
// (mango/endpoints -> mango/helpers/vibeToken.js, same format) and VERIFIED here
|
|
15
|
+
// with a secret shared only between Mango and the orchestrator
|
|
16
|
+
// (VIBE_ORCH_TOKEN_SECRET). The signature proves the claims were issued by Mango;
|
|
17
|
+
// a non-owner cannot forge an admin role without the secret, and the client-side
|
|
18
|
+
// role flag is no longer trusted. The Claude OAuth token is never involved.
|
|
19
|
+
|
|
20
|
+
import crypto from 'node:crypto'
|
|
21
|
+
|
|
22
|
+
const TOKEN_VERSION = 1
|
|
23
|
+
|
|
24
|
+
function b64urlEncode(buf) {
|
|
25
|
+
return Buffer.from(buf).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function b64urlDecode(str) {
|
|
29
|
+
const pad = str.length % 4 === 0 ? '' : '='.repeat(4 - (str.length % 4))
|
|
30
|
+
return Buffer.from(str.replace(/-/g, '+').replace(/_/g, '/') + pad, 'base64')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function sign(part, secret) {
|
|
34
|
+
return b64urlEncode(crypto.createHmac('sha256', secret).update(part).digest())
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Constant-time string compare that never short-circuits on length. */
|
|
38
|
+
function safeEqual(a, b) {
|
|
39
|
+
const ab = Buffer.from(String(a))
|
|
40
|
+
const bb = Buffer.from(String(b))
|
|
41
|
+
if (ab.length !== bb.length) {
|
|
42
|
+
// Still run a comparison to keep timing flat, then fail.
|
|
43
|
+
crypto.timingSafeEqual(ab, ab)
|
|
44
|
+
return false
|
|
45
|
+
}
|
|
46
|
+
return crypto.timingSafeEqual(ab, bb)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Mint an owner token. Used by tests/tooling; the production minter lives in
|
|
51
|
+
* Mango (mango/helpers/vibeToken.js) and MUST keep this exact format.
|
|
52
|
+
*
|
|
53
|
+
* @param {Object} opts
|
|
54
|
+
* @param {string} opts.secret shared HMAC secret (required)
|
|
55
|
+
* @param {string} opts.sub member id
|
|
56
|
+
* @param {string[]} [opts.roles] member roles
|
|
57
|
+
* @param {string} [opts.site] site slug the token is scoped to
|
|
58
|
+
* @param {number} [opts.ttlSeconds=600] lifetime
|
|
59
|
+
* @param {number} [opts.now] epoch seconds (testing override)
|
|
60
|
+
*/
|
|
61
|
+
function mintOwnerToken({ secret, sub, roles = [], site = '', ttlSeconds = 600, now } = {}) {
|
|
62
|
+
if (!secret) throw new Error('mintOwnerToken: secret required')
|
|
63
|
+
if (!sub) throw new Error('mintOwnerToken: sub (member id) required')
|
|
64
|
+
const iat = Number.isFinite(now) ? Math.floor(now) : Math.floor(Date.now() / 1000)
|
|
65
|
+
const payload = { v: TOKEN_VERSION, sub: String(sub), roles: Array.isArray(roles) ? roles : [], site, iat, exp: iat + ttlSeconds }
|
|
66
|
+
const part = b64urlEncode(JSON.stringify(payload))
|
|
67
|
+
return `${part}.${sign(part, secret)}`
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Verify an owner token. Returns { ok, payload } or { ok:false, reason, status }.
|
|
72
|
+
* status is the HTTP status the caller should surface (401 vs 403).
|
|
73
|
+
*
|
|
74
|
+
* @param {string} token
|
|
75
|
+
* @param {Object} opts
|
|
76
|
+
* @param {string} opts.secret shared HMAC secret
|
|
77
|
+
* @param {string} [opts.site] if set, token.site must match
|
|
78
|
+
* @param {string[]} [opts.ownerRoles] roles that count as owner (default ['admin'])
|
|
79
|
+
* @param {number} [opts.now] epoch seconds (testing override)
|
|
80
|
+
*/
|
|
81
|
+
function verifyOwnerToken(token, { secret, site, ownerRoles = ['admin'], now } = {}) {
|
|
82
|
+
if (!secret) return { ok: false, reason: 'owner auth not configured', status: 503 }
|
|
83
|
+
if (!token || typeof token !== 'string') return { ok: false, reason: 'missing token', status: 401 }
|
|
84
|
+
|
|
85
|
+
const dot = token.indexOf('.')
|
|
86
|
+
if (dot <= 0 || dot === token.length - 1) return { ok: false, reason: 'malformed token', status: 401 }
|
|
87
|
+
const part = token.slice(0, dot)
|
|
88
|
+
const sig = token.slice(dot + 1)
|
|
89
|
+
|
|
90
|
+
if (!safeEqual(sig, sign(part, secret))) return { ok: false, reason: 'bad signature', status: 401 }
|
|
91
|
+
|
|
92
|
+
let payload
|
|
93
|
+
try {
|
|
94
|
+
payload = JSON.parse(b64urlDecode(part).toString('utf8'))
|
|
95
|
+
} catch {
|
|
96
|
+
return { ok: false, reason: 'unparseable payload', status: 401 }
|
|
97
|
+
}
|
|
98
|
+
if (!payload || payload.v !== TOKEN_VERSION) return { ok: false, reason: 'unsupported token version', status: 401 }
|
|
99
|
+
|
|
100
|
+
const nowSec = Number.isFinite(now) ? Math.floor(now) : Math.floor(Date.now() / 1000)
|
|
101
|
+
if (typeof payload.exp !== 'number' || nowSec >= payload.exp) return { ok: false, reason: 'token expired', status: 401 }
|
|
102
|
+
if (typeof payload.iat === 'number' && nowSec + 60 < payload.iat) return { ok: false, reason: 'token not yet valid', status: 401 }
|
|
103
|
+
|
|
104
|
+
if (site && payload.site && payload.site !== site) return { ok: false, reason: 'token scoped to a different site', status: 403 }
|
|
105
|
+
|
|
106
|
+
const roles = Array.isArray(payload.roles) ? payload.roles : []
|
|
107
|
+
const isOwner = ownerRoles.some((r) => roles.includes(r))
|
|
108
|
+
if (!isOwner) return { ok: false, reason: 'not an owner', status: 403 }
|
|
109
|
+
|
|
110
|
+
return { ok: true, payload }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export { mintOwnerToken, verifyOwnerToken, TOKEN_VERSION }
|