vibeoscore 1.0.2
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/.env.example +5 -0
- package/README.md +29 -0
- package/client.js +257 -0
- package/client.ts +334 -0
- package/dashboard/dist/assets/index-BnPt1Fii.js +1 -0
- package/dashboard/dist/assets/index-CfH00tOL.css +1 -0
- package/dashboard/dist/index.html +3 -0
- package/lib/blackbox-rf.js +1099 -0
- package/lib/blackbox.js +137 -0
- package/lib/compression.js +119 -0
- package/lib/db.js +106 -0
- package/lib/db.ts +113 -0
- package/lib/delegation.js +137 -0
- package/lib/meta-controller.js +418 -0
- package/lib/meta-controller.mjs +499 -0
- package/lib/patterns.js +150 -0
- package/lib/resolution-tracker.js +486 -0
- package/lib/stress.js +84 -0
- package/lib/tdd.js +218 -0
- package/lib/tier-routing.js +48 -0
- package/mcp-server.js +370 -0
- package/mcp-server.ts +364 -0
- package/middleware/auth.js +75 -0
- package/middleware/auth.ts +87 -0
- package/middleware/usage-logging.js +29 -0
- package/middleware/usage-logging.ts +41 -0
- package/nginx-vibetheog-api.conf +64 -0
- package/package.json +66 -0
- package/routes/admin.js +93 -0
- package/routes/admin.ts +107 -0
- package/routes/blackbox.js +463 -0
- package/routes/compression.js +12 -0
- package/routes/delegation.js +30 -0
- package/routes/patterns.js +53 -0
- package/routes/pricing.js +62 -0
- package/routes/stress.js +30 -0
- package/routes/tdd.js +68 -0
- package/routes/tier-routing.js +31 -0
- package/scripts/dashboard-server.mjs +246 -0
- package/scripts/deploy-zero-downtime.sh +77 -0
- package/scripts/deploy.sh +68 -0
- package/scripts/release.mjs +30 -0
- package/scripts/seed-master-token.js +29 -0
- package/scripts/start-all.mjs +34 -0
- package/server.js +88 -0
- package/vibeos-api.service +19 -0
package/mcp-server.ts
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// SPDX-FileCopyrightText: 2026 vibeOS <https://github.com/DrunkkToys/vibeOS>
|
|
3
|
+
|
|
4
|
+
import http from "node:http"
|
|
5
|
+
import { IncomingMessage, ServerResponse } from "node:http"
|
|
6
|
+
import { parse as parseUrl } from "node:url"
|
|
7
|
+
import { URLSearchParams } from "node:url"
|
|
8
|
+
import { createReadStream, existsSync, statSync } from "node:fs"
|
|
9
|
+
import { extname, join, dirname } from "node:path"
|
|
10
|
+
import { fileURLToPath } from "node:url"
|
|
11
|
+
|
|
12
|
+
const MIME_MAP: Record<string, string> = {
|
|
13
|
+
".html": "text/html; charset=utf-8",
|
|
14
|
+
".js": "application/javascript; charset=utf-8",
|
|
15
|
+
".css": "text/css; charset=utf-8",
|
|
16
|
+
".json": "application/json; charset=utf-8",
|
|
17
|
+
".png": "image/png",
|
|
18
|
+
".ico": "image/x-icon",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type Deps = {
|
|
22
|
+
getState: () => unknown
|
|
23
|
+
getSavings: () => unknown
|
|
24
|
+
getTodos: () => unknown
|
|
25
|
+
getSessionMetrics: (sessionId: string) => unknown
|
|
26
|
+
getCurrentSessionId: () => string
|
|
27
|
+
listReports: (params: { type?: string; project?: string; hours?: number; fingerprint?: string }) => unknown
|
|
28
|
+
readReport: (id: string) => unknown
|
|
29
|
+
runDiagnose: () => unknown
|
|
30
|
+
runProject: () => unknown
|
|
31
|
+
runTrinity: (action: string, opts: { slot?: string; level?: string }) => Promise<unknown>
|
|
32
|
+
runResearchAudit: (hours: number) => unknown
|
|
33
|
+
saveReport: (params: { type: string; summary: string; findings: unknown[]; metrics: Record<string, unknown>; narrative: string; tags: unknown[] }) => string | null
|
|
34
|
+
generateSessionCheckout: () => unknown
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type McpServer = {
|
|
38
|
+
start: (port: number) => Promise<http.Server>
|
|
39
|
+
close: () => Promise<void>
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function json(res: ServerResponse, statusCode: number, data: unknown): void {
|
|
43
|
+
res.statusCode = statusCode
|
|
44
|
+
res.setHeader("Content-Type", "application/json")
|
|
45
|
+
res.end(JSON.stringify(data))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parseBody(req: IncomingMessage): Promise<Record<string, unknown>> {
|
|
49
|
+
return new Promise((resolve, reject) => {
|
|
50
|
+
let raw = ""
|
|
51
|
+
req.on("data", (chunk: Buffer) => {
|
|
52
|
+
raw += String(chunk || "")
|
|
53
|
+
if (raw.length > 1024 * 1024) {
|
|
54
|
+
reject(new Error("payload too large"))
|
|
55
|
+
}
|
|
56
|
+
})
|
|
57
|
+
req.on("end", () => {
|
|
58
|
+
if (!raw.trim()) {
|
|
59
|
+
resolve({})
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
resolve(JSON.parse(raw))
|
|
64
|
+
} catch {
|
|
65
|
+
reject(new Error("invalid request"))
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
req.on("error", reject)
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const _MCP_FILENAME = fileURLToPath(import.meta.url)
|
|
73
|
+
const _MCP_DIR = dirname(_MCP_FILENAME)
|
|
74
|
+
|
|
75
|
+
function resolveDashboardDir(): string {
|
|
76
|
+
const c = [
|
|
77
|
+
join(_MCP_DIR, "dashboard", "dist"),
|
|
78
|
+
]
|
|
79
|
+
for (const p of c) { if (existsSync(join(p, "index.html"))) return p }
|
|
80
|
+
return c[0]
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const DASHBOARD_DIR = resolveDashboardDir()
|
|
84
|
+
|
|
85
|
+
const BACKEND_HEALTH_URL = process.env.VIBEOS_BACKEND_HEALTH_URL || "https://api.vibetheog.com/health"
|
|
86
|
+
const BACKEND_HEALTH_TTL_MS = 5_000
|
|
87
|
+
|
|
88
|
+
let backendHealth: { ok: boolean | null; checkedAt: number } = { ok: null, checkedAt: 0 }
|
|
89
|
+
|
|
90
|
+
async function probeBackendHealth(force = false): Promise<boolean | null> {
|
|
91
|
+
const now = Date.now()
|
|
92
|
+
if (!force && backendHealth.ok !== null && (now - backendHealth.checkedAt) < BACKEND_HEALTH_TTL_MS) {
|
|
93
|
+
return backendHealth.ok
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
const ctl = new AbortController()
|
|
97
|
+
const timer = setTimeout(() => ctl.abort(), 1500)
|
|
98
|
+
const res = await fetch(BACKEND_HEALTH_URL, { signal: ctl.signal })
|
|
99
|
+
clearTimeout(timer)
|
|
100
|
+
backendHealth = { ok: res.ok, checkedAt: now }
|
|
101
|
+
return res.ok
|
|
102
|
+
} catch {
|
|
103
|
+
backendHealth = { ok: false, checkedAt: now }
|
|
104
|
+
return false
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function sendFile(res: ServerResponse, fp: string): void {
|
|
109
|
+
if (!existsSync(fp)) { res.statusCode = 404; res.setHeader("Content-Type", "text/plain; charset=utf-8"); res.end("not found"); return }
|
|
110
|
+
const ext = extname(fp).toLowerCase(); const mime = MIME_MAP[ext] || "application/octet-stream"; const st = statSync(fp)
|
|
111
|
+
res.statusCode = 200; res.setHeader("Content-Type", mime); res.setHeader("Content-Length", st.size); res.setHeader("Cache-Control", "no-cache")
|
|
112
|
+
const s = createReadStream(fp); s.pipe(res); s.on("error", () => { res.statusCode = 500; res.end() })
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function serveDashboard(res: ServerResponse, p: string): void {
|
|
116
|
+
const idx = join(DASHBOARD_DIR, "index.html"); let fp = join(DASHBOARD_DIR, p === "/" ? "index.html" : p)
|
|
117
|
+
if (existsSync(fp) && statSync(fp).isFile()) { sendFile(res, fp); return }
|
|
118
|
+
if (existsSync(idx)) { sendFile(res, idx); return }
|
|
119
|
+
res.statusCode = 404; res.setHeader("Content-Type", "text/plain; charset=utf-8"); res.end("not found")
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function createMcpServer(deps: Deps): McpServer {
|
|
123
|
+
let server: http.Server | null = null
|
|
124
|
+
let startPromise: Promise<http.Server> | null = null
|
|
125
|
+
let closePromise: Promise<void> | null = null
|
|
126
|
+
|
|
127
|
+
const handler = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
128
|
+
try {
|
|
129
|
+
const method = (req.method || "GET").toUpperCase()
|
|
130
|
+
const parsed = parseUrl(req.url || "/", true)
|
|
131
|
+
const path = parsed.pathname || "/"
|
|
132
|
+
|
|
133
|
+
if (method === "GET" && path === "/status") {
|
|
134
|
+
const state = deps.getState() as Record<string, unknown>
|
|
135
|
+
const ok = await probeBackendHealth()
|
|
136
|
+
json(res, 200, { ...state, backend_connected: ok === true, backend_health_url: BACKEND_HEALTH_URL })
|
|
137
|
+
return
|
|
138
|
+
}
|
|
139
|
+
if (method === "GET" && path === "/savings") {
|
|
140
|
+
json(res, 200, deps.getSavings())
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
if (method === "GET" && path === "/todos") {
|
|
144
|
+
json(res, 200, deps.getTodos())
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
if (method === "GET" && path === "/sessions") {
|
|
148
|
+
const state = deps.getState() as Record<string, unknown> | null
|
|
149
|
+
const sessionsMap = state?.sessions_raw as Record<string, Record<string, unknown>> | undefined || {}
|
|
150
|
+
const sessions = Object.entries(sessionsMap).map(([id, ses]) => ({
|
|
151
|
+
id,
|
|
152
|
+
started: ses?.started || null,
|
|
153
|
+
cost_usd: Number(ses?.cost_usd ?? 0) || 0,
|
|
154
|
+
delegation_savings_usd: Array.isArray(ses?.warns)
|
|
155
|
+
? (ses.warns as Array<Record<string, unknown>>).reduce((sum, w) => sum + (Number(w?.est_savings_usd ?? 0) || 0), 0)
|
|
156
|
+
: ses?.total_savings_usd || 0,
|
|
157
|
+
cache_savings_usd: Number(ses?.cache_savings_usd ?? 0) || 0,
|
|
158
|
+
warns_count: Array.isArray(ses?.warns) ? ses.warns.length : 0,
|
|
159
|
+
}))
|
|
160
|
+
json(res, 200, { sessions, total_sessions: sessions.length })
|
|
161
|
+
return
|
|
162
|
+
}
|
|
163
|
+
if (method === "GET" && path === "/sessions/current") {
|
|
164
|
+
json(res, 200, deps.getSessionMetrics(deps.getCurrentSessionId()))
|
|
165
|
+
return
|
|
166
|
+
}
|
|
167
|
+
if (method === "GET" && path === "/reports") {
|
|
168
|
+
try {
|
|
169
|
+
const query = parsed.query as Record<string, string | undefined>
|
|
170
|
+
const type = typeof query.type === "string" ? query.type : undefined
|
|
171
|
+
const project = typeof query.project === "string" ? query.project : undefined
|
|
172
|
+
const hoursRaw = query.hours
|
|
173
|
+
const hours = hoursRaw != null ? Number(hoursRaw) : undefined
|
|
174
|
+
const fingerprint = typeof query.fingerprint === "string" ? query.fingerprint : undefined
|
|
175
|
+
const reports = deps.listReports({ type, project, hours: Number.isFinite(hours as number) ? hours : undefined, fingerprint })
|
|
176
|
+
json(res, 200, reports)
|
|
177
|
+
} catch (err: unknown) {
|
|
178
|
+
const error = err as { status?: number }
|
|
179
|
+
if (error?.status === 404) {
|
|
180
|
+
json(res, 404, { error: "not found", status: 404 })
|
|
181
|
+
return
|
|
182
|
+
}
|
|
183
|
+
throw err
|
|
184
|
+
}
|
|
185
|
+
return
|
|
186
|
+
}
|
|
187
|
+
if (method === "GET" && path.startsWith("/reports/")) {
|
|
188
|
+
const id = decodeURIComponent(path.replace(/^\/reports\//, "")).trim()
|
|
189
|
+
const report = deps.readReport(id)
|
|
190
|
+
if (!report) {
|
|
191
|
+
json(res, 404, { error: "not found", status: 404 })
|
|
192
|
+
return
|
|
193
|
+
}
|
|
194
|
+
json(res, 200, report)
|
|
195
|
+
return
|
|
196
|
+
}
|
|
197
|
+
if (method === "GET" && path === "/diagnose") {
|
|
198
|
+
json(res, 200, deps.runDiagnose())
|
|
199
|
+
return
|
|
200
|
+
}
|
|
201
|
+
if (method === "GET" && path === "/project") {
|
|
202
|
+
json(res, 200, deps.runProject())
|
|
203
|
+
return
|
|
204
|
+
}
|
|
205
|
+
if (method === "POST" && path === "/trinity") {
|
|
206
|
+
let body: Record<string, unknown>
|
|
207
|
+
try {
|
|
208
|
+
body = await parseBody(req)
|
|
209
|
+
} catch {
|
|
210
|
+
json(res, 400, { error: "invalid request", status: 400 })
|
|
211
|
+
return
|
|
212
|
+
}
|
|
213
|
+
const action = body?.action as string | undefined
|
|
214
|
+
const slot = body?.slot as string | undefined
|
|
215
|
+
const level = body?.level as string | undefined
|
|
216
|
+
if (!action || typeof action !== "string") {
|
|
217
|
+
json(res, 400, { error: "invalid request", status: 400 })
|
|
218
|
+
return
|
|
219
|
+
}
|
|
220
|
+
const result = await deps.runTrinity(action, { slot, level })
|
|
221
|
+
const txt = typeof result === "string" ? result : JSON.stringify(result)
|
|
222
|
+
const ok = !(txt.startsWith("❌") || txt.toLowerCase().includes("unknown action"))
|
|
223
|
+
json(res, ok ? 200 : 400, ok ? { ok: true, result } : { ok: false, error: txt })
|
|
224
|
+
return
|
|
225
|
+
}
|
|
226
|
+
if (method === "POST" && path === "/research-audit") {
|
|
227
|
+
let body: Record<string, unknown>
|
|
228
|
+
try {
|
|
229
|
+
body = await parseBody(req)
|
|
230
|
+
} catch {
|
|
231
|
+
json(res, 400, { error: "invalid request", status: 400 })
|
|
232
|
+
return
|
|
233
|
+
}
|
|
234
|
+
const hours = Number(body?.hours ?? 24)
|
|
235
|
+
const report = deps.runResearchAudit(Number.isFinite(hours) ? hours : 24)
|
|
236
|
+
json(res, 200, report)
|
|
237
|
+
return
|
|
238
|
+
}
|
|
239
|
+
if (method === "POST" && path === "/reports") {
|
|
240
|
+
let body: Record<string, unknown>
|
|
241
|
+
try {
|
|
242
|
+
body = await parseBody(req)
|
|
243
|
+
} catch {
|
|
244
|
+
json(res, 400, { error: "invalid request", status: 400 })
|
|
245
|
+
return
|
|
246
|
+
}
|
|
247
|
+
if (!body || typeof body !== "object") {
|
|
248
|
+
json(res, 400, { error: "invalid request", status: 400 })
|
|
249
|
+
return
|
|
250
|
+
}
|
|
251
|
+
const id = deps.saveReport({
|
|
252
|
+
type: "manual",
|
|
253
|
+
summary: (body.summary as string) || "",
|
|
254
|
+
findings: (body.findings as unknown[]) || [],
|
|
255
|
+
metrics: (body.metrics as Record<string, unknown>) || {},
|
|
256
|
+
narrative: (body.narrative as string) || "",
|
|
257
|
+
tags: Array.isArray(body.tags) ? body.tags : [],
|
|
258
|
+
})
|
|
259
|
+
if (!id) {
|
|
260
|
+
json(res, 500, { error: "failed to save report", status: 500 })
|
|
261
|
+
return
|
|
262
|
+
}
|
|
263
|
+
json(res, 200, { ok: true, id })
|
|
264
|
+
return
|
|
265
|
+
}
|
|
266
|
+
if (method === "POST" && path === "/sessions/checkout") {
|
|
267
|
+
const result = deps.generateSessionCheckout()
|
|
268
|
+
json(res, 200, result)
|
|
269
|
+
return
|
|
270
|
+
}
|
|
271
|
+
if (method === "GET" && path === "/events") {
|
|
272
|
+
res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", "Access-Control-Allow-Origin": "*" })
|
|
273
|
+
const push = async () => { const state = deps.getState() as Record<string, unknown>; const ok = await probeBackendHealth(); res.write(`data: ${JSON.stringify({ status: { ...state, backend_connected: ok === true, backend_health_url: BACKEND_HEALTH_URL }, savings: deps.getSavings(), todos: deps.getTodos() })}\n\n`) }; push()
|
|
274
|
+
const iv = setInterval(push, 1500)
|
|
275
|
+
req.on("close", () => { clearInterval(iv) })
|
|
276
|
+
return
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (existsSync(join(DASHBOARD_DIR, "index.html"))) { serveDashboard(res, path); return }
|
|
280
|
+
json(res, 404, { error: "not found", status: 404 })
|
|
281
|
+
} catch (err: unknown) {
|
|
282
|
+
const error = err as { message?: string }
|
|
283
|
+
json(res, 500, { error: error?.message || "internal error", status: 500 })
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return {
|
|
288
|
+
async start(port: number): Promise<http.Server> {
|
|
289
|
+
if (closePromise) await closePromise
|
|
290
|
+
if (server) return server
|
|
291
|
+
if (startPromise) return startPromise
|
|
292
|
+
|
|
293
|
+
const listen = (listenPort: number): Promise<http.Server> => new Promise((resolve, reject) => {
|
|
294
|
+
const nextServer = http.createServer((req: IncomingMessage, res: ServerResponse) => {
|
|
295
|
+
void handler(req, res)
|
|
296
|
+
})
|
|
297
|
+
const onListening = () => resolve(nextServer)
|
|
298
|
+
const onError = (err: Error) => {
|
|
299
|
+
try { nextServer.close() } catch { }
|
|
300
|
+
reject(err)
|
|
301
|
+
}
|
|
302
|
+
nextServer.once("listening", onListening)
|
|
303
|
+
nextServer.once("error", onError)
|
|
304
|
+
try {
|
|
305
|
+
nextServer.listen(listenPort, "127.0.0.1")
|
|
306
|
+
} catch (err: unknown) {
|
|
307
|
+
onError(err as Error)
|
|
308
|
+
}
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
startPromise = (async () => {
|
|
312
|
+
try {
|
|
313
|
+
server = await listen(port)
|
|
314
|
+
return server
|
|
315
|
+
} catch (err: unknown) {
|
|
316
|
+
const error = err as { code?: string; message: string }
|
|
317
|
+
if (error?.code !== "EADDRINUSE" || port === 0) {
|
|
318
|
+
startPromise = null
|
|
319
|
+
server = null
|
|
320
|
+
console.error(`[vibeOS] MCP server bind failed: ${error.message}`)
|
|
321
|
+
throw err
|
|
322
|
+
}
|
|
323
|
+
try {
|
|
324
|
+
const fallback = await listen(0)
|
|
325
|
+
server = fallback
|
|
326
|
+
const bound = fallback.address()
|
|
327
|
+
const actualPort = typeof bound === "object" && bound ? (bound as { port: number }).port : 0
|
|
328
|
+
console.error(`[vibeOS] MCP server port ${port} busy; fell back to ${actualPort}`)
|
|
329
|
+
return fallback
|
|
330
|
+
} catch (fallbackErr: unknown) {
|
|
331
|
+
const fbError = fallbackErr as { message: string }
|
|
332
|
+
startPromise = null
|
|
333
|
+
server = null
|
|
334
|
+
console.error(`[vibeOS] MCP server bind failed: ${fbError.message}`)
|
|
335
|
+
throw fallbackErr
|
|
336
|
+
}
|
|
337
|
+
} finally {
|
|
338
|
+
startPromise = null
|
|
339
|
+
}
|
|
340
|
+
})()
|
|
341
|
+
return startPromise
|
|
342
|
+
},
|
|
343
|
+
|
|
344
|
+
close(): Promise<void> {
|
|
345
|
+
if (!server) return closePromise || Promise.resolve()
|
|
346
|
+
if (closePromise) return closePromise
|
|
347
|
+
const current = server
|
|
348
|
+
closePromise = new Promise((resolve) => {
|
|
349
|
+
try {
|
|
350
|
+
current.close(() => {
|
|
351
|
+
if (server === current) server = null
|
|
352
|
+
closePromise = null
|
|
353
|
+
resolve()
|
|
354
|
+
})
|
|
355
|
+
} catch {
|
|
356
|
+
if (server === current) server = null
|
|
357
|
+
closePromise = null
|
|
358
|
+
resolve()
|
|
359
|
+
}
|
|
360
|
+
})
|
|
361
|
+
return closePromise
|
|
362
|
+
},
|
|
363
|
+
}
|
|
364
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { getDb } from "../lib/db.js";
|
|
3
|
+
const MASTER_KEY = process.env.VIBEOS_API_MASTER_KEY;
|
|
4
|
+
export function authMiddleware(fastify) {
|
|
5
|
+
fastify.addHook("onRequest", async (request, reply) => {
|
|
6
|
+
if (request.url.startsWith("/health") || request.url.startsWith("/favicon")) {
|
|
7
|
+
return;
|
|
8
|
+
}
|
|
9
|
+
if (request.url.startsWith("/admin/")) {
|
|
10
|
+
const authHeader = request.headers["authorization"];
|
|
11
|
+
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
|
12
|
+
return reply.code(401).send({ error: "unauthorized", message: "Missing or invalid Authorization header" });
|
|
13
|
+
}
|
|
14
|
+
const providedKey = authHeader.slice(7);
|
|
15
|
+
const keyBuffer = Buffer.from(providedKey);
|
|
16
|
+
const expectedBuffer = Buffer.from(MASTER_KEY);
|
|
17
|
+
if (keyBuffer.length !== expectedBuffer.length || !crypto.timingSafeEqual(keyBuffer, expectedBuffer)) {
|
|
18
|
+
return reply.code(403).send({ error: "forbidden", message: "Invalid master key" });
|
|
19
|
+
}
|
|
20
|
+
request.adminAuth = true;
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
if (request.url.startsWith("/api/v1/")) {
|
|
24
|
+
const authHeader = request.headers["authorization"];
|
|
25
|
+
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
|
26
|
+
return reply.code(401).send({ error: "unauthorized", message: "Missing or invalid Authorization header" });
|
|
27
|
+
}
|
|
28
|
+
const providedToken = authHeader.slice(7);
|
|
29
|
+
const db = getDb();
|
|
30
|
+
const tokenRow = db.prepare(`
|
|
31
|
+
SELECT t.id, t.token, t.seat_id, t.status, t.expires_at, t.label,
|
|
32
|
+
s.status as seat_status
|
|
33
|
+
FROM api_tokens t
|
|
34
|
+
JOIN seats s ON t.seat_id = s.id
|
|
35
|
+
WHERE t.token = ?
|
|
36
|
+
`).get(providedToken);
|
|
37
|
+
if (!tokenRow) {
|
|
38
|
+
return reply.code(401).send({ error: "unauthorized", message: "Invalid API token" });
|
|
39
|
+
}
|
|
40
|
+
if (tokenRow.status === "revoked") {
|
|
41
|
+
return reply.code(403).send({
|
|
42
|
+
error: "forbidden",
|
|
43
|
+
message: "API token has been revoked",
|
|
44
|
+
code: "TOKEN_REVOKED"
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
if (tokenRow.status === "expired") {
|
|
48
|
+
return reply.code(403).send({
|
|
49
|
+
error: "forbidden",
|
|
50
|
+
message: "API token has expired",
|
|
51
|
+
code: "TOKEN_EXPIRED"
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
if (tokenRow.seat_status !== "active") {
|
|
55
|
+
return reply.code(403).send({
|
|
56
|
+
error: "forbidden",
|
|
57
|
+
message: "License seat is not active. Contact support.",
|
|
58
|
+
code: "SEAT_INACTIVE"
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
if (tokenRow.expires_at && new Date(tokenRow.expires_at) < new Date()) {
|
|
62
|
+
db.prepare("UPDATE api_tokens SET status = 'expired', revoked_at = datetime('now') WHERE id = ?").run(tokenRow.id);
|
|
63
|
+
return reply.code(403).send({
|
|
64
|
+
error: "forbidden",
|
|
65
|
+
message: "API token has expired",
|
|
66
|
+
code: "TOKEN_EXPIRED"
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
db.prepare("UPDATE api_tokens SET last_used_at = datetime('now') WHERE id = ?").run(tokenRow.id);
|
|
70
|
+
request.tokenId = tokenRow.id;
|
|
71
|
+
request.seatId = tokenRow.seat_id;
|
|
72
|
+
request.tokenLabel = tokenRow.label;
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import crypto from "node:crypto"
|
|
2
|
+
import { getDb } from "../lib/db.js"
|
|
3
|
+
|
|
4
|
+
const MASTER_KEY = process.env.VIBEOS_API_MASTER_KEY
|
|
5
|
+
|
|
6
|
+
export function authMiddleware(fastify: any) {
|
|
7
|
+
fastify.addHook("onRequest", async (request: any, reply: any) => {
|
|
8
|
+
if (request.url.startsWith("/health") || request.url.startsWith("/favicon")) {
|
|
9
|
+
return
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
if (request.url.startsWith("/admin/")) {
|
|
13
|
+
const authHeader = request.headers["authorization"]
|
|
14
|
+
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
|
15
|
+
return reply.code(401).send({ error: "unauthorized", message: "Missing or invalid Authorization header" })
|
|
16
|
+
}
|
|
17
|
+
const providedKey = authHeader.slice(7)
|
|
18
|
+
const keyBuffer = Buffer.from(providedKey)
|
|
19
|
+
const expectedBuffer = Buffer.from(MASTER_KEY)
|
|
20
|
+
if (keyBuffer.length !== expectedBuffer.length || !crypto.timingSafeEqual(keyBuffer, expectedBuffer)) {
|
|
21
|
+
return reply.code(403).send({ error: "forbidden", message: "Invalid master key" })
|
|
22
|
+
}
|
|
23
|
+
request.adminAuth = true
|
|
24
|
+
return
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (request.url.startsWith("/api/v1/")) {
|
|
28
|
+
const authHeader = request.headers["authorization"]
|
|
29
|
+
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
|
30
|
+
return reply.code(401).send({ error: "unauthorized", message: "Missing or invalid Authorization header" })
|
|
31
|
+
}
|
|
32
|
+
const providedToken = authHeader.slice(7)
|
|
33
|
+
|
|
34
|
+
const db = getDb()
|
|
35
|
+
const tokenRow = db.prepare(`
|
|
36
|
+
SELECT t.id, t.token, t.seat_id, t.status, t.expires_at, t.label,
|
|
37
|
+
s.status as seat_status
|
|
38
|
+
FROM api_tokens t
|
|
39
|
+
JOIN seats s ON t.seat_id = s.id
|
|
40
|
+
WHERE t.token = ?
|
|
41
|
+
`).get(providedToken)
|
|
42
|
+
|
|
43
|
+
if (!tokenRow) {
|
|
44
|
+
return reply.code(401).send({ error: "unauthorized", message: "Invalid API token" })
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (tokenRow.status === "revoked") {
|
|
48
|
+
return reply.code(403).send({
|
|
49
|
+
error: "forbidden",
|
|
50
|
+
message: "API token has been revoked",
|
|
51
|
+
code: "TOKEN_REVOKED"
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (tokenRow.status === "expired") {
|
|
56
|
+
return reply.code(403).send({
|
|
57
|
+
error: "forbidden",
|
|
58
|
+
message: "API token has expired",
|
|
59
|
+
code: "TOKEN_EXPIRED"
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (tokenRow.seat_status !== "active") {
|
|
64
|
+
return reply.code(403).send({
|
|
65
|
+
error: "forbidden",
|
|
66
|
+
message: "License seat is not active. Contact support.",
|
|
67
|
+
code: "SEAT_INACTIVE"
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (tokenRow.expires_at && new Date(tokenRow.expires_at) < new Date()) {
|
|
72
|
+
db.prepare("UPDATE api_tokens SET status = 'expired', revoked_at = datetime('now') WHERE id = ?").run(tokenRow.id)
|
|
73
|
+
return reply.code(403).send({
|
|
74
|
+
error: "forbidden",
|
|
75
|
+
message: "API token has expired",
|
|
76
|
+
code: "TOKEN_EXPIRED"
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
db.prepare("UPDATE api_tokens SET last_used_at = datetime('now') WHERE id = ?").run(tokenRow.id)
|
|
81
|
+
|
|
82
|
+
request.tokenId = tokenRow.id
|
|
83
|
+
request.seatId = tokenRow.seat_id
|
|
84
|
+
request.tokenLabel = tokenRow.label
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { getDb } from "../lib/db.js";
|
|
2
|
+
export function usageLoggingMiddleware(fastify) {
|
|
3
|
+
fastify.addHook("onResponse", async (request, reply) => {
|
|
4
|
+
const urlPath = request.url.split("?")[0];
|
|
5
|
+
if (!urlPath.startsWith("/api/v1/") && !urlPath.startsWith("/admin/"))
|
|
6
|
+
return;
|
|
7
|
+
try {
|
|
8
|
+
const db = getDb();
|
|
9
|
+
const duration = request.hrtime ? Math.round(request.hrtime()[1] / 1e6) : 0;
|
|
10
|
+
const requestBody = request.body ? JSON.stringify(request.body).substring(0, 4096) : null;
|
|
11
|
+
const responseSize = reply.getHeader("content-length") || 0;
|
|
12
|
+
if (request.tokenId) {
|
|
13
|
+
db.prepare([
|
|
14
|
+
"INSERT INTO usage_log (token_id, endpoint, request_body, response_size, latency_ms)",
|
|
15
|
+
"VALUES (?, ?, ?, ?, ?)"
|
|
16
|
+
].join(" ")).run(request.tokenId, urlPath, requestBody, responseSize, duration);
|
|
17
|
+
}
|
|
18
|
+
else if (request.adminAuth) {
|
|
19
|
+
db.prepare([
|
|
20
|
+
"INSERT INTO admin_audit_log (method, endpoint, request_body, response_status, response_size, latency_ms)",
|
|
21
|
+
"VALUES (?, ?, ?, ?, ?, ?)"
|
|
22
|
+
].join(" ")).run(request.method, urlPath, requestBody, reply.statusCode, responseSize, duration);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
catch (err) {
|
|
26
|
+
console.error("[vibeOS-api] usage logging error: " + err.message);
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { getDb } from "../lib/db.js"
|
|
2
|
+
|
|
3
|
+
export function usageLoggingMiddleware(fastify: any) {
|
|
4
|
+
fastify.addHook("onResponse", async (request: any, reply: any) => {
|
|
5
|
+
const urlPath = request.url.split("?")[0]
|
|
6
|
+
if (!urlPath.startsWith("/api/v1/") && !urlPath.startsWith("/admin/")) return
|
|
7
|
+
|
|
8
|
+
try {
|
|
9
|
+
const db = getDb()
|
|
10
|
+
const duration = request.hrtime ? Math.round(request.hrtime()[1] / 1e6) : 0
|
|
11
|
+
const requestBody = request.body ? JSON.stringify(request.body).substring(0, 4096) : null
|
|
12
|
+
const responseSize = reply.getHeader("content-length") || 0
|
|
13
|
+
if (request.tokenId) {
|
|
14
|
+
db.prepare([
|
|
15
|
+
"INSERT INTO usage_log (token_id, endpoint, request_body, response_size, latency_ms)",
|
|
16
|
+
"VALUES (?, ?, ?, ?, ?)"
|
|
17
|
+
].join(" ")).run(
|
|
18
|
+
request.tokenId,
|
|
19
|
+
urlPath,
|
|
20
|
+
requestBody,
|
|
21
|
+
responseSize,
|
|
22
|
+
duration
|
|
23
|
+
)
|
|
24
|
+
} else if (request.adminAuth) {
|
|
25
|
+
db.prepare([
|
|
26
|
+
"INSERT INTO admin_audit_log (method, endpoint, request_body, response_status, response_size, latency_ms)",
|
|
27
|
+
"VALUES (?, ?, ?, ?, ?, ?)"
|
|
28
|
+
].join(" ")).run(
|
|
29
|
+
request.method,
|
|
30
|
+
urlPath,
|
|
31
|
+
requestBody,
|
|
32
|
+
reply.statusCode,
|
|
33
|
+
responseSize,
|
|
34
|
+
duration
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
} catch (err: any) {
|
|
38
|
+
console.error("[vibeOS-api] usage logging error: " + err.message)
|
|
39
|
+
}
|
|
40
|
+
})
|
|
41
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
limit_req_zone $binary_remote_addr zone=vibeos_api:10m rate=30r/s;
|
|
2
|
+
|
|
3
|
+
server {
|
|
4
|
+
listen 80;
|
|
5
|
+
server_name api.vibetheog.com;
|
|
6
|
+
|
|
7
|
+
location /.well-known/acme-challenge/ {
|
|
8
|
+
root /var/www/certbot;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# Reject requests with spoofed or missing Host header (SMG3 fix)
|
|
13
|
+
if ($host !~* ^(api\.vibetheog\.com|localhost|127\.0\.0\.1)$) {
|
|
14
|
+
return 444;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
location / {
|
|
18
|
+
return 301 https://$host$request_uri;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
server {
|
|
23
|
+
listen 443 ssl http2;
|
|
24
|
+
server_name api.vibetheog.com;
|
|
25
|
+
|
|
26
|
+
ssl_certificate /etc/letsencrypt/live/vibetheog.com/fullchain.pem;
|
|
27
|
+
ssl_certificate_key /etc/letsencrypt/live/vibetheog.com/privkey.pem;
|
|
28
|
+
|
|
29
|
+
client_max_body_size 10M;
|
|
30
|
+
|
|
31
|
+
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
|
|
32
|
+
add_header X-Content-Type-Options "nosniff" always;
|
|
33
|
+
add_header X-Frame-Options "DENY" always;
|
|
34
|
+
add_header X-XSS-Protection "1; mode=block" always;
|
|
35
|
+
|
|
36
|
+
limit_req zone=vibeos_api burst=50 nodelay;
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# Reject requests with spoofed or missing Host header (SMG3 fix)
|
|
40
|
+
if ($host !~* ^(api\.vibetheog\.com|localhost|127\.0\.0\.1)$) {
|
|
41
|
+
return 444;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
location / {
|
|
45
|
+
proxy_pass http://127.0.0.1:3000;
|
|
46
|
+
proxy_http_version 1.1;
|
|
47
|
+
proxy_set_header Upgrade $http_upgrade;
|
|
48
|
+
proxy_set_header Connection 'upgrade';
|
|
49
|
+
proxy_set_header Host $host;
|
|
50
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
51
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
52
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
53
|
+
proxy_cache_bypass $http_upgrade;
|
|
54
|
+
proxy_read_timeout 30s;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
location /health {
|
|
58
|
+
limit_req zone=vibeos_api burst=20 nodelay;
|
|
59
|
+
proxy_pass http://127.0.0.1:3000/health;
|
|
60
|
+
proxy_http_version 1.1;
|
|
61
|
+
proxy_set_header Host $host;
|
|
62
|
+
proxy_read_timeout 10s;
|
|
63
|
+
}
|
|
64
|
+
}
|