ava-engine 0.2.0 → 0.4.0
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 +11 -0
- package/README.md +23 -3
- package/lib/admin.mjs +284 -0
- package/lib/analytics.mjs +73 -0
- package/lib/calls.mjs +97 -0
- package/lib/cli/config.mjs +1 -0
- package/lib/cli/main.mjs +77 -4
- package/lib/cli/openrouter-oauth.mjs +87 -0
- package/lib/cli/setup-server.mjs +32 -4
- package/lib/cli/setup.mjs +27 -3
- package/lib/draft.mjs +53 -0
- package/lib/guard.mjs +73 -28
- package/lib/library.mjs +76 -0
- package/lib/links.mjs +148 -0
- package/lib/mock.mjs +88 -0
- package/lib/models.mjs +10 -4
- package/lib/openrouter.mjs +9 -1
- package/lib/ours.mjs +43 -29
- package/lib/platforms.mjs +77 -1
- package/lib/playbooks.mjs +120 -0
- package/lib/safe-fetch.mjs +79 -0
- package/lib/settings.mjs +187 -0
- package/lib/spend.mjs +62 -0
- package/lib/state.mjs +46 -7
- package/lib/stt.mjs +4 -1
- package/lib/team.mjs +191 -0
- package/lib/tts.mjs +18 -11
- package/lib/webhooks.mjs +112 -0
- package/package.json +3 -3
- package/public/app.js +86 -3
- package/public/dashboard.css +430 -0
- package/public/dashboard.html +76 -0
- package/public/dashboard.js +1096 -0
- package/public/embed.js +60 -0
- package/public/index.html +18 -9
- package/public/login.css +30 -0
- package/public/login.html +37 -0
- package/public/login.js +27 -0
- package/public/setup.css +139 -55
- package/public/setup.html +45 -18
- package/public/setup.js +43 -10
- package/public/style.css +21 -0
- package/server.mjs +164 -44
package/server.mjs
CHANGED
|
@@ -12,8 +12,13 @@ import { oursTurnStream, greetStream, observeUpdate, screenOff, proactiveNudge,
|
|
|
12
12
|
import { synth, TTS_ENABLED, providers, defaultProvider, warmKokoro, kokoroStream, KOKORO_STREAMS, elevenStream, ELEVEN_STREAMS, TtsBusyError } from "./lib/tts.mjs";
|
|
13
13
|
import { transcribe, STT_ENABLED } from "./lib/stt.mjs";
|
|
14
14
|
import { createAsrSession, asrAvailable, warmAsr, availableEngines, defaultEngine, ENGINE_LABELS } from "./lib/asr.mjs";
|
|
15
|
-
import { platformsView, platformById } from "./lib/platforms.mjs";
|
|
16
|
-
import { AUTH_ENABLED, isAuthorized,
|
|
15
|
+
import { platformsView, platformById, CUSTOM_PLAYBOOKS } from "./lib/platforms.mjs";
|
|
16
|
+
import { AUTH_ENABLED, isAuthorized, currentUser, signIn, logoutCookie, authCookieHeader, clientIp, sameOrigin, limiter } from "./lib/guard.mjs";
|
|
17
|
+
import { hasUsers } from "./lib/team.mjs";
|
|
18
|
+
import { emitEvent, WEBHOOKS_ENABLED } from "./lib/webhooks.mjs";
|
|
19
|
+
import { DAILY_BUDGET_USD, dailyCapReached, spentToday } from "./lib/spend.mjs";
|
|
20
|
+
import { getLink, linkBlocked, countLinkCall, publicLinkView, linkCookieHeader, linksFromRequest } from "./lib/links.mjs";
|
|
21
|
+
import { createAdmin } from "./lib/admin.mjs";
|
|
17
22
|
import { WebSocketServer } from "ws";
|
|
18
23
|
|
|
19
24
|
// A stray rejection is logged, not fatal. An uncaught exception leaves the process
|
|
@@ -24,25 +29,33 @@ process.on("uncaughtException", (e) => { console.error("[uncaught exception]", e
|
|
|
24
29
|
const __dir = dirname(fileURLToPath(import.meta.url));
|
|
25
30
|
const PUBLIC = join(__dir, "public");
|
|
26
31
|
const PORT = Number(process.env.PORT) || 5190;
|
|
32
|
+
// HOST=127.0.0.1 keeps Ava private to this computer (the CLI's "just me" setup).
|
|
33
|
+
const HOST = process.env.HOST || undefined;
|
|
34
|
+
const LOCAL_ONLY = HOST === "127.0.0.1" || HOST === "localhost" || HOST === "::1";
|
|
27
35
|
|
|
28
36
|
// Spend limits. Each session is capped in model spend; each client IP in request rate.
|
|
29
|
-
const
|
|
37
|
+
const sessionBudget = () => Number(process.env.SESSION_BUDGET_USD) || 2; // live: editable in Settings
|
|
30
38
|
const MAX_USER_TEXT = 2000;
|
|
31
39
|
const MAX_FRAME_BYTES = 2 * 1024 * 1024; // a downscaled JPEG screen frame is ~100-300 KB
|
|
32
40
|
const FRAME_RE = /^data:image\/(jpeg|png|webp);base64,[A-Za-z0-9+/=]+$/;
|
|
33
41
|
const rate = {
|
|
34
|
-
session: limiter("session", Number(process.env.RATE_SESSIONS_PER_MIN) || 10, 60_000), // new calls per IP per minute
|
|
35
|
-
api: limiter("api", Number(process.env.RATE_API_PER_MIN) || 240, 60_000), // all other /api calls per IP per minute
|
|
42
|
+
session: limiter("session", () => Number(process.env.RATE_SESSIONS_PER_MIN) || 10, 60_000), // new calls per IP per minute
|
|
43
|
+
api: limiter("api", () => Number(process.env.RATE_API_PER_MIN) || 240, 60_000), // all other /api calls per IP per minute
|
|
36
44
|
authFail: limiter("authFail", 20, 10 * 60_000), // wrong passwords per IP per 10 minutes
|
|
37
45
|
};
|
|
38
46
|
|
|
39
47
|
// A wrong password counts against the client; past the limit, stop checking.
|
|
40
48
|
// (A first visit with no credentials at all is just the login prompt, not a guess.)
|
|
41
|
-
const triedPassword = (req) => !!req.headers.authorization || /
|
|
49
|
+
const triedPassword = (req) => !!req.headers.authorization || /ava_session=/.test(String(req.headers.cookie || ""));
|
|
42
50
|
function lockedOut(req) { return triedPassword(req) && rate.authFail.blocked(clientIp(req)); }
|
|
43
|
-
|
|
51
|
+
// Pages go to the sign-in page (and come back after); API calls get a 401.
|
|
52
|
+
function denyAuth(req, res, url) {
|
|
44
53
|
if (triedPassword(req)) rate.authFail(clientIp(req));
|
|
45
|
-
|
|
54
|
+
if ((req.method === "GET" || req.method === "HEAD") && !url.pathname.startsWith("/api/")) {
|
|
55
|
+
res.writeHead(302, { Location: `/login?next=${encodeURIComponent(url.pathname + url.search)}`, "Cache-Control": "no-store" });
|
|
56
|
+
return res.end();
|
|
57
|
+
}
|
|
58
|
+
return send(res, 401, { error: "Sign in first." });
|
|
46
59
|
}
|
|
47
60
|
const LOCKED = "too many failed logins — try again in 10 minutes";
|
|
48
61
|
const asrPerIp = new Map(); // ip → open /asr sockets
|
|
@@ -93,6 +106,7 @@ function send(res, code, body, headers = {}) {
|
|
|
93
106
|
// Upstream errors (OpenRouter, ElevenLabs, Deepgram…) can carry account or quota
|
|
94
107
|
// detail. Log it here; the browser only gets a short, generic reason.
|
|
95
108
|
function upstreamFail(res, e, what) {
|
|
109
|
+
if (e?.code === "daily_cap") return send(res, 503, { error: CAP_MSG }, { "Retry-After": untilMidnightUtc() });
|
|
96
110
|
console.error(` [${what}]`, e?.message || e);
|
|
97
111
|
return send(res, e?.status === 429 ? 429 : 502, { error: `${what} failed` });
|
|
98
112
|
}
|
|
@@ -125,7 +139,32 @@ function readBody(req, limit = 3 * 1024 * 1024) {
|
|
|
125
139
|
|
|
126
140
|
function safeHost(req) { const h = String(req.headers.host || ""); return /^[a-z0-9.-]+(:\d+)?$/i.test(h) ? h : ""; }
|
|
127
141
|
function validFrame(f) { return typeof f === "string" && f.length <= MAX_FRAME_BYTES && FRAME_RE.test(f) ? f : null; }
|
|
128
|
-
|
|
142
|
+
const CAP_MSG = "Ava has reached today's usage limit — try again tomorrow";
|
|
143
|
+
/** Why a session can't spend more (its own budget, or the daily cap across all sessions), or null. */
|
|
144
|
+
function spendLimit(sess) {
|
|
145
|
+
if (!MOCK && dailyCapReached()) return CAP_MSG;
|
|
146
|
+
if (sess.score.ours.cost >= sessionBudget()) return "this session reached its spend limit — start a new call";
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
const admin = createAdmin({
|
|
150
|
+
send, readBody, LOCAL_ONLY, HOST, PORT, sessionBudget,
|
|
151
|
+
origin: (req) => `${secureReq(req) || req.headers["x-forwarded-proto"] === "https" ? "https" : "http"}://${safeHost(req) || `localhost:${PORT}`}`,
|
|
152
|
+
voice: () => ({ enabled: TTS_ENABLED, default: defaultProvider(), providers: providers() }),
|
|
153
|
+
speech: () => ({ offline: asrAvailable(), engines: availableEngines(), default: defaultEngine(), cloud: STT_ENABLED }),
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
/** Serve one HTML page from public/ with its CSP (and the login cookie, if any). */
|
|
157
|
+
async function servePage(req, res, file, headers = {}) {
|
|
158
|
+
try {
|
|
159
|
+
const full = join(PUBLIC, file), html = await readFile(full), st = await stat(full);
|
|
160
|
+
return send(res, 200, html, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store", "Content-Security-Policy": cspFor(html, `${full}|${st.mtimeMs}`, safeHost(req)), ...authCookieHeader(req), ...headers });
|
|
161
|
+
} catch { return send(res, 404, "not found"); }
|
|
162
|
+
}
|
|
163
|
+
// Only same-site paths after signing in (never another host).
|
|
164
|
+
const safeNext = (n) => (typeof n === "string" && /^\/(?!\/)[\w\-./?#=&%~]*$/.test(n) && !n.startsWith("/login") ? n : "/dashboard");
|
|
165
|
+
const secureReq = (req) => process.env.TRUST_PROXY === "1" || !!req.socket.encrypted;
|
|
166
|
+
|
|
167
|
+
const untilMidnightUtc = () => String(Math.ceil((new Date().setUTCHours(24, 0, 0, 0) - Date.now()) / 1000));
|
|
129
168
|
|
|
130
169
|
async function serveStatic(req, res, pathname) {
|
|
131
170
|
let p;
|
|
@@ -171,11 +210,29 @@ async function serveStatic(req, res, pathname) {
|
|
|
171
210
|
// Pages that open a call (and everything they talk to) sit behind ACCESS_PASSWORD
|
|
172
211
|
// when it's set. The landing page and its assets stay public.
|
|
173
212
|
function needsAuth(pathname) {
|
|
174
|
-
return pathname.startsWith("/api/") && pathname !== "/api/health"
|
|
213
|
+
return pathname.startsWith("/api/") && pathname !== "/api/health" && !pathname.startsWith("/api/link/") // a link's id is its own key
|
|
214
|
+
&& pathname !== "/api/auth/login" && pathname !== "/api/auth/mode"
|
|
175
215
|
|| pathname === "/app" || pathname === "/app/" || pathname === "/index.html"
|
|
176
|
-
|| pathname === "/specialist" || pathname === "/specialist.html"
|
|
216
|
+
|| pathname === "/specialist" || pathname === "/specialist.html"
|
|
217
|
+
|| pathname === "/dashboard" || pathname === "/dashboard.html";
|
|
177
218
|
}
|
|
178
219
|
|
|
220
|
+
// Share-link visitors (see lib/links.mjs) may use only what a customer's call needs.
|
|
221
|
+
const CUSTOMER_API = new Set(["/api/session", "/api/turn", "/api/observe", "/api/nudge", "/api/end", "/api/summary", "/api/truncate", "/api/stt", "/api/tts", "/api/handoff", "/api/bridge"]);
|
|
222
|
+
/** Is this caller allowed to drive that session? Operators: any. Link visitors: calls from their own link. */
|
|
223
|
+
function canUse(req, sess) {
|
|
224
|
+
return !!sess && (req.ava.operator || (!!sess.link && req.ava.links.some((l) => l.id === sess.link.id)));
|
|
225
|
+
}
|
|
226
|
+
function sessionFor(req, id) { const s = getSession(id); return canUse(req, s) ? s : undefined; }
|
|
227
|
+
// "This computer": a loopback socket, addressed as localhost, not relayed by a proxy
|
|
228
|
+
// or tunnel (cloudflared and friends also connect from 127.0.0.1).
|
|
229
|
+
const isLoopback = (req) => /^(127\.|::1$|::ffff:127\.)/.test(req.socket.remoteAddress || "")
|
|
230
|
+
&& /^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/i.test(String(req.headers.host || ""))
|
|
231
|
+
&& !["x-forwarded-for", "forwarded", "cf-connecting-ip", "fly-client-ip", "x-real-ip"].some((h) => req.headers[h]);
|
|
232
|
+
// The dashboard changes how Ava runs. Without a password it's only for this computer.
|
|
233
|
+
const dashboardAllowed = (req) => req.ava.operator && (AUTH_ENABLED || LOCAL_ONLY || isLoopback(req) || !!req.ava.user?.apiKey); // an API key is a sign-in
|
|
234
|
+
const DASHBOARD_LOCKED = "The dashboard is only available on this computer until you set a password. Run `npx ava-engine setup` and choose “My team or customers”.";
|
|
235
|
+
|
|
179
236
|
// Aborts when the client goes away before we've answered (not after a normal finish).
|
|
180
237
|
function clientGone(res) {
|
|
181
238
|
const ac = new AbortController();
|
|
@@ -189,10 +246,12 @@ async function handle(req, res) {
|
|
|
189
246
|
for (const [k, v] of Object.entries(SECURITY_HEADERS)) res.setHeader(k, v);
|
|
190
247
|
let url;
|
|
191
248
|
try { url = new URL(req.url, "http://x"); } catch { return send(res, 400, "bad request"); }
|
|
249
|
+
const user = currentUser(req), operator = !!user;
|
|
250
|
+
req.ava = { operator, user, links: operator ? [] : linksFromRequest(req) };
|
|
192
251
|
if (needsAuth(url.pathname)) {
|
|
193
252
|
// Locked-out clients get no answer about any password, right or wrong.
|
|
194
253
|
if (lockedOut(req)) return send(res, 429, LOCKED, { "Retry-After": "600" });
|
|
195
|
-
if (!
|
|
254
|
+
if (!operator && !(req.ava.links.length && CUSTOMER_API.has(url.pathname))) return denyAuth(req, res, url);
|
|
196
255
|
}
|
|
197
256
|
if (url.pathname.startsWith("/api/") && url.pathname !== "/api/health" && !rate.api(clientIp(req))) {
|
|
198
257
|
return send(res, 429, { error: "too many requests — slow down" }, { "Retry-After": "30" });
|
|
@@ -201,6 +260,7 @@ async function handle(req, res) {
|
|
|
201
260
|
if (req.method === "GET" && url.pathname === "/api/health") {
|
|
202
261
|
return send(res, 200, {
|
|
203
262
|
mock: MOCK,
|
|
263
|
+
dailyCapReached: !MOCK && dailyCapReached(),
|
|
204
264
|
models: { talker: MODELS.talker, perception: MODELS.perception, planner: MODELS.planner, intent: MODELS.intent },
|
|
205
265
|
labels: ROLE_LABEL,
|
|
206
266
|
tts: { enabled: TTS_ENABLED, providers: providers(), default: defaultProvider() },
|
|
@@ -216,7 +276,7 @@ async function handle(req, res) {
|
|
|
216
276
|
// End-of-call recap for the session report.
|
|
217
277
|
if (req.method === "POST" && url.pathname === "/api/summary") {
|
|
218
278
|
const { sessionId } = await readBody(req);
|
|
219
|
-
const sess =
|
|
279
|
+
const sess = sessionFor(req, sessionId);
|
|
220
280
|
if (!sess) return send(res, 404, { error: "unknown session" });
|
|
221
281
|
try { return send(res, 200, await sessionSummary(sess)); }
|
|
222
282
|
catch (e) { return upstreamFail(res, e, "summary"); }
|
|
@@ -225,7 +285,7 @@ async function handle(req, res) {
|
|
|
225
285
|
// The user barged in — rewrite Ava's last reply to what was actually heard.
|
|
226
286
|
if (req.method === "POST" && url.pathname === "/api/truncate") {
|
|
227
287
|
const { sessionId, heard = "" } = await readBody(req);
|
|
228
|
-
const sess =
|
|
288
|
+
const sess = sessionFor(req, sessionId);
|
|
229
289
|
if (!sess) return send(res, 404, { error: "unknown session" });
|
|
230
290
|
return send(res, 200, truncateAssistant(sess, String(heard).slice(0, 2000)));
|
|
231
291
|
}
|
|
@@ -285,22 +345,36 @@ async function handle(req, res) {
|
|
|
285
345
|
if (req.method === "POST" && url.pathname === "/api/session") {
|
|
286
346
|
if (!rate.session(clientIp(req))) return send(res, 429, { error: "too many new sessions — try again in a minute" }, { "Retry-After": "60" });
|
|
287
347
|
if (!hasCapacity()) return send(res, 503, { error: "Ava is at capacity right now — try again in a few minutes" }, { "Retry-After": "60" });
|
|
348
|
+
if (!MOCK && dailyCapReached()) return send(res, 503, { error: CAP_MSG }, { "Retry-After": untilMidnightUtc() });
|
|
288
349
|
const body = await readBody(req);
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
350
|
+
// A share link fixes the product and mode; link visitors can't pick their own.
|
|
351
|
+
let link = null;
|
|
352
|
+
if (body.link != null) {
|
|
353
|
+
link = getLink(String(body.link));
|
|
354
|
+
const why = linkBlocked(link);
|
|
355
|
+
if (why) return send(res, 410, { error: why });
|
|
356
|
+
if (!operator && !req.ava.links.some((l) => l.id === link.id)) return send(res, 403, { error: "Open this call from its link." });
|
|
357
|
+
} else if (!operator) return send(res, 403, { error: "Open this call from its link." });
|
|
358
|
+
const mode = link ? link.mode : body.mode === "onboarding" ? "onboarding" : "flexible";
|
|
359
|
+
const platformId = link ? link.product : typeof body.platformId === "string" ? body.platformId : null;
|
|
360
|
+
const platform = platformId ? platformById(platformId) : null; // knowledge in any mode
|
|
361
|
+
if (link && link.product && !platform) return send(res, 410, { error: "This link's product isn't available any more. Ask whoever sent it for a new one." });
|
|
362
|
+
const s = newSession({ mode, platform, link });
|
|
292
363
|
if (!s) return send(res, 503, { error: "Ava is at capacity right now — try again in a few minutes" }, { "Retry-After": "60" });
|
|
364
|
+
if (link) countLinkCall(link);
|
|
365
|
+
emitEvent("session.started", { sessionId: s.id, mode, platform: platform ? { id: platform.id, name: platform.name } : null, link: link ? { id: link.id, name: link.name } : null });
|
|
293
366
|
// pre-warm the talker route so the greeting isn't a cold start
|
|
294
367
|
if (!MOCK) chat({ model: MODELS.talker, role: "talker", provider: PROVIDER.talker, messages: [{ role: "user", content: "hi" }], maxTokens: 1 }).catch(() => {});
|
|
295
|
-
return send(res, 200, { sessionId: s.id, mock: MOCK, models: MODELS, mode, platform: platform ? { id: platform.id, name: platform.name, url: platform.url } : null });
|
|
368
|
+
return send(res, 200, { sessionId: s.id, mock: MOCK, models: MODELS, mode, platform: platform ? { id: platform.id, name: platform.name, url: platform.url } : null, link: link ? publicLinkView(link, platform).options : null });
|
|
296
369
|
}
|
|
297
370
|
|
|
298
371
|
if (req.method === "POST" && url.pathname === "/api/turn") {
|
|
299
372
|
const body = await readBody(req);
|
|
300
373
|
const { sessionId, frameHash = null, greet = false, voice = false, changed = false, screenActive = false } = body;
|
|
301
|
-
const sess =
|
|
374
|
+
const sess = sessionFor(req, sessionId);
|
|
302
375
|
if (!sess) return send(res, 404, { error: "unknown session" });
|
|
303
|
-
|
|
376
|
+
const limit = spendLimit(sess);
|
|
377
|
+
if (limit) return send(res, 402, { error: limit });
|
|
304
378
|
const userText = String(body.userText ?? "").slice(0, MAX_USER_TEXT);
|
|
305
379
|
const frame = validFrame(body.frame);
|
|
306
380
|
|
|
@@ -310,8 +384,8 @@ async function handle(req, res) {
|
|
|
310
384
|
if (greet) await greetStream(sess, emit, !!voice);
|
|
311
385
|
else await oursTurnStream(sess, userText, frame, !!screenActive, frameHash, emit, !!voice, !!changed);
|
|
312
386
|
} catch (e) {
|
|
313
|
-
|
|
314
|
-
emit("error", { error: "Ava couldn't answer that one — try again" });
|
|
387
|
+
if (e?.code === "daily_cap") emit("error", { error: CAP_MSG });
|
|
388
|
+
else { console.error(" [turn]", e?.message || e); emit("error", { error: "Ava couldn't answer that one — try again" }); }
|
|
315
389
|
}
|
|
316
390
|
res.end();
|
|
317
391
|
return;
|
|
@@ -319,41 +393,82 @@ async function handle(req, res) {
|
|
|
319
393
|
|
|
320
394
|
if (req.method === "POST" && url.pathname === "/api/observe") {
|
|
321
395
|
const { sessionId, frame: rawFrame, frameHash = null, stopped = false, changed = false } = await readBody(req);
|
|
322
|
-
const sess =
|
|
396
|
+
const sess = sessionFor(req, sessionId);
|
|
323
397
|
if (!sess) return send(res, 404, { error: "unknown session" });
|
|
324
398
|
if (stopped) return send(res, 200, screenOff(sess)); // user stopped sharing → mark state stale now
|
|
325
|
-
if (
|
|
399
|
+
if (spendLimit(sess)) return send(res, 402, { error: spendLimit(sess) });
|
|
326
400
|
const frame = validFrame(rawFrame);
|
|
327
401
|
if (!frame) return send(res, 400, { error: "no frame" });
|
|
328
402
|
try { return send(res, 200, await observeUpdate(sess, frame, frameHash, !!changed)); }
|
|
329
403
|
catch (e) { return upstreamFail(res, e, "screen read"); }
|
|
330
404
|
}
|
|
331
405
|
|
|
332
|
-
//
|
|
333
|
-
if (req.method === "GET" && url.pathname === "/
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
406
|
+
// ── Signing in ──
|
|
407
|
+
if ((req.method === "GET" || req.method === "HEAD") && url.pathname === "/login") {
|
|
408
|
+
if (operator && AUTH_ENABLED) { res.writeHead(302, { Location: safeNext(url.searchParams.get("next")) }); return res.end(); }
|
|
409
|
+
return servePage(req, res, "login.html");
|
|
410
|
+
}
|
|
411
|
+
if (req.method === "GET" && url.pathname === "/api/auth/mode") return send(res, 200, { accounts: hasUsers(), password: !!process.env.ACCESS_PASSWORD, required: AUTH_ENABLED });
|
|
412
|
+
if (req.method === "POST" && url.pathname === "/api/auth/login") {
|
|
413
|
+
if (lockedOut(req) || rate.authFail.blocked(clientIp(req))) return send(res, 429, { error: LOCKED }, { "Retry-After": "600" });
|
|
414
|
+
const body = await readBody(req);
|
|
415
|
+
const r = signIn({ email: typeof body.email === "string" ? body.email.trim() : "", password: body.password }, req);
|
|
416
|
+
if (!r) { rate.authFail(clientIp(req)); return send(res, 401, { error: hasUsers() ? "That email and password don't match." : "That password isn't right." }); }
|
|
417
|
+
return send(res, 200, { ok: true, user: r.user, next: safeNext(body.next) }, { "Set-Cookie": r.setCookie });
|
|
418
|
+
}
|
|
419
|
+
if (req.method === "POST" && url.pathname === "/api/auth/logout") return send(res, 200, { ok: true }, { "Set-Cookie": logoutCookie(req) });
|
|
420
|
+
if (req.method === "GET" && url.pathname === "/api/auth/me") return send(res, 200, { user, accounts: hasUsers(), required: AUTH_ENABLED });
|
|
421
|
+
|
|
422
|
+
// ── Dashboard (operator only) ──
|
|
423
|
+
if ((req.method === "GET" || req.method === "HEAD") && (url.pathname === "/dashboard" || url.pathname === "/dashboard.html")) {
|
|
424
|
+
if (!dashboardAllowed(req)) return send(res, 403, DASHBOARD_LOCKED);
|
|
425
|
+
return servePage(req, res, "dashboard.html");
|
|
426
|
+
}
|
|
427
|
+
if (req.method === "GET" && url.pathname === "/dashboard/openrouter-callback") return await admin.finishOpenRouter(req, res, url);
|
|
428
|
+
if (url.pathname.startsWith("/api/admin/")) {
|
|
429
|
+
if (!dashboardAllowed(req)) return send(res, 403, { error: DASHBOARD_LOCKED });
|
|
430
|
+
if (req.method !== "GET" && !sameOrigin(req)) return send(res, 403, { error: "cross-site request refused" });
|
|
431
|
+
return await admin(req, res, url, req.ava.user);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// ── Share links: /c/<id> opens a customer call set up by the link ──
|
|
435
|
+
const linkPage = url.pathname.match(/^\/c\/([A-Za-z0-9_-]{22})\/?$/);
|
|
436
|
+
if (linkPage && (req.method === "GET" || req.method === "HEAD")) {
|
|
437
|
+
const l = getLink(linkPage[1]);
|
|
438
|
+
const live = l && l.enabled && !(l.expiresAt && Date.parse(l.expiresAt) <= Date.now());
|
|
439
|
+
return servePage(req, res, "index.html", live ? { "Set-Cookie": linkCookieHeader(l.id, secureReq(req)) } : {});
|
|
440
|
+
}
|
|
441
|
+
const linkApi = url.pathname.match(/^\/api\/link\/([A-Za-z0-9_-]{1,64})$/);
|
|
442
|
+
if (linkApi && req.method === "GET") {
|
|
443
|
+
const l = getLink(linkApi[1]);
|
|
444
|
+
const why = linkBlocked(l);
|
|
445
|
+
if (!l) return send(res, 404, { error: why });
|
|
446
|
+
const p = l.product ? platformById(l.product) : null;
|
|
447
|
+
if (l.product && !p) return send(res, 410, { error: "This link's product isn't available any more. Ask whoever sent it for a new one." });
|
|
448
|
+
return send(res, why ? 410 : 200, { ...publicLinkView(l, p), ...(why ? { error: why } : {}) });
|
|
339
449
|
}
|
|
340
450
|
|
|
451
|
+
// Specialist console page (opened at /specialist?key=<specialist key>).
|
|
452
|
+
if (req.method === "GET" && url.pathname === "/specialist") return servePage(req, res, "specialist.html");
|
|
453
|
+
|
|
341
454
|
// Live bridge between the user's call and a human specialist. The user is
|
|
342
455
|
// identified by sessionId; the specialist only ever holds the specialist key
|
|
343
456
|
// minted when the user asked for a person (revoked when the handoff ends).
|
|
344
457
|
if (req.method === "GET" && url.pathname === "/api/bridge") {
|
|
345
458
|
const key = url.searchParams.get("key");
|
|
346
|
-
|
|
459
|
+
if (key && !operator) return send(res, 401, { error: "specialists sign in with the team password" });
|
|
460
|
+
const sess = key ? getSessionBySpecialistKey(key) : sessionFor(req, url.searchParams.get("sessionId"));
|
|
347
461
|
if (!sess) return send(res, 404, { error: "unknown session" });
|
|
348
462
|
return send(res, 200, bridgePoll(sess, Number(url.searchParams.get("since")) || 0));
|
|
349
463
|
}
|
|
350
464
|
if (req.method === "POST" && url.pathname === "/api/bridge") {
|
|
351
465
|
const { sessionId, key, action = "say", text = "" } = await readBody(req);
|
|
352
466
|
if (action === "usersay") {
|
|
353
|
-
const sess =
|
|
467
|
+
const sess = sessionFor(req, sessionId);
|
|
354
468
|
if (!sess) return send(res, 404, { error: "unknown session" });
|
|
355
469
|
return send(res, 200, userSayToBridge(sess, String(text).slice(0, MAX_USER_TEXT)));
|
|
356
470
|
}
|
|
471
|
+
if (!operator) return send(res, 401, { error: "specialists sign in with the team password" });
|
|
357
472
|
const sess = getSessionBySpecialistKey(key);
|
|
358
473
|
if (!sess) return send(res, 404, { error: "unknown or expired link" });
|
|
359
474
|
if (action === "join") { const r = specialistJoin(sess); return r ? send(res, 200, r) : send(res, 409, { error: "no handoff requested" }); }
|
|
@@ -371,24 +486,25 @@ async function handle(req, res) {
|
|
|
371
486
|
// Human handoff — request a real person, poll the connect state.
|
|
372
487
|
if (req.method === "POST" && url.pathname === "/api/handoff") {
|
|
373
488
|
const { sessionId, action = "request" } = await readBody(req);
|
|
374
|
-
const sess =
|
|
489
|
+
const sess = sessionFor(req, sessionId);
|
|
375
490
|
if (!sess) return send(res, 404, { error: "unknown session" });
|
|
376
491
|
if (action === "cancel") return send(res, 200, cancelHandoff(sess));
|
|
492
|
+
if (sess.link && getLink(sess.link.id)?.handoff === false) return send(res, 403, { error: "Talking to a person isn't available on this link." });
|
|
377
493
|
if (action === "status") return send(res, 200, handoffStatus(sess));
|
|
378
494
|
return send(res, 200, requestHandoff(sess));
|
|
379
495
|
}
|
|
380
496
|
|
|
381
497
|
if (req.method === "POST" && url.pathname === "/api/nudge") {
|
|
382
498
|
const { sessionId } = await readBody(req);
|
|
383
|
-
const sess =
|
|
499
|
+
const sess = sessionFor(req, sessionId);
|
|
384
500
|
if (!sess) return send(res, 404, { error: "unknown session" });
|
|
385
|
-
if (
|
|
501
|
+
if (spendLimit(sess)) return send(res, 402, { error: spendLimit(sess) });
|
|
386
502
|
try { return send(res, 200, await proactiveNudge(sess)); }
|
|
387
503
|
catch (e) { return upstreamFail(res, e, "nudge"); }
|
|
388
504
|
}
|
|
389
505
|
|
|
390
506
|
if (req.method === "GET" && url.pathname === "/api/scoreboard") {
|
|
391
|
-
const sess =
|
|
507
|
+
const sess = sessionFor(req, url.searchParams.get("sessionId"));
|
|
392
508
|
if (!sess) return send(res, 404, { error: "unknown session" });
|
|
393
509
|
return send(res, 200, { ours: scoreView(sess.score.ours) });
|
|
394
510
|
}
|
|
@@ -421,7 +537,9 @@ server.on("upgrade", (req, socket, head) => {
|
|
|
421
537
|
const reject = (code, msg) => { try { socket.write(`HTTP/1.1 ${code} ${msg}\r\nConnection: close\r\n\r\n`); } catch {} socket.destroy(); };
|
|
422
538
|
if (!sameOrigin(req)) return reject(403, "Forbidden");
|
|
423
539
|
if (lockedOut(req)) return reject(429, "Too Many Requests");
|
|
424
|
-
|
|
540
|
+
const operator = isAuthorized(req), links = operator ? [] : linksFromRequest(req);
|
|
541
|
+
if (!operator && !links.length) { if (triedPassword(req)) rate.authFail(clientIp(req)); return reject(401, "Unauthorized"); }
|
|
542
|
+
req.ava = { operator, user: currentUser(req), links };
|
|
425
543
|
if (path === "/asr") {
|
|
426
544
|
const ip = clientIp(req);
|
|
427
545
|
if (asrOpen >= MAX_ASR_TOTAL || (asrPerIp.get(ip) || 0) >= MAX_ASR_PER_IP) return reject(503, "Service Unavailable");
|
|
@@ -435,7 +553,8 @@ rtcWss.on("connection", (ws, req) => {
|
|
|
435
553
|
const role = u.searchParams.get("role") === "specialist" ? "specialist" : "user";
|
|
436
554
|
const other = role === "user" ? "specialist" : "user";
|
|
437
555
|
// The user proves the call with its session id, the specialist with the handoff key.
|
|
438
|
-
|
|
556
|
+
// Specialists are the operator's team; a share-link visitor can only be the customer.
|
|
557
|
+
const sess = role === "specialist" ? (req.ava.operator ? getSessionBySpecialistKey(u.searchParams.get("key")) : null) : sessionFor(req, u.searchParams.get("session"));
|
|
439
558
|
if (!sess) { try { ws.close(4404, "unknown session"); } catch {} return; }
|
|
440
559
|
const session = sess.id;
|
|
441
560
|
let room = rtcRooms.get(session);
|
|
@@ -477,9 +596,6 @@ wss.on("connection", (ws, req) => {
|
|
|
477
596
|
ws.on("error", () => {});
|
|
478
597
|
});
|
|
479
598
|
|
|
480
|
-
// HOST=127.0.0.1 keeps Ava private to this computer (the CLI's "just me" setup).
|
|
481
|
-
const HOST = process.env.HOST || undefined;
|
|
482
|
-
const LOCAL_ONLY = HOST === "127.0.0.1" || HOST === "localhost" || HOST === "::1";
|
|
483
599
|
|
|
484
600
|
server.on("error", (e) => {
|
|
485
601
|
if (e.code === "EADDRINUSE") console.error(`\n ✗ Port ${PORT} is already in use. Stop whatever is using it, or pick another port (PORT=5191, or \`ava-engine start --port 5191\`).\n`);
|
|
@@ -490,12 +606,16 @@ server.on("error", (e) => {
|
|
|
490
606
|
|
|
491
607
|
server.listen(PORT, HOST, () => {
|
|
492
608
|
console.log(`\n Ava — screen-aware voice agent`);
|
|
493
|
-
console.log(` → http://localhost:${PORT}/
|
|
609
|
+
console.log(` → dashboard http://localhost:${PORT}/dashboard`);
|
|
610
|
+
console.log(` → test call http://localhost:${PORT}/app`);
|
|
494
611
|
console.log(` mode: ${MOCK ? "MOCK (no OPENROUTER_API_KEY set — synthetic replies)" : "LIVE (OpenRouter)"}`);
|
|
495
612
|
if (!MOCK) console.log(` models: talker=${MODELS.talker} perception=${MODELS.perception} planner=${MODELS.planner} intent=${MODELS.intent}`);
|
|
613
|
+
if (!MOCK) console.log(DAILY_BUDGET_USD ? ` daily cap: $${DAILY_BUDGET_USD} (spent today: $${spentToday().toFixed(2)})` : " daily cap: none (set DAILY_BUDGET_USD to limit spend across all calls)");
|
|
614
|
+
if (CUSTOM_PLAYBOOKS.count) console.log(` playbooks: ${CUSTOM_PLAYBOOKS.count} from ${CUSTOM_PLAYBOOKS.dir}`);
|
|
615
|
+
if (WEBHOOKS_ENABLED) console.log(" webhooks: on");
|
|
496
616
|
if (LOCAL_ONLY) console.log(" access: this computer only");
|
|
497
617
|
else if (!AUTH_ENABLED) console.log(" access: OPEN — anyone who can reach this server can spend your API credit. Set ACCESS_PASSWORD before exposing it.");
|
|
498
|
-
else console.log(" access: password protected");
|
|
618
|
+
else console.log(hasUsers() ? " access: team accounts (sign in at /login)" : " access: password protected");
|
|
499
619
|
console.log("");
|
|
500
620
|
warmKokoro(); // preload the offline voice so the first reply is fast
|
|
501
621
|
warmAsr(); // preload the offline ear so the first utterance is fast
|