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/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, challengeHeaders, authCookieHeader, clientIp, sameOrigin, limiter } from "./lib/guard.mjs";
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 SESSION_BUDGET_USD = Number(process.env.SESSION_BUDGET_USD) || 2;
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 || /ava_auth=/.test(String(req.headers.cookie || ""));
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
- function denyAuth(req, res) {
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
- return send(res, 401, "authentication required", challengeHeaders());
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
- function overBudget(sess) { return sess.score.ours.cost >= SESSION_BUDGET_USD; }
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 (!isAuthorized(req)) return denyAuth(req, res);
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 = getSession(sessionId);
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 = getSession(sessionId);
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
- const mode = body.mode === "onboarding" ? "onboarding" : "flexible";
290
- const platform = typeof body.platformId === "string" ? platformById(body.platformId) : null; // knowledge in any mode
291
- const s = newSession({ mode, platform });
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 = getSession(sessionId);
374
+ const sess = sessionFor(req, sessionId);
302
375
  if (!sess) return send(res, 404, { error: "unknown session" });
303
- if (overBudget(sess)) return send(res, 402, { error: "this session reached its spend limit — start a new call" });
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
- console.error(" [turn]", e?.message || e);
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 = getSession(sessionId);
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 (overBudget(sess)) return send(res, 402, { error: "spend limit reached" });
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
- // Specialist console page (opened at /specialist?key=<specialist key>).
333
- if (req.method === "GET" && url.pathname === "/specialist") {
334
- try {
335
- const full = join(PUBLIC, "specialist.html"), html = await readFile(full), st = await stat(full);
336
- 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) });
337
- }
338
- catch { return send(res, 404, "not found"); }
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
- const sess = key ? getSessionBySpecialistKey(key) : getSession(url.searchParams.get("sessionId"));
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 = getSession(sessionId);
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 = getSession(sessionId);
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 = getSession(sessionId);
499
+ const sess = sessionFor(req, sessionId);
384
500
  if (!sess) return send(res, 404, { error: "unknown session" });
385
- if (overBudget(sess)) return send(res, 402, { error: "spend limit reached" });
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 = getSession(url.searchParams.get("sessionId"));
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
- if (!isAuthorized(req)) { if (triedPassword(req)) rate.authFail(clientIp(req)); return reject(401, "Unauthorized"); }
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
- const sess = role === "specialist" ? getSessionBySpecialistKey(u.searchParams.get("key")) : getSession(u.searchParams.get("session"));
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}/app`);
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