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.
Files changed (34) hide show
  1. package/cli.js +57 -0
  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 +35 -1
  11. package/default/vite.config.js +46 -0
  12. package/lib/devProxyGateway.js +173 -0
  13. package/lib/staging-gateway.js +23 -1
  14. package/lib/vibe-orchestrator/README.md +76 -0
  15. package/lib/vibe-orchestrator/scripts/fake-claude.mjs +35 -0
  16. package/lib/vibe-orchestrator/scripts/path-guard-hook.mjs +70 -0
  17. package/lib/vibe-orchestrator/scripts/vibe-recover.sh +63 -0
  18. package/lib/vibe-orchestrator/server.js +344 -0
  19. package/lib/vibe-orchestrator/src/attachments.js +98 -0
  20. package/lib/vibe-orchestrator/src/claudeRunner.js +233 -0
  21. package/lib/vibe-orchestrator/src/config.js +227 -0
  22. package/lib/vibe-orchestrator/src/costMirror.js +64 -0
  23. package/lib/vibe-orchestrator/src/costStore.js +209 -0
  24. package/lib/vibe-orchestrator/src/ownerToken.js +113 -0
  25. package/lib/vibe-orchestrator/src/pathGuard.js +114 -0
  26. package/lib/vibe-orchestrator/src/preamble.js +139 -0
  27. package/lib/vibe-orchestrator/src/publisher.js +376 -0
  28. package/lib/vibe-orchestrator/src/recovery.js +199 -0
  29. package/lib/vibe-orchestrator/src/screenshot.js +38 -0
  30. package/lib/vibe-orchestrator/src/sessionManager.js +291 -0
  31. package/lib/vibe-orchestrator/src/streamParser.js +188 -0
  32. package/lib/vibe-orchestrator/src/tokenMeter.js +64 -0
  33. package/package.json +1 -1
  34. 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 }