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/README.md +20 -2
- 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 +43 -3
- package/lib/cli/setup-server.mjs +3 -2
- package/lib/cli/setup.mjs +1 -1
- 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 +14 -4
- package/lib/models.mjs +10 -4
- package/lib/openrouter.mjs +4 -1
- package/lib/ours.mjs +3 -1
- package/lib/platforms.mjs +70 -8
- package/lib/playbooks.mjs +11 -2
- package/lib/safe-fetch.mjs +79 -0
- package/lib/settings.mjs +187 -0
- package/lib/spend.mjs +4 -1
- package/lib/state.mjs +35 -5
- package/lib/stt.mjs +4 -1
- package/lib/team.mjs +191 -0
- package/lib/tts.mjs +18 -11
- package/lib/webhooks.mjs +51 -7
- package/package.json +3 -3
- package/public/app.js +84 -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 +17 -10
- package/public/login.css +30 -0
- package/public/login.html +37 -0
- package/public/login.js +27 -0
- package/public/setup.css +138 -57
- package/public/setup.html +40 -19
- package/public/setup.js +15 -7
- package/public/style.css +17 -0
- package/server.mjs +142 -39
package/lib/playbooks.mjs
CHANGED
|
@@ -16,7 +16,7 @@ export const PLAYBOOKS_DIR = home(process.env.PLAYBOOKS_DIR || join(process.env.
|
|
|
16
16
|
|
|
17
17
|
const ID = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
18
18
|
const HEX = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
|
|
19
|
-
const KNOWN = new Set(["id", "name", "url", "category", "accent", "tagline", "firstWin", "path", "optional", "journey", "etaMin", "knowledge", "demoOnly", "$schema"]);
|
|
19
|
+
const KNOWN = new Set(["id", "name", "url", "category", "accent", "tagline", "firstWin", "path", "optional", "journey", "etaMin", "knowledge", "demoOnly", "author", "origin", "$schema"]);
|
|
20
20
|
|
|
21
21
|
/** Starter file for `ava-engine playbooks new <id>`. */
|
|
22
22
|
export function template(id) {
|
|
@@ -61,6 +61,14 @@ export function validatePlaybook(raw, fallbackId) {
|
|
|
61
61
|
if (raw.path !== undefined && !strList(raw.path, 12, 400)) errors.push(`"path" must be a list of up to 12 how-to lines (up to 400 characters each)`);
|
|
62
62
|
if (raw.etaMin !== undefined && !(Number.isInteger(raw.etaMin) && raw.etaMin >= 1 && raw.etaMin <= 120)) errors.push(`"etaMin" must be a whole number of minutes from 1 to 120`);
|
|
63
63
|
if (raw.demoOnly !== undefined && typeof raw.demoOnly !== "boolean") errors.push(`"demoOnly" must be true or false`);
|
|
64
|
+
if (raw.author !== undefined && !str(raw.author, 80)) errors.push(`"author" must be a name (up to 80 characters)`);
|
|
65
|
+
// origin: where an installed copy came from ({ library, sha256, installedAt }), kept so updates can be offered
|
|
66
|
+
let origin;
|
|
67
|
+
if (raw.origin !== undefined) {
|
|
68
|
+
const o = raw.origin;
|
|
69
|
+
if (!o || typeof o !== "object" || !str(o.library, 300) || !/^[0-9a-f]{64}$/.test(String(o.sha256 || ""))) errors.push(`"origin" is set by the library installer; remove it if you edited this by hand`);
|
|
70
|
+
else origin = { library: o.library, sha256: o.sha256, installedAt: typeof o.installedAt === "string" ? o.installedAt.slice(0, 40) : undefined };
|
|
71
|
+
}
|
|
64
72
|
let knowledge = [];
|
|
65
73
|
if (raw.knowledge !== undefined) {
|
|
66
74
|
if (!Array.isArray(raw.knowledge) || raw.knowledge.length > 200) errors.push(`"knowledge" must be a list of up to 200 entries`);
|
|
@@ -73,12 +81,13 @@ export function validatePlaybook(raw, fallbackId) {
|
|
|
73
81
|
}
|
|
74
82
|
for (const k of Object.keys(raw)) if (!KNOWN.has(k)) warnings.push(`unknown field "${k}" is ignored`);
|
|
75
83
|
if (errors.length) return { errors, warnings };
|
|
76
|
-
const { name, url, category, accent, tagline, firstWin, optional, etaMin, demoOnly } = raw;
|
|
84
|
+
const { name, url, category, accent, tagline, firstWin, optional, etaMin, demoOnly, author } = raw;
|
|
77
85
|
return {
|
|
78
86
|
warnings,
|
|
79
87
|
playbook: {
|
|
80
88
|
id, name, url, category: category || "Your product", accent, tagline: tagline || "", firstWin: firstWin || "the first step done",
|
|
81
89
|
path: raw.path || [], optional, journey: raw.journey, etaMin: etaMin || null, knowledge, ...(demoOnly ? { demoOnly: true } : {}),
|
|
90
|
+
...(author ? { author } : {}), ...(origin ? { origin } : {}),
|
|
82
91
|
},
|
|
83
92
|
};
|
|
84
93
|
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Fetch a public web page for "Draft with AI", and nothing else.
|
|
2
|
+
//
|
|
3
|
+
// The address comes from a dashboard user, so the server must not become a way
|
|
4
|
+
// to read private networks (cloud metadata at 169.254.169.254, localhost admin
|
|
5
|
+
// pages, the office LAN). Every connection, including each redirect, is checked
|
|
6
|
+
// at the moment it's made: the DNS answer is filtered in the socket's own lookup,
|
|
7
|
+
// so a name can't resolve to a public address for the check and a private one
|
|
8
|
+
// for the request. Small, slow-capped and text only.
|
|
9
|
+
|
|
10
|
+
import http from "node:http";
|
|
11
|
+
import https from "node:https";
|
|
12
|
+
import dns from "node:dns";
|
|
13
|
+
import net from "node:net";
|
|
14
|
+
|
|
15
|
+
const MAX_BYTES = 2 * 1024 * 1024, TIMEOUT_MS = 12_000, MAX_REDIRECTS = 3;
|
|
16
|
+
|
|
17
|
+
/** True for addresses a public fetch must never reach. */
|
|
18
|
+
export function isPrivateAddress(ip) {
|
|
19
|
+
if (net.isIPv4(ip)) {
|
|
20
|
+
const [a, b] = ip.split(".").map(Number);
|
|
21
|
+
return a === 0 || a === 10 || a === 127 || (a === 100 && b >= 64 && b <= 127) || (a === 169 && b === 254)
|
|
22
|
+
|| (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 192 && b === 0) || (a === 198 && (b === 18 || b === 19)) || a >= 224;
|
|
23
|
+
}
|
|
24
|
+
const v = ip.toLowerCase();
|
|
25
|
+
if (v.startsWith("::ffff:")) return isPrivateAddress(v.slice(7));
|
|
26
|
+
return v === "::" || v === "::1" || /^f[cd]/.test(v) || /^fe[89ab]/.test(v) || v.startsWith("ff");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function publicLookup(host, opts, cb) {
|
|
30
|
+
dns.lookup(host, { all: true }, (err, list) => {
|
|
31
|
+
if (err) return cb(err);
|
|
32
|
+
const ok = list.find((a) => !isPrivateAddress(a.address));
|
|
33
|
+
if (!ok || list.some((a) => isPrivateAddress(a.address))) return cb(Object.assign(new Error("That address points to a private network."), { code: "EPRIVATE" }));
|
|
34
|
+
opts?.all ? cb(null, [ok]) : cb(null, ok.address, ok.family);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function once(url) {
|
|
39
|
+
return new Promise((resolve, reject) => {
|
|
40
|
+
const lib = url.protocol === "https:" ? https : http;
|
|
41
|
+
if (net.isIP(url.hostname.replace(/^\[|\]$/g, "")) && isPrivateAddress(url.hostname.replace(/^\[|\]$/g, ""))) return reject(new Error("That address points to a private network."));
|
|
42
|
+
const req = lib.get(url, { lookup: publicLookup, timeout: TIMEOUT_MS, headers: { "User-Agent": "ava-engine (playbook drafting)", Accept: "text/html,text/plain,text/markdown;q=0.9,*/*;q=0.1" } }, (res) => {
|
|
43
|
+
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { res.resume(); return resolve({ redirect: new URL(res.headers.location, url) }); }
|
|
44
|
+
if (res.statusCode !== 200) { res.resume(); return reject(new Error(`The page answered ${res.statusCode}.`)); }
|
|
45
|
+
const type = String(res.headers["content-type"] || "");
|
|
46
|
+
if (!/text\/|application\/(xhtml|json|xml)/.test(type)) { res.resume(); return reject(new Error("That address isn't a web page or text.")); }
|
|
47
|
+
let size = 0; const chunks = [];
|
|
48
|
+
res.on("data", (c) => { size += c.length; if (size > MAX_BYTES) { req.destroy(new Error("That page is too large (over 2 MB).")); } else chunks.push(c); });
|
|
49
|
+
res.on("end", () => resolve({ body: Buffer.concat(chunks).toString("utf8"), type }));
|
|
50
|
+
res.on("error", reject);
|
|
51
|
+
});
|
|
52
|
+
req.on("timeout", () => req.destroy(new Error("The page took too long to answer.")));
|
|
53
|
+
req.on("error", reject);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** GET a public http(s) page. Returns { text, type, url }. */
|
|
58
|
+
export async function fetchPublic(address) {
|
|
59
|
+
let url;
|
|
60
|
+
try { url = new URL(address); } catch { throw new Error("That isn't a valid web address."); }
|
|
61
|
+
for (let i = 0; i <= MAX_REDIRECTS; i++) {
|
|
62
|
+
if (!/^https?:$/.test(url.protocol)) throw new Error("Only http:// and https:// addresses can be read.");
|
|
63
|
+
const r = await once(url);
|
|
64
|
+
if (r.redirect) { url = r.redirect; continue; }
|
|
65
|
+
return { text: r.body, type: r.type, url: url.href };
|
|
66
|
+
}
|
|
67
|
+
throw new Error("That page redirects too many times.");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Readable text from HTML: headings and paragraphs, without scripts, styles and navigation. */
|
|
71
|
+
export function htmlToText(html) {
|
|
72
|
+
return String(html)
|
|
73
|
+
.replace(/<(script|style|noscript|svg|nav|footer|header|form|iframe)\b[\s\S]*?<\/\1>/gi, " ")
|
|
74
|
+
.replace(/<(h[1-6])\b[^>]*>/gi, "\n\n## ").replace(/<(br|\/p|\/li|\/h[1-6]|\/tr|\/div)\b[^>]*>/gi, "\n").replace(/<li\b[^>]*>/gi, "\n- ")
|
|
75
|
+
.replace(/<[^>]+>/g, " ")
|
|
76
|
+
.replace(/ /g, " ").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'|'/g, "'")
|
|
77
|
+
.replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n)))
|
|
78
|
+
.replace(/[ \t\f\r]+/g, " ").replace(/ *\n */g, "\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
79
|
+
}
|
package/lib/settings.mjs
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// Settings you can change from the dashboard, applied without a restart.
|
|
2
|
+
//
|
|
3
|
+
// Ava's settings are environment variables, saved in a .env file: the CLI's
|
|
4
|
+
// ~/.ava-engine/.env (AVA_CONFIG_FILE), or ./.env when run from source. Saving
|
|
5
|
+
// here edits that file in place (other lines and comments are kept), updates
|
|
6
|
+
// the running process, and tells every module that caches a value to reload.
|
|
7
|
+
//
|
|
8
|
+
// A variable set by the host itself (Fly secrets, Docker -e, the shell) wins over
|
|
9
|
+
// the file on the next start, so the dashboard shows those as read-only.
|
|
10
|
+
|
|
11
|
+
import { existsSync, readFileSync, renameSync, writeFileSync, chmodSync, mkdirSync } from "node:fs";
|
|
12
|
+
import { dirname, join, resolve } from "node:path";
|
|
13
|
+
|
|
14
|
+
export const CONFIG_PATH = process.env.AVA_CONFIG_FILE || resolve(".env");
|
|
15
|
+
|
|
16
|
+
const SLUG = /^[\w.-]+\/[\w.:-]+$/;
|
|
17
|
+
const num = (min, max, int = false) => ({ type: "number", min, max, int });
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Every editable setting. `secret`: never sent to the browser. `restart`: saved,
|
|
21
|
+
* but only takes effect after Ava restarts. `section` groups them on the page.
|
|
22
|
+
*/
|
|
23
|
+
export const SCHEMA = [
|
|
24
|
+
// AI
|
|
25
|
+
{ key: "OPENROUTER_API_KEY", section: "ai", label: "OpenRouter API key", type: "secret", min: 20, hint: "Empty = demo mode with canned replies." },
|
|
26
|
+
{ key: "MODEL_TALKER", section: "ai", label: "Talker model", type: "slug", def: "meta-llama/llama-3.3-70b-instruct", hint: "Writes the spoken reply. Latency matters most here." },
|
|
27
|
+
{ key: "MODEL_PERCEPTION", section: "ai", label: "Screen-reading model", type: "slug", def: "google/gemini-2.5-flash", hint: "Must accept images." },
|
|
28
|
+
{ key: "MODEL_INTENT", section: "ai", label: "Intent model", type: "slug", def: "google/gemini-2.5-flash-lite", hint: "Classifies turns and checks workflow steps." },
|
|
29
|
+
{ key: "MODEL_PLANNER", section: "ai", label: "Planner model", type: "slug", def: "anthropic/claude-sonnet-4.5", hint: "Keeps the goal and plan. Runs after the reply." },
|
|
30
|
+
{ key: "TALKER_PROVIDER", section: "ai", label: "Talker provider", type: "provider", def: "groq", hint: "An OpenRouter provider to prefer, or auto." },
|
|
31
|
+
{ key: "PERCEPTION_PROVIDER", section: "ai", label: "Screen-reading provider", type: "provider", def: "", hint: "Optional. Empty or auto lets OpenRouter choose." },
|
|
32
|
+
// voice
|
|
33
|
+
{ key: "TTS_DEFAULT", section: "voice", label: "Ava's voice", type: "enum", options: ["kokoro", "elevenlabs", "hume", "browser"], def: "kokoro" },
|
|
34
|
+
{ key: "ELEVENLABS_API_KEY", section: "voice", label: "ElevenLabs API key", type: "secret", min: 20 },
|
|
35
|
+
{ key: "ELEVEN_VOICE_ID", section: "voice", label: "ElevenLabs voice ID", type: "text", max: 64, def: "XrExE9yKIg1WjnnlVkGX" },
|
|
36
|
+
{ key: "HUME_API_KEY", section: "voice", label: "Hume API key", type: "secret", min: 20 },
|
|
37
|
+
{ key: "KOKORO_VOICE", section: "voice", label: "Kokoro voice", type: "text", max: 32, def: "af_heart", hint: "e.g. af_heart, af_bella, am_michael, bf_emma." },
|
|
38
|
+
{ key: "KOKORO", section: "voice", label: "Offline Kokoro voice", type: "enum", options: ["on", "off"], def: "on", restart: true },
|
|
39
|
+
{ key: "DEEPGRAM_API_KEY", section: "voice", label: "Deepgram API key", type: "secret", min: 20, hint: "Cloud speech-to-text, used only without offline models." },
|
|
40
|
+
// access
|
|
41
|
+
{ key: "ACCESS_PASSWORD", section: "access", label: "Access password", type: "secret", min: 10, hint: "Needed for the dashboard and test page from other computers. Share links don't need it." },
|
|
42
|
+
{ key: "PUBLIC_URL", section: "access", label: "Public address", type: "url", def: "", hint: "Where customers reach Ava, e.g. https://ava.example.com. Used in share links and alerts." },
|
|
43
|
+
{ key: "HOST", section: "access", label: "Listen on", type: "enum", options: ["127.0.0.1", "0.0.0.0"], def: "0.0.0.0", restart: true, hint: "127.0.0.1 = this computer only." },
|
|
44
|
+
{ key: "PORT", section: "access", label: "Port", ...num(1, 65535, true), def: "5190", restart: true },
|
|
45
|
+
{ key: "TRUST_PROXY", section: "access", label: "Behind a proxy", type: "enum", options: ["1", "0"], def: "0", restart: true, hint: "1 when Fly, Cloudflare or nginx sits in front." },
|
|
46
|
+
// limits
|
|
47
|
+
{ key: "SESSION_BUDGET_USD", section: "limits", label: "Spend cap per call (USD)", ...num(0.01, 1000), def: "2" },
|
|
48
|
+
{ key: "DAILY_BUDGET_USD", section: "limits", label: "Spend cap per day (USD)", ...num(0, 1_000_000), def: "", hint: "All calls together. Empty or 0 = no cap." },
|
|
49
|
+
{ key: "MAX_SESSIONS", section: "limits", label: "Calls at once", ...num(1, 10_000, true), def: "50" },
|
|
50
|
+
{ key: "RATE_SESSIONS_PER_MIN", section: "limits", label: "New calls per visitor per minute", ...num(1, 10_000, true), def: "10" },
|
|
51
|
+
{ key: "RATE_API_PER_MIN", section: "limits", label: "Requests per visitor per minute", ...num(10, 100_000, true), def: "240" },
|
|
52
|
+
// events
|
|
53
|
+
{ key: "WEBHOOK_URL", section: "events", label: "Webhook URLs", type: "urls", secret: true, hint: "Comma-separated. Every call event is POSTed here as JSON." },
|
|
54
|
+
{ key: "WEBHOOK_SECRET", section: "events", label: "Webhook signing secret", type: "secret", min: 16 },
|
|
55
|
+
{ key: "SLACK_WEBHOOK_URL", section: "events", label: "Slack incoming webhook", type: "url", secret: true, hint: "Posts when a customer asks for a person." },
|
|
56
|
+
{ key: "SLACK_BOT_TOKEN", section: "events", label: "Slack bot token", type: "secret", min: 20, hint: "Optional, instead of the webhook: one thread per handoff with updates. Needs a bot with chat:write in the channel." },
|
|
57
|
+
{ key: "SLACK_CHANNEL", section: "events", label: "Slack channel", type: "text", max: 80, hint: "The channel ID for the bot, e.g. C0123ABCD." },
|
|
58
|
+
// data
|
|
59
|
+
{ key: "TRANSCRIPTS", section: "data", label: "Keep call transcripts", type: "enum", options: ["off", "on"], def: "off", hint: "Off: only numbers are kept. On: what was said is saved for the call detail page." },
|
|
60
|
+
{ key: "TRANSCRIPT_DAYS", section: "data", label: "Delete transcripts after (days)", ...num(1, 3650, true), def: "30" },
|
|
61
|
+
// playbooks
|
|
62
|
+
{ key: "PLAYBOOK_LIBRARY_URL", section: "playbooks", label: "Playbook library", type: "url", def: "https://raw.githubusercontent.com/Mr-vero/ava-playbooks/main", hint: "Where Playbooks → Library comes from. Point it at your own fork to curate playbooks for your team." },
|
|
63
|
+
{ key: "BUILTIN_PLAYBOOKS", section: "playbooks", label: "Sample products", type: "enum", options: ["on", "off"], def: "on", hint: "Off hides the built-in samples from the test page." },
|
|
64
|
+
];
|
|
65
|
+
const BY_KEY = new Map(SCHEMA.map((s) => [s.key, s]));
|
|
66
|
+
const isSecret = (s) => s.type === "secret" || s.secret;
|
|
67
|
+
|
|
68
|
+
// ── the .env file ──
|
|
69
|
+
function parseEnv(text) {
|
|
70
|
+
const out = {};
|
|
71
|
+
for (const line of text.split(/\r?\n/)) {
|
|
72
|
+
const m = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
|
|
73
|
+
if (!m) continue;
|
|
74
|
+
let v = m[2];
|
|
75
|
+
if (/^"(.*)"$/.test(v)) v = v.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, "\\");
|
|
76
|
+
else if (/^'(.*)'$/.test(v)) v = v.slice(1, -1);
|
|
77
|
+
else v = v.replace(/\s+#.*$/, "");
|
|
78
|
+
out[m[1]] = v;
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
function readFile() { try { return parseEnv(readFileSync(CONFIG_PATH, "utf8")); } catch { return {}; } }
|
|
83
|
+
/** A value as it must appear in .env, readable by both Node's --env-file and the CLI. */
|
|
84
|
+
function envValue(v) {
|
|
85
|
+
if (/^[A-Za-z0-9_@%+=:,./-]*$/.test(v)) return v;
|
|
86
|
+
if (!v.includes("'")) return `'${v}'`;
|
|
87
|
+
if (!v.includes('"') && !v.includes("\\")) return `"${v}"`;
|
|
88
|
+
throw new Error("Values can't contain both kinds of quotes.");
|
|
89
|
+
}
|
|
90
|
+
function writeFile(patch) {
|
|
91
|
+
const lines = existsSync(CONFIG_PATH) ? readFileSync(CONFIG_PATH, "utf8").split(/\r?\n/) : ["# Ava settings. Edit here or in the dashboard (Settings)."];
|
|
92
|
+
for (const [k, v] of Object.entries(patch)) {
|
|
93
|
+
const re = new RegExp(`^\\s*(?:export\\s+)?${k}\\s*=`);
|
|
94
|
+
const i = lines.findIndex((l) => re.test(l));
|
|
95
|
+
if (v === "") { if (i >= 0) lines.splice(i, 1); continue; }
|
|
96
|
+
const line = `${k}=${envValue(v)}`;
|
|
97
|
+
if (i >= 0) lines[i] = line; else lines.splice(lines.length && lines.at(-1) === "" ? lines.length - 1 : lines.length, 0, line);
|
|
98
|
+
}
|
|
99
|
+
mkdirSync(dirname(CONFIG_PATH), { recursive: true });
|
|
100
|
+
const tmp = join(dirname(CONFIG_PATH), `.env.${process.pid}.tmp`);
|
|
101
|
+
writeFileSync(tmp, lines.join("\n").replace(/\n*$/, "\n"), { mode: 0o600 });
|
|
102
|
+
renameSync(tmp, CONFIG_PATH);
|
|
103
|
+
try { chmodSync(CONFIG_PATH, 0o600); } catch {}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Variables the host set itself (not from our file) win on the next start: read-only here.
|
|
107
|
+
const fileAtStart = readFile();
|
|
108
|
+
const hostSet = new Set(SCHEMA.map((s) => s.key).filter((k) => process.env[k] !== undefined && process.env[k] !== fileAtStart[k]));
|
|
109
|
+
|
|
110
|
+
// ── validation ──
|
|
111
|
+
function check(s, raw) {
|
|
112
|
+
const v = String(raw ?? "").trim();
|
|
113
|
+
if (v === "") return { value: "" }; // empty = back to the default / turn off
|
|
114
|
+
if (/[\r\n\0]/.test(v)) return { error: `${s.label}: must be one line.` };
|
|
115
|
+
switch (s.type) {
|
|
116
|
+
case "secret": return v.length < (s.min || 1) ? { error: `${s.label}: at least ${s.min} characters.` } : v.length > 500 ? { error: `${s.label}: too long.` } : { value: v };
|
|
117
|
+
case "slug": return SLUG.test(v) ? { value: v } : { error: `${s.label}: use an OpenRouter model id like provider/model-name.` };
|
|
118
|
+
case "provider": return /^[a-z0-9][a-z0-9_.-]{0,40}$/i.test(v) ? { value: v } : { error: `${s.label}: a provider name like groq, or auto.` };
|
|
119
|
+
case "enum": return s.options.includes(v) ? { value: v } : { error: `${s.label}: choose one of ${s.options.join(", ")}.` };
|
|
120
|
+
case "text": return v.length <= (s.max || 200) && /^[\w.: -]+$/.test(v) ? { value: v } : { error: `${s.label}: letters, digits, dashes and dots only.` };
|
|
121
|
+
case "url": try { const u = new URL(v); return /^https?:$/.test(u.protocol) ? { value: v.replace(/\/+$/, "") } : { error: `${s.label}: must start with http:// or https://.` }; } catch { return { error: `${s.label}: isn't a valid web address.` }; }
|
|
122
|
+
case "urls": {
|
|
123
|
+
const list = v.split(",").map((x) => x.trim()).filter(Boolean);
|
|
124
|
+
for (const u of list) { try { if (!/^https?:$/.test(new URL(u).protocol)) throw 0; } catch { return { error: `${s.label}: “${u}” isn't a valid http(s) address.` }; } }
|
|
125
|
+
return { value: list.join(",") };
|
|
126
|
+
}
|
|
127
|
+
case "number": {
|
|
128
|
+
const n = Number(v);
|
|
129
|
+
if (!Number.isFinite(n) || n < s.min || n > s.max || (s.int && !Number.isInteger(n))) return { error: `${s.label}: ${s.int ? "a whole number" : "a number"} from ${s.min} to ${s.max}.` };
|
|
130
|
+
return { value: String(n) };
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return { error: `${s.label}: unsupported.` };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ── what the dashboard sees ──
|
|
137
|
+
function mask(k, v) {
|
|
138
|
+
if (!v) return "";
|
|
139
|
+
if (k.endsWith("_URL")) return v.split(",").map((u) => { try { return `${new URL(u).host}/…`; } catch { return "…"; } }).join(", ");
|
|
140
|
+
return v.length > 12 ? `${v.slice(0, 6)}…${v.slice(-4)}` : "••••••";
|
|
141
|
+
}
|
|
142
|
+
/** Every setting, with secrets masked, for the Settings page. */
|
|
143
|
+
export function settingsView() {
|
|
144
|
+
return {
|
|
145
|
+
file: CONFIG_PATH,
|
|
146
|
+
settings: SCHEMA.map((s) => {
|
|
147
|
+
const v = process.env[s.key] ?? "";
|
|
148
|
+
return {
|
|
149
|
+
key: s.key, section: s.section, label: s.label, type: s.type, options: s.options, hint: s.hint, min: s.min, max: s.max,
|
|
150
|
+
default: s.def ?? "", secret: isSecret(s), restart: !!s.restart, set: v !== "",
|
|
151
|
+
value: isSecret(s) ? mask(s.key, v) : v,
|
|
152
|
+
locked: hostSet.has(s.key), pending: !!s.restart && pendingRestart.has(s.key),
|
|
153
|
+
};
|
|
154
|
+
}),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ── saving ──
|
|
159
|
+
const listeners = [];
|
|
160
|
+
const pendingRestart = new Set();
|
|
161
|
+
/** Run fn(changedKeys) after every save, so cached values reload. */
|
|
162
|
+
export function onSettingsChange(fn) { listeners.push(fn); }
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Save changes: { KEY: "value" } ("" removes it). Secrets are only changed when
|
|
166
|
+
* sent. Returns { changed, restart } or { errors }.
|
|
167
|
+
*/
|
|
168
|
+
export function updateSettings(patch) {
|
|
169
|
+
const errors = [], clean = {};
|
|
170
|
+
for (const [k, raw] of Object.entries(patch || {})) {
|
|
171
|
+
const s = BY_KEY.get(k);
|
|
172
|
+
if (!s) { errors.push(`Unknown setting ${k}.`); continue; }
|
|
173
|
+
if (hostSet.has(k)) { errors.push(`${s.label} is set by your host's environment; change it there.`); continue; }
|
|
174
|
+
const r = check(s, raw);
|
|
175
|
+
if (r.error) errors.push(r.error); else if ((process.env[k] ?? "") !== r.value) clean[k] = r.value;
|
|
176
|
+
}
|
|
177
|
+
if (errors.length) return { errors };
|
|
178
|
+
const keys = Object.keys(clean);
|
|
179
|
+
if (!keys.length) return { changed: [], restart: [] };
|
|
180
|
+
writeFile(clean);
|
|
181
|
+
for (const k of keys) { if (clean[k] === "") delete process.env[k]; else process.env[k] = clean[k]; }
|
|
182
|
+
const restart = keys.filter((k) => BY_KEY.get(k).restart);
|
|
183
|
+
for (const k of restart) pendingRestart.add(k);
|
|
184
|
+
for (const fn of listeners) { try { fn(new Set(keys)); } catch (e) { console.error(" [settings] reload failed:", e.message); } }
|
|
185
|
+
console.log(` [settings] updated ${keys.join(", ")}${restart.length ? " (restart to apply " + restart.join(", ") + ")" : ""}`);
|
|
186
|
+
return { changed: keys, restart };
|
|
187
|
+
}
|
package/lib/spend.mjs
CHANGED
|
@@ -13,7 +13,10 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
13
13
|
import { homedir } from "node:os";
|
|
14
14
|
import { dirname, join } from "node:path";
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
import { onSettingsChange } from "./settings.mjs";
|
|
17
|
+
|
|
18
|
+
export let DAILY_BUDGET_USD = Math.max(0, Number(process.env.DAILY_BUDGET_USD) || 0);
|
|
19
|
+
onSettingsChange((k) => { if (k.has("DAILY_BUDGET_USD")) DAILY_BUDGET_USD = Math.max(0, Number(process.env.DAILY_BUDGET_USD) || 0); });
|
|
17
20
|
const FILE = process.env.SPEND_FILE || join(process.env.AVA_HOME || join(homedir(), ".ava-engine"), "spend.json");
|
|
18
21
|
|
|
19
22
|
const today = () => new Date().toISOString().slice(0, 10);
|
package/lib/state.mjs
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
4
|
import { emitEvent } from "./webhooks.mjs";
|
|
5
|
+
import { recordCall, saveTranscript } from "./calls.mjs";
|
|
5
6
|
|
|
6
7
|
const sessions = new Map();
|
|
7
8
|
const byKey = new Map(); // specialist key → session id (see mintSpecialistKey)
|
|
@@ -10,7 +11,7 @@ function emptyScore() {
|
|
|
10
11
|
return { turns: 0, promptTokens: 0, completionTokens: 0, cost: 0, latencies: [], perception: { captured: 0, analysed: 0 } };
|
|
11
12
|
}
|
|
12
13
|
|
|
13
|
-
const
|
|
14
|
+
const maxSessions = () => Number(process.env.MAX_SESSIONS) || 50; // read live: editable in Settings
|
|
14
15
|
const MAX_AGE_MS = 6 * 3600 * 1000;
|
|
15
16
|
const IDLE_MS = 30 * 60 * 1000;
|
|
16
17
|
|
|
@@ -19,20 +20,47 @@ function drop(id, reason) {
|
|
|
19
20
|
if (!s) return;
|
|
20
21
|
if (s.specialistKey) revokeSpecialistKey(s);
|
|
21
22
|
sessions.delete(id);
|
|
23
|
+
const c = callSummary(s);
|
|
24
|
+
emitEvent("session.ended", { sessionId: id, reason, ...c });
|
|
25
|
+
const wf = s.ours.workflow;
|
|
26
|
+
const transcript = saveTranscript(id, {
|
|
27
|
+
lines: (s.ours.bridge?.log || []).map((l) => ({ role: l.role, text: l.text, at: new Date(l.at).toISOString() })),
|
|
28
|
+
recap: s.recap || null,
|
|
29
|
+
});
|
|
30
|
+
recordCall({
|
|
31
|
+
id, reason, startedAt: new Date(s.createdAt).toISOString(), endedAt: new Date().toISOString(), ...c,
|
|
32
|
+
steps: wf ? wf.steps.map((x) => ({ title: x.title, done: x.status === "done" })) : null, // product data, not what was said
|
|
33
|
+
transcript,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The numbers about a call: what the dashboard shows and webhooks send. No transcript. */
|
|
38
|
+
function callSummary(s) {
|
|
22
39
|
const o = s.ours, wf = o.workflow;
|
|
23
|
-
|
|
24
|
-
|
|
40
|
+
return {
|
|
41
|
+
mode: o.mode, platform: o.platform ? { id: o.platform.id, name: o.platform.name } : null,
|
|
42
|
+
link: s.link ? { id: s.link.id, name: s.link.name } : null,
|
|
25
43
|
durationSec: Math.round((Date.now() - s.createdAt) / 1000), turns: s.score.ours.turns, costUsd: Math.round(s.score.ours.cost * 1e4) / 1e4,
|
|
26
44
|
workflow: wf ? { title: wf.title, done: wf.steps.filter((x) => x.status === "done").length, total: wf.steps.length, completed: !!wf.complete } : null,
|
|
27
45
|
handoffRequested: !!o.handoff?.requested,
|
|
28
|
-
}
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Calls happening now, for the dashboard. */
|
|
50
|
+
export function liveSessions() {
|
|
51
|
+
return [...sessions.values()].map((s) => ({
|
|
52
|
+
id: s.id, startedAt: new Date(s.createdAt).toISOString(), lastActiveAt: new Date(s.lastActive).toISOString(),
|
|
53
|
+
...callSummary(s),
|
|
54
|
+
currentStep: s.ours.workflow ? s.ours.workflow.steps[s.ours.workflow.current]?.title || null : null,
|
|
55
|
+
handoff: s.ours.handoff?.requested ? { state: s.ours.handoff.state, since: s.ours.handoff.at ? new Date(s.ours.handoff.at).toISOString() : null, specialistKey: s.specialistKey, pickedUpBy: s.ours.handoff.pickedUpBy || null } : null,
|
|
56
|
+
})).sort((a, b) => b.startedAt.localeCompare(a.startedAt));
|
|
29
57
|
}
|
|
30
58
|
|
|
31
59
|
/** Room for one more session? Expired and idle sessions are pruned first. */
|
|
32
60
|
export function hasCapacity() {
|
|
33
61
|
const now = Date.now();
|
|
34
62
|
for (const [id, s] of sessions) { if (now - s.createdAt > MAX_AGE_MS || now - s.lastActive > IDLE_MS) drop(id, "expired"); }
|
|
35
|
-
return sessions.size <
|
|
63
|
+
return sessions.size < maxSessions();
|
|
36
64
|
}
|
|
37
65
|
|
|
38
66
|
export function newSession(opts = {}) {
|
|
@@ -47,8 +75,10 @@ export function newSession(opts = {}) {
|
|
|
47
75
|
createdAt: Date.now(),
|
|
48
76
|
lastActive: Date.now(),
|
|
49
77
|
specialistKey: null, // capability for the human-specialist console; minted on handoff
|
|
78
|
+
link: opts.link ? { id: opts.link.id, name: opts.link.name } : null, // the share link this call came from
|
|
50
79
|
ours: {
|
|
51
80
|
sessionId: id, // for the events this session's agents report (webhooks)
|
|
81
|
+
customer: !!opts.link, // a customer on a share link (not the operator testing)
|
|
52
82
|
mode, // "flexible" | "onboarding"
|
|
53
83
|
platform, // onboarding target {id,name,url,firstWin,path,...} or null
|
|
54
84
|
screenState: null, // last structured screen state {app,page,elements,state,v}
|
package/lib/stt.mjs
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
// Deepgram speech-to-text (nova-3): the cloud fallback that turns a recorded
|
|
2
2
|
// utterance into text when the offline sherpa models aren't available.
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
import { onSettingsChange } from "./settings.mjs";
|
|
5
|
+
|
|
6
|
+
export let STT_ENABLED = !!process.env.DEEPGRAM_API_KEY;
|
|
7
|
+
onSettingsChange((k) => { if (k.has("DEEPGRAM_API_KEY")) STT_ENABLED = !!process.env.DEEPGRAM_API_KEY; });
|
|
5
8
|
const MODEL = process.env.DEEPGRAM_MODEL || "nova-3";
|
|
6
9
|
|
|
7
10
|
// buffer: raw audio bytes; contentType e.g. "audio/webm" or "audio/mpeg".
|
package/lib/team.mjs
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// Team accounts: who can sign in to the dashboard, and what each person may do.
|
|
2
|
+
//
|
|
3
|
+
// owner everything, including Settings and the team
|
|
4
|
+
// editor share links, playbooks and calls
|
|
5
|
+
// specialist the inbox and calls: picks up customers who asked for a person
|
|
6
|
+
//
|
|
7
|
+
// Accounts live in USERS_FILE (default ~/.ava-engine/users.json, mode 600) with
|
|
8
|
+
// scrypt password hashes. Sign-ins are signed session cookies; the signing key is
|
|
9
|
+
// generated once per install (~/.ava-engine/secret.key). Without any accounts Ava
|
|
10
|
+
// works as before: ACCESS_PASSWORD, or this computer only.
|
|
11
|
+
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, chmodSync, statSync } from "node:fs";
|
|
13
|
+
import { homedir } from "node:os";
|
|
14
|
+
import { dirname, join } from "node:path";
|
|
15
|
+
import { createHash, createHmac, randomBytes, randomUUID, scryptSync, timingSafeEqual } from "node:crypto";
|
|
16
|
+
|
|
17
|
+
const HOME = process.env.AVA_HOME || join(homedir(), ".ava-engine");
|
|
18
|
+
const FILE = process.env.USERS_FILE || join(HOME, "users.json");
|
|
19
|
+
const KEY_FILE = join(HOME, "secret.key");
|
|
20
|
+
export const ROLES = ["owner", "editor", "specialist"];
|
|
21
|
+
const EMAIL = /^[^\s@]{1,64}@[^\s@]{1,190}\.[^\s@]{2,}$/;
|
|
22
|
+
|
|
23
|
+
let users = [], keys = [], loadedMtime = -1;
|
|
24
|
+
// Re-read when the file changed underneath us (`ava-engine team` while Ava runs).
|
|
25
|
+
function fresh() {
|
|
26
|
+
let m = 0;
|
|
27
|
+
try { m = statSync(FILE).mtimeMs; } catch {}
|
|
28
|
+
if (m === loadedMtime) return;
|
|
29
|
+
loadedMtime = m;
|
|
30
|
+
try { const j = JSON.parse(readFileSync(FILE, "utf8")); users = Array.isArray(j.users) ? j.users : []; keys = Array.isArray(j.keys) ? j.keys : []; } catch { users = []; keys = []; }
|
|
31
|
+
}
|
|
32
|
+
fresh();
|
|
33
|
+
function save() {
|
|
34
|
+
mkdirSync(dirname(FILE), { recursive: true });
|
|
35
|
+
const tmp = `${FILE}.${process.pid}.tmp`;
|
|
36
|
+
writeFileSync(tmp, JSON.stringify({ users, keys }, null, 2), { mode: 0o600 });
|
|
37
|
+
renameSync(tmp, FILE);
|
|
38
|
+
try { chmodSync(FILE, 0o600); } catch {}
|
|
39
|
+
try { loadedMtime = statSync(FILE).mtimeMs; } catch {}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ── signing key (one per install) ──
|
|
43
|
+
let key = null;
|
|
44
|
+
function signingKey() {
|
|
45
|
+
if (key) return key;
|
|
46
|
+
try { key = Buffer.from(readFileSync(KEY_FILE, "utf8").trim(), "base64url"); if (key.length >= 32) return key; } catch {}
|
|
47
|
+
key = randomBytes(32);
|
|
48
|
+
mkdirSync(dirname(KEY_FILE), { recursive: true });
|
|
49
|
+
writeFileSync(KEY_FILE, key.toString("base64url"), { mode: 0o600 });
|
|
50
|
+
return key;
|
|
51
|
+
}
|
|
52
|
+
const mac = (s) => createHmac("sha256", signingKey()).update(s).digest("base64url");
|
|
53
|
+
const eq = (a, b) => { const x = Buffer.from(String(a)), y = Buffer.from(String(b)); return x.length === y.length && timingSafeEqual(x, y); };
|
|
54
|
+
|
|
55
|
+
/** A signed, tamper-proof token for a cookie: payload is a small object. */
|
|
56
|
+
export function signToken(payload) {
|
|
57
|
+
const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
|
58
|
+
return `${body}.${mac(body)}`;
|
|
59
|
+
}
|
|
60
|
+
export function readToken(token) {
|
|
61
|
+
const [body, sig] = String(token || "").split(".");
|
|
62
|
+
if (!body || !sig || !eq(sig, mac(body))) return null;
|
|
63
|
+
try { return JSON.parse(Buffer.from(body, "base64url").toString("utf8")); } catch { return null; }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── passwords ──
|
|
67
|
+
function hash(password, salt = randomBytes(16).toString("base64url")) {
|
|
68
|
+
return { salt, hash: scryptSync(password, salt, 64, { N: 16384, r: 8, p: 1 }).toString("base64url") };
|
|
69
|
+
}
|
|
70
|
+
const DUMMY = hash("not-a-real-password"); // equal work when the email is unknown
|
|
71
|
+
export function passwordProblem(p) {
|
|
72
|
+
if (typeof p !== "string" || p.length < 10) return "Passwords need at least 10 characters.";
|
|
73
|
+
if (p.length > 200) return "That password is too long.";
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
export const suggestPassword = () => randomBytes(12).toString("base64url");
|
|
77
|
+
|
|
78
|
+
// ── accounts ──
|
|
79
|
+
const pub = (u) => u && { id: u.id, email: u.email, name: u.name, role: u.role, createdAt: u.createdAt, lastLoginAt: u.lastLoginAt || null };
|
|
80
|
+
export const hasUsers = () => { fresh(); return users.length > 0; };
|
|
81
|
+
export const listUsers = () => { fresh(); return users.map(pub); };
|
|
82
|
+
export const getUser = (id) => { fresh(); return users.find((u) => u.id === id) || null; };
|
|
83
|
+
const byEmail = (email) => { fresh(); return users.find((u) => u.email === String(email || "").trim().toLowerCase()) || null; };
|
|
84
|
+
const owners = () => users.filter((u) => u.role === "owner");
|
|
85
|
+
|
|
86
|
+
function validate({ email, name, role }, { existing } = {}) {
|
|
87
|
+
const errors = [];
|
|
88
|
+
if (email !== undefined) {
|
|
89
|
+
const e = String(email || "").trim().toLowerCase();
|
|
90
|
+
if (!EMAIL.test(e)) errors.push("Enter a valid email address.");
|
|
91
|
+
else if (byEmail(e) && byEmail(e).id !== existing?.id) errors.push("Someone on the team already uses that email.");
|
|
92
|
+
}
|
|
93
|
+
if (name !== undefined && !(typeof name === "string" && name.trim().length >= 1 && name.length <= 60)) errors.push("Enter a name (up to 60 characters).");
|
|
94
|
+
if (role !== undefined && !ROLES.includes(role)) errors.push("Pick a role: owner, editor or specialist.");
|
|
95
|
+
return errors;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Add someone. The first account must be an owner. Returns { user } or { errors }. */
|
|
99
|
+
export function createUser({ email, name, role, password }) {
|
|
100
|
+
const errors = validate({ email, name, role });
|
|
101
|
+
const pw = passwordProblem(password); if (pw) errors.push(pw);
|
|
102
|
+
if (!users.length && role !== "owner") errors.push("The first account must be an owner.");
|
|
103
|
+
if (errors.length) return { errors };
|
|
104
|
+
const u = { id: randomUUID(), email: email.trim().toLowerCase(), name: name.trim(), role, ...hash(password), tv: 1, createdAt: new Date().toISOString() };
|
|
105
|
+
users.push(u); save();
|
|
106
|
+
return { user: pub(u) };
|
|
107
|
+
}
|
|
108
|
+
/** Change name or role. There's always at least one owner. */
|
|
109
|
+
export function updateUser(id, { name, role }) {
|
|
110
|
+
const u = getUser(id);
|
|
111
|
+
if (!u) return { errors: ["That person isn't on the team."] };
|
|
112
|
+
const errors = validate({ name, role }, { existing: u });
|
|
113
|
+
if (role && role !== "owner" && u.role === "owner" && owners().length === 1) errors.push("Ava needs at least one owner. Make someone else an owner first.");
|
|
114
|
+
if (errors.length) return { errors };
|
|
115
|
+
if (name !== undefined) u.name = name.trim();
|
|
116
|
+
if (role !== undefined && role !== u.role) { u.role = role; u.tv += 1; } // new role: sign in again
|
|
117
|
+
save();
|
|
118
|
+
return { user: pub(u) };
|
|
119
|
+
}
|
|
120
|
+
/** Set a new password. Signs that person out everywhere. */
|
|
121
|
+
export function setPassword(id, password) {
|
|
122
|
+
const u = getUser(id);
|
|
123
|
+
if (!u) return { errors: ["That person isn't on the team."] };
|
|
124
|
+
const pw = passwordProblem(password); if (pw) return { errors: [pw] };
|
|
125
|
+
Object.assign(u, hash(password)); u.tv += 1; save();
|
|
126
|
+
return { user: pub(u) };
|
|
127
|
+
}
|
|
128
|
+
export function deleteUser(id) {
|
|
129
|
+
const u = getUser(id);
|
|
130
|
+
if (!u) return { errors: ["That person isn't on the team."] };
|
|
131
|
+
if (u.role === "owner" && owners().length === 1) return { errors: ["You can't remove the last owner."] };
|
|
132
|
+
users = users.filter((x) => x.id !== id); save();
|
|
133
|
+
return { deleted: true };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Check an email and password. Returns the account or null (same work either way). */
|
|
137
|
+
export function verifyLogin(email, password) {
|
|
138
|
+
const u = byEmail(email);
|
|
139
|
+
const h = hash(String(password || ""), u ? u.salt : DUMMY.salt).hash;
|
|
140
|
+
if (!u || !eq(h, u.hash)) return null;
|
|
141
|
+
u.lastLoginAt = new Date().toISOString(); save();
|
|
142
|
+
return u;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Session token for an account; invalid after a password or role change (tv). */
|
|
146
|
+
export const sessionFor = (u) => signToken({ u: u.id, tv: u.tv, iat: Date.now() });
|
|
147
|
+
/** The account a session token belongs to, if it's still valid. */
|
|
148
|
+
export function userFromSession(payload, maxAgeMs) {
|
|
149
|
+
if (!payload || !payload.u || Date.now() - payload.iat > maxAgeMs) return null;
|
|
150
|
+
const u = getUser(payload.u);
|
|
151
|
+
return u && u.tv === payload.tv ? pub(u) : null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ── API keys: for scripts and tools (n8n, Zapier, your backend) ──
|
|
155
|
+
// "ava_sk_" + 32 random bytes. Only a SHA-256 is stored, so a copied users.json
|
|
156
|
+
// can't be used to call Ava; the key itself is shown once, when it's made.
|
|
157
|
+
const sha = (s) => createHash("sha256").update(s).digest("hex");
|
|
158
|
+
const keyPub = (k) => ({ id: k.id, name: k.name, role: k.role, prefix: k.prefix, createdAt: k.createdAt, createdBy: k.createdBy || null, lastUsedAt: k.lastUsedAt || null });
|
|
159
|
+
export const listKeys = () => { fresh(); return keys.map(keyPub); };
|
|
160
|
+
/** Make a key. Returns { key (shown once), apiKey } or { errors }. */
|
|
161
|
+
export function createKey({ name, role, createdBy }) {
|
|
162
|
+
const errors = [];
|
|
163
|
+
if (!(typeof name === "string" && name.trim() && name.length <= 60)) errors.push("Name the key after what uses it, e.g. “n8n” (up to 60 characters).");
|
|
164
|
+
if (!ROLES.includes(role)) errors.push("Pick a role for the key: owner, editor or specialist.");
|
|
165
|
+
if (errors.length) return { errors };
|
|
166
|
+
fresh();
|
|
167
|
+
const key = `ava_sk_${randomBytes(32).toString("base64url")}`;
|
|
168
|
+
const k = { id: randomUUID(), name: name.trim(), role, prefix: key.slice(0, 11), hash: sha(key), createdAt: new Date().toISOString(), createdBy: createdBy || null };
|
|
169
|
+
keys.push(k); save();
|
|
170
|
+
return { key, apiKey: keyPub(k) };
|
|
171
|
+
}
|
|
172
|
+
export function deleteKey(id) {
|
|
173
|
+
fresh();
|
|
174
|
+
const n = keys.length;
|
|
175
|
+
keys = keys.filter((k) => k.id !== id);
|
|
176
|
+
if (keys.length === n) return false;
|
|
177
|
+
save(); return true;
|
|
178
|
+
}
|
|
179
|
+
/** The key behind a Bearer token, as a signed-in "user" with the key's role. */
|
|
180
|
+
export function userFromApiKey(token) {
|
|
181
|
+
if (typeof token !== "string" || !token.startsWith("ava_sk_") || token.length > 100) return null;
|
|
182
|
+
fresh();
|
|
183
|
+
const h = sha(token);
|
|
184
|
+
const k = keys.find((x) => eq(x.hash, h));
|
|
185
|
+
if (!k) return null;
|
|
186
|
+
if (!k.lastUsedAt || Date.now() - Date.parse(k.lastUsedAt) > 600_000) { k.lastUsedAt = new Date().toISOString(); save(); } // at most every 10 minutes
|
|
187
|
+
return { id: `key:${k.id}`, name: k.name, role: k.role, apiKey: true };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export const USERS_FILE = FILE;
|
|
191
|
+
export const usersFileExists = () => existsSync(FILE);
|