ava-engine 0.3.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/public/setup.js CHANGED
@@ -4,8 +4,15 @@
4
4
  (() => {
5
5
  const $ = (id) => document.getElementById(id);
6
6
  const TOKEN = new URLSearchParams(location.hash.slice(1)).get("token") || "";
7
- const ORDER = ["ai", "voice", "speech", "access", "review"];
8
- const S = { step: "ai", state: null, keyOk: false, elevenOk: false, selectedPacks: new Set(), pollTimer: null };
7
+ const ORDER = ["welcome", "ai", "voice", "speech", "access", "review"];
8
+ const STEPS = ORDER.slice(1); // the numbered steps in the rail
9
+ const ICON = {
10
+ kokoro: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 19c0-8 5-13 14-14-1 9-6 14-14 14Z"/><path d="M5 19 13 11"/></svg>',
11
+ elevenlabs: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 10v4M8 7v10M12 4v16M16 8v8M20 11v2"/></svg>',
12
+ browser: '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="4.5" width="18" height="14" rx="2.5"/><path d="M3 9h18M6.5 6.8h.01M9 6.8h.01"/></svg>',
13
+ pack: '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5.5 11.5a6.5 6.5 0 0 0 13 0M12 18v3"/></svg>',
14
+ };
15
+ const S = { step: "welcome", state: null, keyOk: false, elevenOk: false, selectedPacks: new Set(), pollTimer: null };
9
16
 
10
17
  async function api(path, body) {
11
18
  const r = await fetch(`/api/setup/${path}`, {
@@ -25,7 +32,8 @@
25
32
  function show(step) {
26
33
  S.step = step;
27
34
  document.querySelectorAll(".step").forEach((s) => { s.hidden = s.dataset.step !== step; });
28
- const idx = ORDER.indexOf(step);
35
+ const idx = STEPS.indexOf(step) === -1 ? (step === "launching" ? STEPS.length : -1) : STEPS.indexOf(step);
36
+ $("progressBar").style.width = `${step === "welcome" ? 4 : step === "launching" ? 100 : ((idx + 1) / (STEPS.length + 1)) * 100}%`;
29
37
  document.querySelectorAll("#steps li").forEach((li, i) => {
30
38
  li.classList.toggle("current", i === idx);
31
39
  li.classList.toggle("done", idx > i || step === "launching");
@@ -104,7 +112,7 @@
104
112
  $("voiceCards").innerHTML = "";
105
113
  for (const [id, info] of Object.entries(v)) {
106
114
  const label = document.createElement("label"); label.className = "card";
107
- label.innerHTML = `<input type="radio" name="voice"><span class="card-body"><b></b><span></span></span>`;
115
+ label.innerHTML = `<input type="radio" name="voice"><span class="card-icon">${ICON[id] || ICON.browser}</span><span class="card-body"><b></b><span></span></span>`;
108
116
  const input = label.querySelector("input"); input.value = id; input.checked = id === cur;
109
117
  label.querySelector("b").textContent = info.label;
110
118
  label.querySelector(".card-body span").textContent = id === "kokoro" && S.state.kokoro.installed ? "Free, runs on this computer. Installed." : info.hint[0].toUpperCase() + info.hint.slice(1) + ".";
@@ -127,7 +135,7 @@
127
135
  for (const [id, p] of Object.entries(S.state.packs)) {
128
136
  const installed = S.state.installedPacks.includes(id);
129
137
  const label = document.createElement("label"); label.className = "card";
130
- label.innerHTML = `<input type="checkbox"><span class="card-body"><b></b><span></span></span>`;
138
+ label.innerHTML = `<input type="checkbox"><span class="card-icon">${ICON.pack}</span><span class="card-body"><b></b><span></span></span>`;
131
139
  const input = label.querySelector("input"); input.value = id;
132
140
  input.checked = installed || S.selectedPacks.has(id); input.disabled = installed;
133
141
  label.querySelector("b").textContent = `${p.label} · ${p.size}`;
@@ -208,7 +216,7 @@
208
216
  ["Voice", S.state.voices[a.voice].label],
209
217
  ["Speech recognition", packs.length ? packs.join(", ") : "Chrome's built-in recognition"],
210
218
  ["Access", a.audience === "me" ? "This computer only" : "Password protected"],
211
- ["Address", `http://localhost:${a.port}/app`],
219
+ ["Dashboard", `http://localhost:${a.port}/dashboard`],
212
220
  ];
213
221
  if (a.audience === "others" && a.password !== "keep") rows.splice(4, 0, ["Password", a.password]);
214
222
  const dl = $("summary"); dl.innerHTML = "";
@@ -279,7 +287,7 @@
279
287
  $("regen").addEventListener("click", async () => { $("password").value = (await api("password")).password; });
280
288
  $("copyPass").addEventListener("click", async () => { try { await navigator.clipboard.writeText($("password").value); $("copyPass").textContent = "Copied"; setTimeout(() => { $("copyPass").textContent = "Copy"; }, 1400); } catch {} });
281
289
  $("launch").addEventListener("click", launch);
282
- show("ai");
290
+ show("welcome");
283
291
  pollJobs();
284
292
  }
285
293
  // A fresh setup link opened in this same tab only changes the #token, which
package/public/style.css CHANGED
@@ -838,3 +838,20 @@ body { height: 100dvh; }
838
838
  .rep-foot { justify-content: flex-end; }
839
839
  .rep-foot .ghostbtn { white-space: nowrap; }
840
840
  }
841
+
842
+ /* ── lobby extras: dashboard link (operators) and the journey preview (share links) ── */
843
+ .ob-section[hidden], .ob-dashlink[hidden] { display: none; }
844
+ .ob-dashlink { align-self: flex-start; display: inline-flex; align-items: center; gap: 5px; margin-bottom: 8px; padding: 5px 12px 5px 8px; border-radius: 99px; border: 1px solid var(--edge); background: var(--control); color: var(--ink2); font-size: 13px; font-weight: 500; text-decoration: none; transition: border-color .15s, color .15s; }
845
+ .ob-dashlink:hover { color: var(--ink); border-color: #C6C6D0; }
846
+ .ob-journey { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; counter-reset: j; }
847
+ .ob-journey li { counter-increment: j; display: flex; align-items: center; gap: 12px; font-size: 14.5px; }
848
+ .ob-journey li::before { content: counter(j); width: 26px; height: 26px; flex: none; display: grid; place-items: center; border-radius: 50%; font: 500 12px var(--mono); color: var(--amber); background: var(--brandtint); border: 1px solid var(--brandline); }
849
+ .ob-journey + .ob-divider { margin-top: 4px; }
850
+
851
+ /* ── share-link (customer) mode: the call without operator controls ── */
852
+ .customer #obAgent, .customer #obVoiceField, .customer #obSttField, .customer .ob-dashlink,
853
+ .customer #consolebtn, .customer #modelchip, .customer #agentlog, .customer .rep-right, .customer .rep-replay { display: none !important; }
854
+ .customer .ob-gear { grid-template-columns: 1fr; }
855
+ .customer .rep-left { flex: 1; }
856
+ .wf-nosteps .wf-head, .wf-nosteps .wf-steps { display: none; }
857
+ .ob-unavailable .ob-section, .ob-unavailable .ob-divider, .ob-unavailable .ob-actions, .ob-unavailable .ob-priv { display: none !important; }
package/server.mjs CHANGED
@@ -13,9 +13,12 @@ import { synth, TTS_ENABLED, providers, defaultProvider, warmKokoro, kokoroStrea
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
15
  import { platformsView, platformById, CUSTOM_PLAYBOOKS } from "./lib/platforms.mjs";
16
- import { AUTH_ENABLED, isAuthorized, challengeHeaders, authCookieHeader, clientIp, sameOrigin, limiter } from "./lib/guard.mjs";
16
+ import { AUTH_ENABLED, isAuthorized, currentUser, signIn, logoutCookie, authCookieHeader, clientIp, sameOrigin, limiter } from "./lib/guard.mjs";
17
+ import { hasUsers } from "./lib/team.mjs";
17
18
  import { emitEvent, WEBHOOKS_ENABLED } from "./lib/webhooks.mjs";
18
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";
19
22
  import { WebSocketServer } from "ws";
20
23
 
21
24
  // A stray rejection is logged, not fatal. An uncaught exception leaves the process
@@ -26,25 +29,33 @@ process.on("uncaughtException", (e) => { console.error("[uncaught exception]", e
26
29
  const __dir = dirname(fileURLToPath(import.meta.url));
27
30
  const PUBLIC = join(__dir, "public");
28
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";
29
35
 
30
36
  // Spend limits. Each session is capped in model spend; each client IP in request rate.
31
- 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
32
38
  const MAX_USER_TEXT = 2000;
33
39
  const MAX_FRAME_BYTES = 2 * 1024 * 1024; // a downscaled JPEG screen frame is ~100-300 KB
34
40
  const FRAME_RE = /^data:image\/(jpeg|png|webp);base64,[A-Za-z0-9+/=]+$/;
35
41
  const rate = {
36
- session: limiter("session", Number(process.env.RATE_SESSIONS_PER_MIN) || 10, 60_000), // new calls per IP per minute
37
- 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
38
44
  authFail: limiter("authFail", 20, 10 * 60_000), // wrong passwords per IP per 10 minutes
39
45
  };
40
46
 
41
47
  // A wrong password counts against the client; past the limit, stop checking.
42
48
  // (A first visit with no credentials at all is just the login prompt, not a guess.)
43
- 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 || ""));
44
50
  function lockedOut(req) { return triedPassword(req) && rate.authFail.blocked(clientIp(req)); }
45
- 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) {
46
53
  if (triedPassword(req)) rate.authFail(clientIp(req));
47
- 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." });
48
59
  }
49
60
  const LOCKED = "too many failed logins — try again in 10 minutes";
50
61
  const asrPerIp = new Map(); // ip → open /asr sockets
@@ -132,9 +143,27 @@ const CAP_MSG = "Ava has reached today's usage limit — try again tomorrow";
132
143
  /** Why a session can't spend more (its own budget, or the daily cap across all sessions), or null. */
133
144
  function spendLimit(sess) {
134
145
  if (!MOCK && dailyCapReached()) return CAP_MSG;
135
- if (sess.score.ours.cost >= SESSION_BUDGET_USD) return "this session reached its spend limit — start a new call";
146
+ if (sess.score.ours.cost >= sessionBudget()) return "this session reached its spend limit — start a new call";
136
147
  return null;
137
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
+
138
167
  const untilMidnightUtc = () => String(Math.ceil((new Date().setUTCHours(24, 0, 0, 0) - Date.now()) / 1000));
139
168
 
140
169
  async function serveStatic(req, res, pathname) {
@@ -181,10 +210,28 @@ async function serveStatic(req, res, pathname) {
181
210
  // Pages that open a call (and everything they talk to) sit behind ACCESS_PASSWORD
182
211
  // when it's set. The landing page and its assets stay public.
183
212
  function needsAuth(pathname) {
184
- 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"
185
215
  || pathname === "/app" || pathname === "/app/" || pathname === "/index.html"
186
- || pathname === "/specialist" || pathname === "/specialist.html";
216
+ || pathname === "/specialist" || pathname === "/specialist.html"
217
+ || pathname === "/dashboard" || pathname === "/dashboard.html";
218
+ }
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)));
187
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”.";
188
235
 
189
236
  // Aborts when the client goes away before we've answered (not after a normal finish).
190
237
  function clientGone(res) {
@@ -199,10 +246,12 @@ async function handle(req, res) {
199
246
  for (const [k, v] of Object.entries(SECURITY_HEADERS)) res.setHeader(k, v);
200
247
  let url;
201
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) };
202
251
  if (needsAuth(url.pathname)) {
203
252
  // Locked-out clients get no answer about any password, right or wrong.
204
253
  if (lockedOut(req)) return send(res, 429, LOCKED, { "Retry-After": "600" });
205
- if (!isAuthorized(req)) return denyAuth(req, res);
254
+ if (!operator && !(req.ava.links.length && CUSTOMER_API.has(url.pathname))) return denyAuth(req, res, url);
206
255
  }
207
256
  if (url.pathname.startsWith("/api/") && url.pathname !== "/api/health" && !rate.api(clientIp(req))) {
208
257
  return send(res, 429, { error: "too many requests — slow down" }, { "Retry-After": "30" });
@@ -227,7 +276,7 @@ async function handle(req, res) {
227
276
  // End-of-call recap for the session report.
228
277
  if (req.method === "POST" && url.pathname === "/api/summary") {
229
278
  const { sessionId } = await readBody(req);
230
- const sess = getSession(sessionId);
279
+ const sess = sessionFor(req, sessionId);
231
280
  if (!sess) return send(res, 404, { error: "unknown session" });
232
281
  try { return send(res, 200, await sessionSummary(sess)); }
233
282
  catch (e) { return upstreamFail(res, e, "summary"); }
@@ -236,7 +285,7 @@ async function handle(req, res) {
236
285
  // The user barged in — rewrite Ava's last reply to what was actually heard.
237
286
  if (req.method === "POST" && url.pathname === "/api/truncate") {
238
287
  const { sessionId, heard = "" } = await readBody(req);
239
- const sess = getSession(sessionId);
288
+ const sess = sessionFor(req, sessionId);
240
289
  if (!sess) return send(res, 404, { error: "unknown session" });
241
290
  return send(res, 200, truncateAssistant(sess, String(heard).slice(0, 2000)));
242
291
  }
@@ -298,20 +347,31 @@ async function handle(req, res) {
298
347
  if (!hasCapacity()) return send(res, 503, { error: "Ava is at capacity right now — try again in a few minutes" }, { "Retry-After": "60" });
299
348
  if (!MOCK && dailyCapReached()) return send(res, 503, { error: CAP_MSG }, { "Retry-After": untilMidnightUtc() });
300
349
  const body = await readBody(req);
301
- const mode = body.mode === "onboarding" ? "onboarding" : "flexible";
302
- const platform = typeof body.platformId === "string" ? platformById(body.platformId) : null; // knowledge in any mode
303
- 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 });
304
363
  if (!s) return send(res, 503, { error: "Ava is at capacity right now — try again in a few minutes" }, { "Retry-After": "60" });
305
- emitEvent("session.started", { sessionId: s.id, mode, platform: platform ? { id: platform.id, name: platform.name } : null });
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 });
306
366
  // pre-warm the talker route so the greeting isn't a cold start
307
367
  if (!MOCK) chat({ model: MODELS.talker, role: "talker", provider: PROVIDER.talker, messages: [{ role: "user", content: "hi" }], maxTokens: 1 }).catch(() => {});
308
- 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 });
309
369
  }
310
370
 
311
371
  if (req.method === "POST" && url.pathname === "/api/turn") {
312
372
  const body = await readBody(req);
313
373
  const { sessionId, frameHash = null, greet = false, voice = false, changed = false, screenActive = false } = body;
314
- const sess = getSession(sessionId);
374
+ const sess = sessionFor(req, sessionId);
315
375
  if (!sess) return send(res, 404, { error: "unknown session" });
316
376
  const limit = spendLimit(sess);
317
377
  if (limit) return send(res, 402, { error: limit });
@@ -333,7 +393,7 @@ async function handle(req, res) {
333
393
 
334
394
  if (req.method === "POST" && url.pathname === "/api/observe") {
335
395
  const { sessionId, frame: rawFrame, frameHash = null, stopped = false, changed = false } = await readBody(req);
336
- const sess = getSession(sessionId);
396
+ const sess = sessionFor(req, sessionId);
337
397
  if (!sess) return send(res, 404, { error: "unknown session" });
338
398
  if (stopped) return send(res, 200, screenOff(sess)); // user stopped sharing → mark state stale now
339
399
  if (spendLimit(sess)) return send(res, 402, { error: spendLimit(sess) });
@@ -343,31 +403,72 @@ async function handle(req, res) {
343
403
  catch (e) { return upstreamFail(res, e, "screen read"); }
344
404
  }
345
405
 
346
- // Specialist console page (opened at /specialist?key=<specialist key>).
347
- if (req.method === "GET" && url.pathname === "/specialist") {
348
- try {
349
- const full = join(PUBLIC, "specialist.html"), html = await readFile(full), st = await stat(full);
350
- 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) });
351
- }
352
- 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");
353
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 } : {}) });
449
+ }
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");
354
453
 
355
454
  // Live bridge between the user's call and a human specialist. The user is
356
455
  // identified by sessionId; the specialist only ever holds the specialist key
357
456
  // minted when the user asked for a person (revoked when the handoff ends).
358
457
  if (req.method === "GET" && url.pathname === "/api/bridge") {
359
458
  const key = url.searchParams.get("key");
360
- 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"));
361
461
  if (!sess) return send(res, 404, { error: "unknown session" });
362
462
  return send(res, 200, bridgePoll(sess, Number(url.searchParams.get("since")) || 0));
363
463
  }
364
464
  if (req.method === "POST" && url.pathname === "/api/bridge") {
365
465
  const { sessionId, key, action = "say", text = "" } = await readBody(req);
366
466
  if (action === "usersay") {
367
- const sess = getSession(sessionId);
467
+ const sess = sessionFor(req, sessionId);
368
468
  if (!sess) return send(res, 404, { error: "unknown session" });
369
469
  return send(res, 200, userSayToBridge(sess, String(text).slice(0, MAX_USER_TEXT)));
370
470
  }
471
+ if (!operator) return send(res, 401, { error: "specialists sign in with the team password" });
371
472
  const sess = getSessionBySpecialistKey(key);
372
473
  if (!sess) return send(res, 404, { error: "unknown or expired link" });
373
474
  if (action === "join") { const r = specialistJoin(sess); return r ? send(res, 200, r) : send(res, 409, { error: "no handoff requested" }); }
@@ -385,16 +486,17 @@ async function handle(req, res) {
385
486
  // Human handoff — request a real person, poll the connect state.
386
487
  if (req.method === "POST" && url.pathname === "/api/handoff") {
387
488
  const { sessionId, action = "request" } = await readBody(req);
388
- const sess = getSession(sessionId);
489
+ const sess = sessionFor(req, sessionId);
389
490
  if (!sess) return send(res, 404, { error: "unknown session" });
390
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." });
391
493
  if (action === "status") return send(res, 200, handoffStatus(sess));
392
494
  return send(res, 200, requestHandoff(sess));
393
495
  }
394
496
 
395
497
  if (req.method === "POST" && url.pathname === "/api/nudge") {
396
498
  const { sessionId } = await readBody(req);
397
- const sess = getSession(sessionId);
499
+ const sess = sessionFor(req, sessionId);
398
500
  if (!sess) return send(res, 404, { error: "unknown session" });
399
501
  if (spendLimit(sess)) return send(res, 402, { error: spendLimit(sess) });
400
502
  try { return send(res, 200, await proactiveNudge(sess)); }
@@ -402,7 +504,7 @@ async function handle(req, res) {
402
504
  }
403
505
 
404
506
  if (req.method === "GET" && url.pathname === "/api/scoreboard") {
405
- const sess = getSession(url.searchParams.get("sessionId"));
507
+ const sess = sessionFor(req, url.searchParams.get("sessionId"));
406
508
  if (!sess) return send(res, 404, { error: "unknown session" });
407
509
  return send(res, 200, { ours: scoreView(sess.score.ours) });
408
510
  }
@@ -435,7 +537,9 @@ server.on("upgrade", (req, socket, head) => {
435
537
  const reject = (code, msg) => { try { socket.write(`HTTP/1.1 ${code} ${msg}\r\nConnection: close\r\n\r\n`); } catch {} socket.destroy(); };
436
538
  if (!sameOrigin(req)) return reject(403, "Forbidden");
437
539
  if (lockedOut(req)) return reject(429, "Too Many Requests");
438
- 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 };
439
543
  if (path === "/asr") {
440
544
  const ip = clientIp(req);
441
545
  if (asrOpen >= MAX_ASR_TOTAL || (asrPerIp.get(ip) || 0) >= MAX_ASR_PER_IP) return reject(503, "Service Unavailable");
@@ -449,7 +553,8 @@ rtcWss.on("connection", (ws, req) => {
449
553
  const role = u.searchParams.get("role") === "specialist" ? "specialist" : "user";
450
554
  const other = role === "user" ? "specialist" : "user";
451
555
  // The user proves the call with its session id, the specialist with the handoff key.
452
- 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"));
453
558
  if (!sess) { try { ws.close(4404, "unknown session"); } catch {} return; }
454
559
  const session = sess.id;
455
560
  let room = rtcRooms.get(session);
@@ -491,9 +596,6 @@ wss.on("connection", (ws, req) => {
491
596
  ws.on("error", () => {});
492
597
  });
493
598
 
494
- // HOST=127.0.0.1 keeps Ava private to this computer (the CLI's "just me" setup).
495
- const HOST = process.env.HOST || undefined;
496
- const LOCAL_ONLY = HOST === "127.0.0.1" || HOST === "localhost" || HOST === "::1";
497
599
 
498
600
  server.on("error", (e) => {
499
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`);
@@ -504,7 +606,8 @@ server.on("error", (e) => {
504
606
 
505
607
  server.listen(PORT, HOST, () => {
506
608
  console.log(`\n Ava — screen-aware voice agent`);
507
- console.log(` → http://localhost:${PORT}/app`);
609
+ console.log(` → dashboard http://localhost:${PORT}/dashboard`);
610
+ console.log(` → test call http://localhost:${PORT}/app`);
508
611
  console.log(` mode: ${MOCK ? "MOCK (no OPENROUTER_API_KEY set — synthetic replies)" : "LIVE (OpenRouter)"}`);
509
612
  if (!MOCK) console.log(` models: talker=${MODELS.talker} perception=${MODELS.perception} planner=${MODELS.planner} intent=${MODELS.intent}`);
510
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)");
@@ -512,7 +615,7 @@ server.listen(PORT, HOST, () => {
512
615
  if (WEBHOOKS_ENABLED) console.log(" webhooks: on");
513
616
  if (LOCAL_ONLY) console.log(" access: this computer only");
514
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.");
515
- else console.log(" access: password protected");
618
+ else console.log(hasUsers() ? " access: team accounts (sign in at /login)" : " access: password protected");
516
619
  console.log("");
517
620
  warmKokoro(); // preload the offline voice so the first reply is fast
518
621
  warmAsr(); // preload the offline ear so the first utterance is fast