ada-agent 0.6.0 → 0.7.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 CHANGED
@@ -245,6 +245,13 @@ See **[docs/architecture.md](docs/architecture.md)** for the design (adapters, r
245
245
  flow, file layout), **[docs/orchestration.md](docs/orchestration.md)** for the agent strategies, and
246
246
  **[docs/integrations.md](docs/integrations.md)** for the HTTP API / SDK / ACP.
247
247
 
248
+ ## Enterprise
249
+
250
+ `ada-server` doubles as an org **control plane**: per-user seat keys, an org policy (server-enforced
251
+ model allowlist + tool rules pushed to every client), per-user usage metering, and an audit log —
252
+ activated only when you create seats, file-backed, self-hosted in your own network. See
253
+ **[docs/enterprise.md](docs/enterprise.md)** for the 2-minute bootstrap.
254
+
248
255
  ## Benchmarks
249
256
 
250
257
  ada can run **SWE-bench Verified** — it generates patches for real GitHub issues (one isolated repo
@@ -0,0 +1,83 @@
1
+ # Enterprise: seats, policy, metering, audit
2
+
3
+ `ada-server` doubles as an org **control plane**: per-user seat keys, an org policy the backend
4
+ enforces (and clients apply locally), per-user usage metering, and an audit log. It's all
5
+ file-backed under `~/.ada/server/` (override with `ADA_DATA_DIR`) — fine to ~50 seats; a database
6
+ is the upgrade path, not the starting point.
7
+
8
+ **Enterprise mode activates only when a seat exists or `ADA_ADMIN_KEY` is set.** With neither, the
9
+ backend behaves exactly as before (dev-open, or `ADA_CLIENT_KEYS`/login).
10
+
11
+ ## Bootstrap (2 minutes)
12
+
13
+ ```bash
14
+ # 1. start the backend with a bootstrap admin key (any long random string)
15
+ export ADA_ADMIN_KEY=$(openssl rand -hex 24)
16
+ export CLOUDFLARE_ACCOUNT_ID=... CLOUDFLARE_API_TOKEN=... # your provider keys
17
+ ada-server # banner shows: [ENTERPRISE (0 seats + admin key)]
18
+
19
+ # 2. create a seat per developer — the key is shown ONCE
20
+ curl -s -X POST -H "Authorization: Bearer $ADA_ADMIN_KEY" \
21
+ -d '{"name":"alice"}' http://localhost:8787/v1/users
22
+ # → { "key": "ada_sk_…", "name": "alice", "role": "dev", "note": "shown once — store it now" }
23
+ ```
24
+
25
+ Each developer sets their seat key and points at the backend:
26
+
27
+ ```bash
28
+ export ADA_BACKEND_URL=https://ada.yourcompany.com/v1
29
+ export ADA_CLIENT_KEY=ada_sk_…
30
+ ada
31
+ ```
32
+
33
+ ## Seats
34
+
35
+ ```bash
36
+ GET /v1/users # list: name, role, keyPrefix, created, disabled (admin)
37
+ POST /v1/users # {"name":"bob","role":"dev"|"admin"} → full key, once (admin)
38
+ DELETE /v1/users/<keyPrefix> # disable a seat (≥12 chars of its key; kept for the audit trail) (admin)
39
+ ```
40
+
41
+ Full keys are never listed after creation — only a 14-char prefix. `ADA_ADMIN_KEY` is the
42
+ break-glass admin; create an admin *seat* for day-to-day and keep the env key in a vault.
43
+
44
+ ## Org policy
45
+
46
+ ```bash
47
+ curl -X PUT -H "Authorization: Bearer $ADA_ADMIN_KEY" http://localhost:8787/v1/policy -d '{
48
+ "models": ["@cf/*", "claude-*"],
49
+ "permissions": [
50
+ { "tool": "web_*", "action": "deny" },
51
+ { "tool": "bash", "pattern": "*curl*", "action": "ask" }
52
+ ]
53
+ }'
54
+ ```
55
+
56
+ - **`models`** — allowlist (`*` wildcards). Enforced **server-side** (403 + audit entry), so a
57
+ modified client can't route around it. Empty/absent = all models.
58
+ - **`permissions`** — tool rules **pushed to clients** (fetched from `GET /v1/policy` at startup —
59
+ interactive, `-p` headless, `serve`, and `acp` alike). Merged restrictive-wins with local config:
60
+ an org `deny` beats any local `allow`, an org `ask` upgrades a local `allow`, and an org `allow`
61
+ can never *loosen* a local deny or the default gating. **Honest caveat:** tool rules run in the
62
+ *client*, so they govern well-behaved clients — and only **model-allowlist** denials are audited
63
+ server-side; tool-rule outcomes are not visible to `/v1/audit`. The **hard, server-enforced**
64
+ guarantees are: authentication, the model allowlist, provider pinning (when an allowlist is set,
65
+ the client's `provider` hint is ignored so a request can't be re-routed off-policy), and metering.
66
+
67
+ ## Usage & audit
68
+
69
+ ```bash
70
+ GET /v1/usage?days=30 # totals + per-user + per-model {requests, promptTokens, completionTokens} (admin)
71
+ GET /v1/audit?limit=200 # seat_created / seat_disabled / policy_updated / policy_denied_model … (admin)
72
+ ```
73
+
74
+ Metering is captured server-side by teeing every chat response (streamed or not) and recording the
75
+ upstream's reported token usage per user — clients can't underreport. Join with
76
+ `ada catalog` prices for cost.
77
+
78
+ ## Deployment notes
79
+
80
+ - Run behind TLS (caddy/nginx) — seat keys travel as bearer tokens.
81
+ - `ADA_DATA_DIR` on a persistent volume; back it up (it's 4 small JSON/JSONL files).
82
+ - One deployment = one org. Multi-org/SaaS is deliberately out of scope for v1.
83
+ - Compliance paperwork (SOC 2, DPA) is process, not code — start it when a buyer asks.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ada-agent",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "A from-zero terminal coding agent with a Cursor-style routing backend, ~285 skills, MCP connectors, and ask/plan/auto modes",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -30,13 +30,22 @@ export function healthUrl(backendUrl: string): string {
30
30
  }
31
31
  }
32
32
 
33
- async function probe(url: string, timeoutMs = 800): Promise<boolean> {
34
- try {
35
- const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
36
- return res.ok;
37
- } catch {
38
- return false;
39
- }
33
+ // Plain node:http with agent:false, NOT fetch: undici's keep-alive socket from a probe lingers into
34
+ // process teardown and deterministically prints "Assertion failed: !(handle->flags &
35
+ // UV_HANDLE_CLOSING)" on Windows at exit. agent:false closes the socket with the response.
36
+ function probe(url: string, timeoutMs = 800): Promise<boolean> {
37
+ return new Promise((resolve) => {
38
+ import("node:http")
39
+ .then((http) => {
40
+ const req = http.get(url, { agent: false, timeout: timeoutMs }, (res) => {
41
+ res.resume(); // drain so the socket can close
42
+ resolve((res.statusCode ?? 500) < 400);
43
+ });
44
+ req.on("timeout", () => req.destroy());
45
+ req.on("error", () => resolve(false));
46
+ })
47
+ .catch(() => resolve(false));
48
+ });
40
49
  }
41
50
 
42
51
  /** Resolved path to bin/ada-server.mjs (sibling of bin/ada.mjs, packaged in the npm tarball). */
package/src/client/cli.ts CHANGED
@@ -3,7 +3,8 @@
3
3
  import { createInterface } from "node:readline/promises";
4
4
  import { spawnSync } from "node:child_process";
5
5
  import { basename, dirname, join, resolve } from "node:path";
6
- import { readFileSync } from "node:fs";
6
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
+ import { homedir } from "node:os";
7
8
  import { fileURLToPath } from "node:url";
8
9
  import { stdin, stdout } from "node:process";
9
10
  import OpenAI from "openai";
@@ -13,7 +14,7 @@ import { expandPrompt, loadPrompts } from "./prompts.ts";
13
14
  import { Session, list, type SessionMeta } from "./session.ts";
14
15
  import { deleteCredential, getCredential, listCredentials } from "../server/credentials.ts";
15
16
  import { deviceLogin, oauthConfig } from "../server/oauth.ts";
16
- import { addTrust, isTrusted, loadSettings, setActiveAgentPermissions, type Settings } from "./settings.ts";
17
+ import { addTrust, isTrusted, loadSettings, setActiveAgentPermissions, setOrgPermissions, type PermRule, type Settings } from "./settings.ts";
17
18
  import { getCommands, loadExtensions } from "./extensions.ts";
18
19
  import { registerTool, setAsker } from "./tools.ts";
19
20
  import { addRemoteSkill, loadSkills, registerSkillTool } from "./skills.ts";
@@ -368,6 +369,38 @@ async function loginFlow(provider: string): Promise<boolean> {
368
369
  }
369
370
  }
370
371
 
372
+ /** Fetch org policy from an enterprise backend and apply its tool rules locally (restrictive-wins
373
+ * merge in settings.permissionFor; the backend enforces the model allowlist regardless). Caches the
374
+ * last-good policy under ~/.ada so a transient fetch failure falls back to known rules instead of
375
+ * silently dropping them. No-op against a non-enterprise backend. */
376
+ async function applyOrgPolicy(): Promise<void> {
377
+ const cacheFile = join(homedir(), ".ada", "org-policy.json");
378
+ const enterprise = clientKey().startsWith("ada_sk_"); // a seat key ⇒ this is an enterprise backend
379
+ try {
380
+ const r = await fetch(`${BACKEND}/policy`, { headers: { authorization: `Bearer ${clientKey()}` }, signal: AbortSignal.timeout(3000) });
381
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
382
+ const policy = (await r.json()) as { permissions?: PermRule[] };
383
+ setOrgPermissions(policy.permissions ?? null);
384
+ try {
385
+ mkdirSync(join(homedir(), ".ada"), { recursive: true });
386
+ writeFileSync(cacheFile, JSON.stringify(policy));
387
+ } catch {
388
+ /* cache best-effort */
389
+ }
390
+ if (policy.permissions?.length) console.error(`\x1b[2m↳ org policy applied (${policy.permissions.length} rule${policy.permissions.length === 1 ? "" : "s"})\x1b[0m`);
391
+ } catch (e) {
392
+ if (!enterprise) return; // non-enterprise backend — local rules only, silently
393
+ // Enterprise backend unreachable — fall back to the last policy we saw, and say so loudly.
394
+ try {
395
+ const cached = JSON.parse(readFileSync(cacheFile, "utf8")) as { permissions?: PermRule[] };
396
+ setOrgPermissions(cached.permissions ?? null);
397
+ console.error(`\x1b[33m[warn] could not fetch org policy (${e instanceof Error ? e.message : e}) — using cached rules.\x1b[0m`);
398
+ } catch {
399
+ console.error(`\x1b[33m[warn] could not fetch org policy (${e instanceof Error ? e.message : e}) and no cache — org tool rules NOT applied this session.\x1b[0m`);
400
+ }
401
+ }
402
+ }
403
+
371
404
  /** Startup login check: probe the backend; if it says 401, offer to sign in and rebuild the client. */
372
405
  async function ensureAuth(rl: RL, client: OpenAI): Promise<OpenAI> {
373
406
  let status: number;
@@ -658,6 +691,7 @@ async function main(): Promise<void> {
658
691
  registerSkillTool(loadSkills(trusted));
659
692
  await loadMcpServers(trusted);
660
693
  const client = makeClient();
694
+ await applyOrgPolicy(); // enterprise org rules apply to acp sessions too
661
695
  let model = process.env.ADA_MODEL || settings.model || "";
662
696
  if (!model) {
663
697
  try {
@@ -741,6 +775,7 @@ async function main(): Promise<void> {
741
775
  registerSkillTool(loadSkills(trusted));
742
776
  await loadMcpServers(trusted);
743
777
  const client = makeClient();
778
+ await applyOrgPolicy(); // enterprise org rules apply to serve sessions too
744
779
  let model = (process.argv[3] && !process.argv[3].startsWith("--") ? process.argv[3] : "") || process.env.ADA_MODEL || settings.model || "";
745
780
  if (!model) {
746
781
  try {
@@ -1082,6 +1117,7 @@ async function main(): Promise<void> {
1082
1117
  if (flags.print !== undefined) {
1083
1118
  const trusted = isTrusted(process.cwd());
1084
1119
  const settings = loadSettings(trusted);
1120
+ await applyOrgPolicy(); // org tool rules bind headless runs too (CI is the classic bypass path)
1085
1121
  let pm = flags.model ?? process.env.ADA_MODEL ?? settings.model ?? scoped[0] ?? "";
1086
1122
  if (!pm) {
1087
1123
  try {
@@ -1125,6 +1161,7 @@ async function main(): Promise<void> {
1125
1161
  const mcp = await loadMcpServers(includeProject);
1126
1162
 
1127
1163
  client = await ensureAuth(rl, client); // always check login at startup; prompt if the backend says 401
1164
+ await applyOrgPolicy(); // enterprise backends push org tool rules; no-op otherwise
1128
1165
 
1129
1166
  let session: Session;
1130
1167
  let history: Msg[] = [];
@@ -66,9 +66,15 @@ export function setActiveAgentPermissions(rules: PermRule[] | null): void {
66
66
  activeAgentPerms = rules;
67
67
  }
68
68
 
69
- /** Evaluate the configured permission rules for a tool call. null = no matching rule (use defaults). */
70
- export function permissionFor(toolName: string, summary: string): PermAction | null {
71
- const rules = activeAgentPerms ?? loadSettings(isTrusted(process.cwd())).permissions ?? [];
69
+ // Org policy pushed by an enterprise backend (fetched from /v1/policy at startup). Merged
70
+ // restrictive-wins: an org "deny" beats any local "allow"; an org "ask" upgrades a local "allow".
71
+ // A local "deny" always stands the org can tighten a user's setup, never loosen it.
72
+ let orgPerms: PermRule[] | null = null;
73
+ export function setOrgPermissions(rules: PermRule[] | null): void {
74
+ orgPerms = rules?.length ? rules : null;
75
+ }
76
+
77
+ function evalRules(rules: PermRule[], toolName: string, summary: string): PermAction | null {
72
78
  let result: PermAction | null = null;
73
79
  for (const r of rules) {
74
80
  const toolOk = !r.tool || r.tool === toolName || globMatch(r.tool, toolName);
@@ -78,6 +84,18 @@ export function permissionFor(toolName: string, summary: string): PermAction | n
78
84
  return result;
79
85
  }
80
86
 
87
+ const STRICTNESS: Record<PermAction, number> = { allow: 0, ask: 1, deny: 2 };
88
+
89
+ /** Evaluate the configured permission rules for a tool call. null = no matching rule (use defaults). */
90
+ export function permissionFor(toolName: string, summary: string): PermAction | null {
91
+ const local = evalRules(activeAgentPerms ?? loadSettings(isTrusted(process.cwd())).permissions ?? [], toolName, summary);
92
+ if (!orgPerms) return local;
93
+ const org = evalRules(orgPerms, toolName, summary);
94
+ if (org === null) return local;
95
+ if (local === null) return org === "allow" ? null : org; // org can't LOOSEN the default gating, only tighten
96
+ return STRICTNESS[org] > STRICTNESS[local] ? org : local;
97
+ }
98
+
81
99
  export function addTrust(dir: string): void {
82
100
  const g = readJson(GLOBAL);
83
101
  const dirs = new Set(g.trustedDirs ?? []);
@@ -99,14 +99,21 @@ export function formatFile(abs: string): boolean {
99
99
  }
100
100
 
101
101
  // node-pty gives the bash tool a real terminal. It's a required dependency; if the native build is
102
- // ever broken on a platform, fall back to spawnSync so bash still works.
103
- const pty: typeof PtyType | null = (() => {
104
- try {
105
- return createRequire(import.meta.url)("node-pty") as typeof PtyType;
106
- } catch {
107
- return null;
102
+ // ever broken on a platform, fall back to spawnSync so bash still works. Loaded LAZILY on the first
103
+ // bash call: merely loading the native module on Windows sets up async handles whose teardown races
104
+ // process.exit and prints "Assertion failed: !(handle->flags & UV_HANDLE_CLOSING)" — commands that
105
+ // never spawn a PTY (--version, catalog, --list-models, …) shouldn't pay that.
106
+ let ptyMod: typeof PtyType | null | undefined;
107
+ function getPty(): typeof PtyType | null {
108
+ if (ptyMod === undefined) {
109
+ try {
110
+ ptyMod = createRequire(import.meta.url)("node-pty") as typeof PtyType;
111
+ } catch {
112
+ ptyMod = null;
113
+ }
108
114
  }
109
- })();
115
+ return ptyMod;
116
+ }
110
117
 
111
118
  // Built via new RegExp (string escapes) so no literal ESC/BEL bytes live in the source.
112
119
  const ANSI = new RegExp("[\\u001B\\u009B][\\[\\]()#;?]*(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\\u0007|(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~])", "g");
@@ -120,7 +127,7 @@ function runPty(command: string, timeoutMs = 120_000): Promise<{ output: string;
120
127
  const win = process.platform === "win32";
121
128
  const shell = win ? process.env.COMSPEC ?? "cmd.exe" : process.env.SHELL ?? "/bin/bash";
122
129
  const shellArgs = win ? ["/c", command] : ["-lc", command];
123
- const p = pty!.spawn(shell, shellArgs, { name: "xterm-256color", cols: 120, rows: 30, cwd: process.cwd(), env: process.env as Record<string, string> });
130
+ const p = getPty()!.spawn(shell, shellArgs, { name: "xterm-256color", cols: 120, rows: 30, cwd: process.cwd(), env: process.env as Record<string, string> });
124
131
  let out = "";
125
132
  const cap = 10 * 1024 * 1024;
126
133
  p.onData((d) => {
@@ -360,7 +367,7 @@ export const tools: Tool[] = [
360
367
  needsApproval: true,
361
368
  async run(args) {
362
369
  const command = String(args.command);
363
- if (pty) {
370
+ if (getPty()) {
364
371
  const { output, code } = await runPty(command);
365
372
  return { output: `exit ${code ?? "null"}\n${spillIfHuge(stripAnsi(output).trim() || "(no output)")}`, isError: code !== 0 };
366
373
  }
package/src/selfcheck.ts CHANGED
@@ -293,6 +293,79 @@ async function main(): Promise<void> {
293
293
  assert.equal(route("anything-else"), "openrouter", "unmatched → openrouter");
294
294
  }
295
295
 
296
+ // --- enterprise control plane: seats, policy, metering, audit (temp data dir, no HTTP) ---
297
+ {
298
+ const dir = join(tmpdir(), `ada-ent-${Date.now()}`);
299
+ const ent = await import("./server/enterprise.ts");
300
+ process.env.ADA_DATA_DIR = dir;
301
+ try {
302
+ assert.equal(ent.enterpriseMode(dir), false, "no seats + no admin key → enterprise mode off");
303
+ const key = ent.createSeat("alice", "admin", dir);
304
+ assert.ok(key.startsWith("ada_sk_") && key.length > 40, "seat keys are long and prefixed");
305
+ assert.equal(ent.enterpriseMode(dir), true, "a seat activates enterprise mode");
306
+ assert.deepEqual(ent.identifySeat(key, dir), { user: "alice", role: "admin" }, "seat key resolves to its identity");
307
+ assert.equal(ent.identifySeat("ada_sk_wrong", dir), null, "unknown key → null");
308
+ // The auth-bypass the review caught: Object.prototype keys must NOT authenticate.
309
+ for (const evil of ["toString", "constructor", "__proto__", "valueOf", "hasOwnProperty"]) {
310
+ assert.equal(ent.identifySeat(evil, dir), null, `prototype key "${evil}" must not authenticate`);
311
+ }
312
+ assert.equal(ent.listSeats(dir)[0]!.keyPrefix.length, 14, "listing exposes only a key prefix");
313
+ assert.equal(ent.disableSeat(key.slice(0, 8), dir), null, "too-short prefix refused");
314
+ assert.equal(ent.disableSeat(key.slice(0, 14), dir), "alice", "disable by unique prefix");
315
+ assert.equal(ent.identifySeat(key, dir), null, "disabled seat no longer authenticates");
316
+
317
+ assert.ok(ent.modelAllowed("claude-opus-4-8", {}), "empty policy allows everything");
318
+ const pol = { models: ["@cf/*", "claude-*"] };
319
+ assert.ok(ent.modelAllowed("@cf/moonshotai/kimi-k2.7-code", pol), "wildcard allowlist matches");
320
+ assert.ok(!ent.modelAllowed("gpt-5", pol), "non-listed model denied");
321
+
322
+ ent.appendUsage({ ts: Date.now(), user: "alice", model: "m1", provider: "p", promptTokens: 100, completionTokens: 20 }, dir);
323
+ ent.appendUsage({ ts: Date.now(), user: "alice", model: "m1", provider: "p", promptTokens: 50, completionTokens: 10 }, dir);
324
+ ent.appendUsage({ ts: Date.now() - 90 * 86_400_000, user: "old", model: "m1", provider: "p", promptTokens: 999, completionTokens: 999 }, dir);
325
+ const sum = ent.usageSummary(30, dir);
326
+ assert.equal(sum.byUser.alice!.requests, 2, "usage aggregates per user");
327
+ assert.equal(sum.totals.promptTokens, 150, "old rows fall outside the window");
328
+
329
+ assert.ok(ent.auditTail(10, dir).some((e) => e.event === "seat_created"), "audit log records seat creation");
330
+
331
+ const sse = 'data: {"choices":[]}\n\ndata: {"choices":[],"usage":{"prompt_tokens":11,"completion_tokens":7,"completion_tokens_details":{"reasoning_tokens":2}}}\n\ndata: [DONE]\n\n';
332
+ assert.deepEqual(ent.extractLastUsage(sse), { promptTokens: 11, completionTokens: 7 }, "usage extracted from SSE tail (nested details ok)");
333
+ assert.equal(ent.extractLastUsage("no usage here"), null, "no usage → null");
334
+ // A trailing "usage": null must not hide the real one earlier in the stream.
335
+ assert.deepEqual(ent.extractLastUsage('{"usage":{"prompt_tokens":5,"completion_tokens":3}}\n{"usage":null}'), { promptTokens: 5, completionTokens: 3 }, "trailing usage:null skipped, real one found");
336
+
337
+ // policy validation rejects malformed shapes, accepts good ones
338
+ assert.ok("error" in ent.validatePolicy({ models: [1, 2] }), "non-string models rejected");
339
+ assert.ok("error" in ent.validatePolicy({ permissions: [{ tool: "x" }] }), "permission without action rejected");
340
+ assert.ok("policy" in ent.validatePolicy({ models: ["@cf/*"], permissions: [{ tool: "bash", action: "deny" }] }), "valid policy accepted");
341
+
342
+ // corrupt users.json → CorruptStore (fail-closed), NOT an empty map that unlocks the backend
343
+ writeFileSync(join(dir, "users.json"), "{ this is not json");
344
+ assert.throws(() => ent.loadSeats(dir), (e: unknown) => e instanceof ent.CorruptStore, "corrupt users.json throws CorruptStore");
345
+ assert.equal(ent.enterpriseMode(dir), true, "corrupt store → still enterprise (locked), never open");
346
+ } finally {
347
+ delete process.env.ADA_DATA_DIR;
348
+ rmSync(dir, { recursive: true, force: true });
349
+ }
350
+ }
351
+
352
+ // --- org policy merge: restrictive wins, org can tighten but never loosen ---
353
+ {
354
+ const { permissionFor, setActiveAgentPermissions, setOrgPermissions } = await import("./client/settings.ts");
355
+ setActiveAgentPermissions([{ tool: "bash", action: "allow" }]);
356
+ setOrgPermissions([{ tool: "bash", action: "deny" }]);
357
+ assert.equal(permissionFor("bash", "x"), "deny", "org deny beats local allow");
358
+ setOrgPermissions([{ tool: "bash", action: "ask" }]);
359
+ assert.equal(permissionFor("bash", "x"), "ask", "org ask upgrades local allow");
360
+ setActiveAgentPermissions([{ tool: "bash", action: "deny" }]);
361
+ setOrgPermissions([{ tool: "bash", action: "allow" }]);
362
+ assert.equal(permissionFor("bash", "x"), "deny", "org allow cannot loosen a local deny");
363
+ setActiveAgentPermissions([]);
364
+ assert.equal(permissionFor("bash", "x"), null, "org allow cannot loosen the default gating");
365
+ setOrgPermissions(null);
366
+ setActiveAgentPermissions(null);
367
+ }
368
+
296
369
  // --- @codebase semantic search: pure parts (no network / no embedding model needed) ---
297
370
  {
298
371
  const { chunkText, cosine, walkFiles } = await import("./client/embed-index.ts");
@@ -0,0 +1,346 @@
1
+ // Enterprise control plane: seats (per-user client keys), org policy, usage metering, audit log.
2
+ // One deployment = one org (it's self-hosted; multi-org is the SaaS upgrade path, not v1).
3
+ //
4
+ // Enterprise mode ACTIVATES when a seat exists or ADA_ADMIN_KEY is set — with neither, the backend
5
+ // behaves exactly as before (dev-open / ADA_CLIENT_KEYS / login). Bootstrap:
6
+ //
7
+ // ADA_ADMIN_KEY=<random> ada-server
8
+ // curl -X POST -H "Authorization: Bearer $ADA_ADMIN_KEY" localhost:8787/v1/users -d '{"name":"alice"}'
9
+ //
10
+ // Security posture (hardened after an adversarial review):
11
+ // - lookups are own-property + format-guarded (no prototype-key auth bypass);
12
+ // - writes are atomic (tmp + rename); a corrupt/unreadable store fails CLOSED (never dev-open);
13
+ // - key comparisons are timing-safe.
14
+ // ponytail: file-backed under ~/.ada/server — fine to ~50 seats. Postgres + rotating usage logs are
15
+ // the upgrade path when an org outgrows files (usageSummary/auditTail read whole files: OK to
16
+ // low-millions of rows, then rotate).
17
+
18
+ import { randomBytes, timingSafeEqual } from "node:crypto";
19
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
20
+ import { homedir } from "node:os";
21
+ import { join, resolve } from "node:path";
22
+
23
+ // Resolved once: an empty ADA_DATA_DIR means "unset", and a relative path can't scatter the auth
24
+ // store across working directories (which would itself be a fail-open).
25
+ const DATA_DIR = resolve(process.env.ADA_DATA_DIR || join(homedir(), ".ada", "server"));
26
+ function dataDir(): string {
27
+ return DATA_DIR;
28
+ }
29
+
30
+ /** Thrown when a store file exists but can't be read/parsed — callers must fail CLOSED, never open. */
31
+ export class CorruptStore extends Error {}
32
+
33
+ export interface Seat {
34
+ name: string;
35
+ role: "admin" | "dev";
36
+ created: string;
37
+ disabled?: boolean;
38
+ }
39
+ export interface PolicyRule {
40
+ tool?: string;
41
+ pattern?: string;
42
+ action: "allow" | "ask" | "deny";
43
+ }
44
+ export interface Policy {
45
+ models?: string[];
46
+ permissions?: PolicyRule[];
47
+ }
48
+ export interface UsageRow {
49
+ ts: number;
50
+ user: string;
51
+ model: string;
52
+ provider: string;
53
+ promptTokens: number;
54
+ completionTokens: number;
55
+ }
56
+ export interface Identity {
57
+ user: string;
58
+ role: "admin" | "dev";
59
+ }
60
+
61
+ const usersFile = (dir: string): string => join(dir, "users.json");
62
+ const policyFile = (dir: string): string => join(dir, "policy.json");
63
+ const usageFile = (dir: string): string => join(dir, "usage.jsonl");
64
+ const auditFile = (dir: string): string => join(dir, "audit.jsonl");
65
+
66
+ function atomicWrite(file: string, data: string): void {
67
+ mkdirSync(join(file, ".."), { recursive: true });
68
+ const tmp = `${file}.tmp.${process.pid}`;
69
+ writeFileSync(tmp, data);
70
+ renameSync(tmp, file); // atomic on the same filesystem — a crash can't leave a torn file
71
+ }
72
+
73
+ function errno(e: unknown): string | undefined {
74
+ return (e as NodeJS.ErrnoException).code;
75
+ }
76
+
77
+ /** Seats keyed by full key. Missing file → empty (prototype-free) map. Any OTHER read/parse error
78
+ * → CorruptStore (callers fail closed — a torn users.json must not silently disable auth). */
79
+ export function loadSeats(dir = dataDir()): Record<string, Seat> {
80
+ const map: Record<string, Seat> = Object.create(null); // no Object.prototype — belt-and-suspenders with the own-property check
81
+ let text: string;
82
+ try {
83
+ text = readFileSync(usersFile(dir), "utf8");
84
+ } catch (e) {
85
+ if (errno(e) === "ENOENT") return map;
86
+ throw new CorruptStore(`users.json unreadable: ${e instanceof Error ? e.message : e}`);
87
+ }
88
+ try {
89
+ const parsed = (JSON.parse(text) as { users?: Record<string, Seat> }).users ?? {};
90
+ for (const [k, v] of Object.entries(parsed)) map[k] = v;
91
+ return map;
92
+ } catch (e) {
93
+ throw new CorruptStore(`users.json corrupt: ${e instanceof Error ? e.message : e}`);
94
+ }
95
+ }
96
+
97
+ function saveSeats(seats: Record<string, Seat>, dir = dataDir()): void {
98
+ atomicWrite(usersFile(dir), JSON.stringify({ users: seats }, null, 2));
99
+ }
100
+
101
+ /** Enterprise mode = admin key set, or seats exist. A corrupt seat store counts as enterprise
102
+ * (locked), never as "no seats" — fail closed. */
103
+ export function enterpriseMode(dir = dataDir()): boolean {
104
+ if (process.env.ADA_ADMIN_KEY) return true;
105
+ try {
106
+ return Object.keys(loadSeats(dir)).length > 0;
107
+ } catch {
108
+ return true;
109
+ }
110
+ }
111
+
112
+ function timingEqual(a: string, b: string): boolean {
113
+ const ab = Buffer.from(a);
114
+ const bb = Buffer.from(b);
115
+ return ab.length === bb.length && timingSafeEqual(ab, bb);
116
+ }
117
+
118
+ /** Resolve a bearer token to a seat identity (or the bootstrap admin). Null = not a seat. Throws
119
+ * CorruptStore if the seat store can't be read (caller returns 503, never dev-open). */
120
+ export function identifySeat(token: string, dir = dataDir()): Identity | null {
121
+ const admin = process.env.ADA_ADMIN_KEY;
122
+ if (admin && timingEqual(token, admin)) return { user: "admin", role: "admin" };
123
+ if (!token.startsWith("ada_sk_")) return null; // format guard — "toString"/"__proto__"/… never reach the map
124
+ const seats = loadSeats(dir); // may throw CorruptStore
125
+ if (!Object.prototype.hasOwnProperty.call(seats, token)) return null; // own-property only
126
+ const seat = seats[token]!;
127
+ return seat.disabled ? null : { user: seat.name, role: seat.role };
128
+ }
129
+
130
+ /** Create a seat; returns its full key (shown once — only a prefix is ever listed again). */
131
+ export function createSeat(name: string, role: "admin" | "dev" = "dev", dir = dataDir()): string {
132
+ const key = `ada_sk_${randomBytes(24).toString("hex")}`;
133
+ const seats = loadSeats(dir);
134
+ seats[key] = { name, role, created: new Date().toISOString() };
135
+ saveSeats(seats, dir);
136
+ appendAudit({ ts: Date.now(), user: "-", event: "seat_created", detail: `${name} (${role})` }, dir);
137
+ return key;
138
+ }
139
+
140
+ /** Disable (not delete — the audit trail keeps the history) the seat whose key starts with prefix. */
141
+ export function disableSeat(prefix: string, dir = dataDir()): string | null {
142
+ if (prefix.length < 12) return null; // too short to be safely unique
143
+ const seats = loadSeats(dir);
144
+ const keys = Object.keys(seats).filter((k) => k.startsWith(prefix));
145
+ if (keys.length !== 1) return null;
146
+ seats[keys[0]!]!.disabled = true;
147
+ saveSeats(seats, dir);
148
+ appendAudit({ ts: Date.now(), user: "-", event: "seat_disabled", detail: seats[keys[0]!]!.name }, dir);
149
+ return seats[keys[0]!]!.name;
150
+ }
151
+
152
+ /** Key prefixes + metadata for listing — full keys are never returned after creation. Display-only,
153
+ * so a corrupt store yields [] rather than crashing the banner. */
154
+ export function listSeats(dir = dataDir()): Array<Seat & { keyPrefix: string }> {
155
+ try {
156
+ return Object.entries(loadSeats(dir)).map(([k, s]) => ({ ...s, keyPrefix: k.slice(0, 14) }));
157
+ } catch {
158
+ return [];
159
+ }
160
+ }
161
+
162
+ let lastGoodPolicy: Policy | null = null;
163
+ /** Missing file → {} (no policy = allow all, legitimate). A corrupt EXISTING file → last-known-good
164
+ * if we have one, else CorruptStore (fail closed — a security control must not degrade to allow-all). */
165
+ export function loadPolicy(dir = dataDir()): Policy {
166
+ let text: string;
167
+ try {
168
+ text = readFileSync(policyFile(dir), "utf8");
169
+ } catch (e) {
170
+ if (errno(e) === "ENOENT") return {};
171
+ if (lastGoodPolicy) return lastGoodPolicy;
172
+ throw new CorruptStore(`policy.json unreadable: ${e instanceof Error ? e.message : e}`);
173
+ }
174
+ try {
175
+ lastGoodPolicy = JSON.parse(text) as Policy;
176
+ return lastGoodPolicy;
177
+ } catch (e) {
178
+ if (lastGoodPolicy) return lastGoodPolicy;
179
+ throw new CorruptStore(`policy.json corrupt: ${e instanceof Error ? e.message : e}`);
180
+ }
181
+ }
182
+
183
+ export function savePolicy(p: Policy, dir = dataDir()): void {
184
+ atomicWrite(policyFile(dir), JSON.stringify(p, null, 2));
185
+ lastGoodPolicy = p;
186
+ appendAudit({ ts: Date.now(), user: "-", event: "policy_updated", detail: JSON.stringify(p).slice(0, 300) }, dir);
187
+ }
188
+
189
+ /** Validate a policy shape from the wire. Returns the typed policy or an error message. */
190
+ export function validatePolicy(raw: unknown): { policy: Policy } | { error: string } {
191
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { error: "policy must be a JSON object" };
192
+ const r = raw as Record<string, unknown>;
193
+ const out: Policy = {};
194
+ if (r.models !== undefined) {
195
+ if (!Array.isArray(r.models) || r.models.some((m) => typeof m !== "string" || !m.trim())) return { error: "models must be an array of non-empty strings" };
196
+ out.models = r.models as string[];
197
+ }
198
+ if (r.permissions !== undefined) {
199
+ if (!Array.isArray(r.permissions)) return { error: "permissions must be an array" };
200
+ for (const p of r.permissions) {
201
+ const rule = p as Record<string, unknown>;
202
+ if (!rule || typeof rule !== "object" || !["allow", "ask", "deny"].includes(rule.action as string)) return { error: "each permission needs action: allow|ask|deny" };
203
+ if (rule.tool !== undefined && typeof rule.tool !== "string") return { error: "permission.tool must be a string" };
204
+ if (rule.pattern !== undefined && typeof rule.pattern !== "string") return { error: "permission.pattern must be a string" };
205
+ }
206
+ out.permissions = r.permissions as PolicyRule[];
207
+ }
208
+ return { policy: out };
209
+ }
210
+
211
+ function globMatch(pattern: string, s: string): boolean {
212
+ const re = new RegExp(`^${pattern.split("*").map((p) => p.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*")}$`, "i");
213
+ return re.test(s);
214
+ }
215
+
216
+ /** Is this model allowed by org policy? No/empty allowlist = everything allowed. */
217
+ export function modelAllowed(model: string, policy: Policy): boolean {
218
+ if (!Array.isArray(policy.models) || !policy.models.length) return true;
219
+ return policy.models.some((p) => globMatch(p, model));
220
+ }
221
+
222
+ export function appendUsage(row: UsageRow, dir = dataDir()): void {
223
+ try {
224
+ mkdirSync(dir, { recursive: true });
225
+ appendFileSync(usageFile(dir), `${JSON.stringify(row)}\n`);
226
+ } catch {
227
+ /* metering is best-effort; never fail a request over it */
228
+ }
229
+ }
230
+
231
+ export interface AuditRow {
232
+ ts: number;
233
+ user: string;
234
+ event: string;
235
+ detail: string;
236
+ }
237
+
238
+ export function appendAudit(row: AuditRow, dir = dataDir()): void {
239
+ try {
240
+ mkdirSync(dir, { recursive: true });
241
+ appendFileSync(auditFile(dir), `${JSON.stringify(row)}\n`);
242
+ } catch {
243
+ /* best-effort */
244
+ }
245
+ }
246
+
247
+ export function auditTail(limit = 200, dir = dataDir()): AuditRow[] {
248
+ let lines: string[];
249
+ try {
250
+ lines = readFileSync(auditFile(dir), "utf8").split("\n").filter(Boolean);
251
+ } catch {
252
+ return [];
253
+ }
254
+ const out: AuditRow[] = [];
255
+ for (const l of lines.slice(-limit)) {
256
+ try {
257
+ out.push(JSON.parse(l) as AuditRow); // skip a torn last line instead of losing the whole view
258
+ } catch {
259
+ /* skip corrupt line */
260
+ }
261
+ }
262
+ return out;
263
+ }
264
+
265
+ interface Bucket {
266
+ requests: number;
267
+ promptTokens: number;
268
+ completionTokens: number;
269
+ }
270
+ export interface UsageSummary {
271
+ since: number;
272
+ totals: Bucket;
273
+ byUser: Record<string, Bucket>;
274
+ byModel: Record<string, Bucket>;
275
+ }
276
+
277
+ export function usageSummary(days = 30, dir = dataDir()): UsageSummary {
278
+ const since = Date.now() - days * 86_400_000;
279
+ const zero = (): Bucket => ({ requests: 0, promptTokens: 0, completionTokens: 0 });
280
+ const out: UsageSummary = { since, totals: zero(), byUser: Object.create(null), byModel: Object.create(null) };
281
+ let lines: string[] = [];
282
+ try {
283
+ lines = readFileSync(usageFile(dir), "utf8").split("\n").filter(Boolean);
284
+ } catch {
285
+ return out;
286
+ }
287
+ for (const l of lines) {
288
+ let r: UsageRow;
289
+ try {
290
+ r = JSON.parse(l) as UsageRow;
291
+ } catch {
292
+ continue;
293
+ }
294
+ if (r.ts < since) continue;
295
+ for (const b of [out.totals, (out.byUser[r.user] ??= zero()), (out.byModel[r.model] ??= zero())]) {
296
+ b.requests++;
297
+ b.promptTokens += r.promptTokens || 0;
298
+ b.completionTokens += r.completionTokens || 0;
299
+ }
300
+ }
301
+ return out;
302
+ }
303
+
304
+ function matchBraces(text: string, start: number): string | null {
305
+ let depth = 0;
306
+ let inStr = false;
307
+ let esc = false;
308
+ for (let i = start; i < text.length; i++) {
309
+ const c = text[i];
310
+ if (inStr) {
311
+ if (esc) esc = false;
312
+ else if (c === "\\") esc = true;
313
+ else if (c === '"') inStr = false;
314
+ } else if (c === '"') inStr = true;
315
+ else if (c === "{") depth++;
316
+ else if (c === "}" && --depth === 0) return text.slice(start, i + 1);
317
+ }
318
+ return null;
319
+ }
320
+
321
+ /** Pull the LAST real `"usage": { … }` object out of streamed/response text. Skips a trailing
322
+ * `"usage": null` and keeps scanning backwards, so a null in a late frame doesn't hide a real one. */
323
+ export function extractLastUsage(text: string): { promptTokens: number; completionTokens: number } | null {
324
+ let at = text.lastIndexOf('"usage"');
325
+ while (at >= 0) {
326
+ const brace = text.indexOf("{", at + 7);
327
+ const colon = text.indexOf(":", at + 7);
328
+ if (brace >= 0 && colon >= 0 && text.slice(colon + 1, brace).trim() === "") {
329
+ const obj = matchBraces(text, brace);
330
+ if (obj) {
331
+ try {
332
+ const u = JSON.parse(obj) as { prompt_tokens?: number; completion_tokens?: number };
333
+ if (u.prompt_tokens != null || u.completion_tokens != null) return { promptTokens: u.prompt_tokens ?? 0, completionTokens: u.completion_tokens ?? 0 };
334
+ } catch {
335
+ /* malformed — keep looking backwards */
336
+ }
337
+ }
338
+ }
339
+ at = text.lastIndexOf('"usage"', at - 1);
340
+ }
341
+ return null;
342
+ }
343
+
344
+ export function storeExists(dir = dataDir()): boolean {
345
+ return existsSync(usersFile(dir)) || existsSync(policyFile(dir));
346
+ }
@@ -5,6 +5,7 @@
5
5
  import { createServer } from "node:http";
6
6
  import type { IncomingMessage, ServerResponse } from "node:http";
7
7
  import { PORT, PROVIDERS, clientKeys, configuredProviders, isConfigured } from "./config.ts";
8
+ import { CorruptStore, type Identity, appendAudit, appendUsage, auditTail, createSeat, disableSeat, enterpriseMode, extractLastUsage, identifySeat, listSeats, loadPolicy, modelAllowed, savePolicy, usageSummary, validatePolicy } from "./enterprise.ts";
8
9
  import { allowedUsers, isAllowed, verifyIdentity } from "./identity.ts";
9
10
  import { adapterFor } from "./providers/registry.ts";
10
11
  import { route } from "./router.ts";
@@ -19,20 +20,32 @@ function readBody(req: IncomingMessage): Promise<string> {
19
20
  }
20
21
 
21
22
  function locked(): boolean {
22
- return clientKeys() !== null || allowedUsers() !== null || !!process.env.ADA_REQUIRE_LOGIN;
23
+ return enterpriseMode() || clientKeys() !== null || allowedUsers() !== null || !!process.env.ADA_REQUIRE_LOGIN;
23
24
  }
24
25
 
25
- /** A request is allowed if it carries a known static client key, OR a valid GitHub/Google
26
- * login token (allowlisted). With nothing configured, the backend is open (dev mode). */
27
- async function authorized(req: IncomingMessage): Promise<boolean> {
28
- if (!locked()) return true; // dev mode: no auth configured
26
+ /** Resolve a request to WHO is making it. Order: seat key / ADA_ADMIN_KEY (enterprise), legacy
27
+ * static client key, GitHub/Google login. With no auth configured, the backend is open (dev mode).
28
+ * Returns "corrupt" if the seat store can't be read — the caller MUST 503, never fall through to
29
+ * dev-open. Null = unauthorized. */
30
+ async function identify(req: IncomingMessage): Promise<Identity | "corrupt" | null> {
29
31
  const h = req.headers["authorization"];
30
32
  const token = typeof h === "string" && h.startsWith("Bearer ") ? h.slice(7) : "";
31
- if (!token) return false;
32
- const keys = clientKeys();
33
- if (keys?.includes(token)) return true; // static client key
34
- const id = await verifyIdentity(token); // GitHub / Google login
35
- return !!id && isAllowed(id.user);
33
+ if (token) {
34
+ let seat: Identity | null;
35
+ try {
36
+ seat = identifySeat(token);
37
+ } catch (e) {
38
+ if (e instanceof CorruptStore) return "corrupt";
39
+ throw e;
40
+ }
41
+ if (seat) return seat;
42
+ // Legacy ADA_CLIENT_KEYS are NOT honored once seats/admin-key exist — enterprise supersedes them,
43
+ // so a disabled seat can't be resurrected via a still-configured shared key.
44
+ if (!enterpriseMode() && clientKeys()?.includes(token)) return { user: "team", role: "dev" };
45
+ const id = await verifyIdentity(token); // GitHub / Google login
46
+ if (id && isAllowed(id.user)) return { user: id.user, role: "dev" };
47
+ }
48
+ return locked() ? null : { user: "dev", role: "dev" }; // dev mode: open
36
49
  }
37
50
 
38
51
  function json(res: ServerResponse, status: number, obj: unknown): void {
@@ -49,7 +62,7 @@ async function handleModels(res: ServerResponse): Promise<void> {
49
62
  json(res, 200, { object: "list", data });
50
63
  }
51
64
 
52
- async function handleChat(req: IncomingMessage, res: ServerResponse): Promise<void> {
65
+ async function handleChat(req: IncomingMessage, res: ServerResponse, who: Identity): Promise<void> {
53
66
  const raw = await readBody(req);
54
67
  let body: Record<string, unknown>;
55
68
  try {
@@ -61,32 +74,90 @@ async function handleChat(req: IncomingMessage, res: ServerResponse): Promise<vo
61
74
  const model = String(body.model ?? "");
62
75
  if (!model) return json(res, 400, { error: { message: "missing 'model'" } });
63
76
 
64
- const provider = route(model, typeof body.provider === "string" ? body.provider : undefined);
77
+ // Org policy: model allowlist (enterprise). Enforced server-side so a modified client can't skip it.
78
+ let policy: import("./enterprise.ts").Policy;
79
+ try {
80
+ policy = loadPolicy();
81
+ } catch (e) {
82
+ if (e instanceof CorruptStore) return json(res, 503, { error: { message: "org policy unreadable — refusing requests (fail-closed)" } });
83
+ throw e;
84
+ }
85
+ if (!modelAllowed(model, policy)) {
86
+ appendAudit({ ts: Date.now(), user: who.user, event: "policy_denied_model", detail: model });
87
+ return json(res, 403, { error: { message: `model '${model}' is not allowed by org policy (allowed: ${policy.models!.join(", ")})` } });
88
+ }
89
+
90
+ // When an allowlist is active, IGNORE the client's `provider` hint — else a seat holder could
91
+ // send an allowlisted model id with a different provider and leak the body to it before the
92
+ // upstream rejects the id. Route by the model id only.
93
+ const explicit = policy.models?.length ? undefined : typeof body.provider === "string" ? body.provider : undefined;
94
+ const provider = route(model, explicit);
65
95
  if (!isConfigured(provider)) {
66
96
  return json(res, 400, {
67
97
  error: { message: `provider '${provider}' not configured — set ${PROVIDERS[provider].keyEnv} on the backend` },
68
98
  });
69
99
  }
70
100
 
101
+ // Metering must not be client-suppressible: force the upstream to emit a usage object on streams
102
+ // (OpenAI-compat only sends it when include_usage is set). Harmless for providers that ignore it.
103
+ if (body.stream) body.stream_options = { ...((body.stream_options as Record<string, unknown>) ?? {}), include_usage: true };
104
+
105
+ // Usage metering: tee the response (streamed or not) and record the last usage object the
106
+ // upstream reported. Wrapping res keeps this in ONE place for every adapter.
107
+ {
108
+ let tail = "";
109
+ const scan = (c: unknown): void => {
110
+ if (typeof c === "string" || Buffer.isBuffer(c)) tail = (tail + c.toString()).slice(-16_384);
111
+ };
112
+ const write = res.write.bind(res);
113
+ const end = res.end.bind(res);
114
+ res.write = ((c: never, ...a: never[]) => {
115
+ scan(c);
116
+ return write(c, ...a);
117
+ }) as typeof res.write;
118
+ res.end = ((c?: never, ...a: never[]) => {
119
+ scan(c);
120
+ const u = extractLastUsage(tail);
121
+ if (u) appendUsage({ ts: Date.now(), user: who.user, model, provider, promptTokens: u.promptTokens, completionTokens: u.completionTokens });
122
+ return end(c, ...a);
123
+ }) as typeof res.end;
124
+ }
125
+
71
126
  delete body.provider; // our routing hint; never forward it upstream
72
127
  await adapterFor(provider).chat({ provider, model, body, res });
73
128
  }
74
129
 
75
130
  /** Embeddings for @codebase semantic search — forwarded to the ollama provider's
76
- * OpenAI-compatible endpoint (embedding models only live there for now). */
77
- async function handleEmbeddings(req: IncomingMessage, res: ServerResponse): Promise<void> {
131
+ * OpenAI-compatible endpoint (embedding models only live there for now). Subject to the same org
132
+ * model allowlist as chat, and metered/attributed. */
133
+ async function handleEmbeddings(req: IncomingMessage, res: ServerResponse, who: Identity): Promise<void> {
78
134
  const raw = await readBody(req);
135
+ let body: Record<string, unknown>;
79
136
  try {
80
- JSON.parse(raw);
137
+ body = JSON.parse(raw);
81
138
  } catch {
82
139
  return json(res, 400, { error: { message: "invalid JSON body" } });
83
140
  }
141
+ const model = String(body.model ?? "");
142
+ let policy: import("./enterprise.ts").Policy;
143
+ try {
144
+ policy = loadPolicy();
145
+ } catch (e) {
146
+ if (e instanceof CorruptStore) return json(res, 503, { error: { message: "org policy unreadable — refusing requests" } });
147
+ throw e;
148
+ }
149
+ if (model && !modelAllowed(model, policy)) {
150
+ appendAudit({ ts: Date.now(), user: who.user, event: "policy_denied_model", detail: `embeddings:${model}` });
151
+ return json(res, 403, { error: { message: `embedding model '${model}' is not allowed by org policy` } });
152
+ }
84
153
  const upstream = await fetch(`${PROVIDERS.ollama.baseURL}/embeddings`, {
85
154
  method: "POST",
86
155
  headers: { "content-type": "application/json" },
87
156
  body: raw,
88
157
  });
89
158
  const text = await upstream.text();
159
+ const u = extractLastUsage(text); // embedding responses report prompt_tokens
160
+ if (u) appendUsage({ ts: Date.now(), user: who.user, model, provider: "ollama", promptTokens: u.promptTokens, completionTokens: 0 });
90
161
  res.writeHead(upstream.status, { "content-type": "application/json" });
91
162
  res.end(text);
92
163
  }
@@ -98,22 +169,85 @@ const server = createServer(async (req, res) => {
98
169
  res.writeHead(200, { "content-type": "text/plain" });
99
170
  return res.end("ada backend ok");
100
171
  }
172
+ const who = await identify(req);
173
+ if (who === "corrupt") return json(res, 503, { error: { message: "auth store unreadable — refusing all requests (fail-closed). Fix ~/.ada/server/users.json." } });
174
+ if (!who) return json(res, 401, { error: { message: "unauthorized — invalid client key, seat key, or login" } });
175
+
101
176
  if (req.method === "GET" && url.pathname === "/v1/whoami") {
102
- if (!(await authorized(req))) return json(res, 401, { error: { message: "not logged in" } });
103
- return json(res, 200, { ok: true });
177
+ return json(res, 200, { ok: true, user: who.user, role: who.role });
104
178
  }
105
179
  if (req.method === "GET" && url.pathname === "/v1/models") {
106
- if (!(await authorized(req))) return json(res, 401, { error: { message: "unauthorized — invalid client key or login" } });
107
180
  return await handleModels(res);
108
181
  }
109
182
  if (req.method === "POST" && url.pathname === "/v1/chat/completions") {
110
- if (!(await authorized(req))) return json(res, 401, { error: { message: "unauthorized — invalid client key or login" } });
111
- return await handleChat(req, res);
183
+ return await handleChat(req, res, who);
112
184
  }
113
185
  if (req.method === "POST" && url.pathname === "/v1/embeddings") {
114
- if (!(await authorized(req))) return json(res, 401, { error: { message: "unauthorized — invalid client key or login" } });
115
- return await handleEmbeddings(req, res);
186
+ return await handleEmbeddings(req, res, who);
116
187
  }
188
+
189
+ // ---- enterprise control plane ----
190
+ if (url.pathname === "/v1/policy") {
191
+ if (req.method === "GET") {
192
+ // any seat — clients fetch this and apply the tool rules locally
193
+ let policy: unknown;
194
+ try {
195
+ policy = loadPolicy();
196
+ } catch (e) {
197
+ if (e instanceof CorruptStore) return json(res, 503, { error: { message: "org policy unreadable" } });
198
+ throw e;
199
+ }
200
+ appendAudit({ ts: Date.now(), user: who.user, event: "policy_fetched", detail: "" }); // spot seats that never fetch
201
+ return json(res, 200, policy);
202
+ }
203
+ if (req.method === "PUT") {
204
+ if (who.role !== "admin") return json(res, 403, { error: { message: "admin only" } });
205
+ let parsed: unknown;
206
+ try {
207
+ parsed = JSON.parse(await readBody(req));
208
+ } catch {
209
+ return json(res, 400, { error: { message: "invalid JSON body" } });
210
+ }
211
+ const v = validatePolicy(parsed);
212
+ if ("error" in v) return json(res, 400, { error: { message: v.error } });
213
+ savePolicy(v.policy);
214
+ return json(res, 200, { ok: true });
215
+ }
216
+ }
217
+ if (url.pathname === "/v1/users") {
218
+ if (who.role !== "admin") return json(res, 403, { error: { message: "admin only" } });
219
+ if (req.method === "GET") return json(res, 200, { users: listSeats() });
220
+ if (req.method === "POST") {
221
+ let name = "";
222
+ let role: "admin" | "dev" = "dev";
223
+ try {
224
+ const b = JSON.parse(await readBody(req)) as { name?: string; role?: string };
225
+ name = String(b.name ?? "").trim();
226
+ if (b.role === "admin") role = "admin";
227
+ } catch {
228
+ /* falls through to the name check */
229
+ }
230
+ if (!name) return json(res, 400, { error: { message: "missing 'name'" } });
231
+ return json(res, 200, { key: createSeat(name, role), name, role, note: "shown once — store it now" });
232
+ }
233
+ }
234
+ {
235
+ const m = req.method === "DELETE" && url.pathname.match(/^\/v1\/users\/([\w]+)$/);
236
+ if (m) {
237
+ if (who.role !== "admin") return json(res, 403, { error: { message: "admin only" } });
238
+ const name = disableSeat(m[1]!);
239
+ return json(res, name ? 200 : 404, name ? { ok: true, disabled: name } : { error: { message: "unknown or ambiguous key prefix (send ≥12 chars)" } });
240
+ }
241
+ }
242
+ if (req.method === "GET" && url.pathname === "/v1/usage") {
243
+ if (who.role !== "admin") return json(res, 403, { error: { message: "admin only" } });
244
+ return json(res, 200, usageSummary(Math.min(Number(url.searchParams.get("days")) || 30, 365)));
245
+ }
246
+ if (req.method === "GET" && url.pathname === "/v1/audit") {
247
+ if (who.role !== "admin") return json(res, 403, { error: { message: "admin only" } });
248
+ return json(res, 200, { events: auditTail(Math.min(Number(url.searchParams.get("limit")) || 200, 2000)) });
249
+ }
250
+
117
251
  return json(res, 404, { error: { message: "not found" } });
118
252
  } catch (err) {
119
253
  if (!res.headersSent) json(res, 500, { error: { message: err instanceof Error ? err.message : String(err) } });
@@ -127,9 +261,13 @@ const server = createServer(async (req, res) => {
127
261
  });
128
262
 
129
263
  server.listen(PORT, () => {
130
- const auth = locked()
131
- ? `auth ON (client keys + GitHub/Google login${allowedUsers() ? `, allowlist: ${allowedUsers()!.length}` : ""})`
132
- : "AUTH DISABLED (dev) — set ADA_CLIENT_KEYS or ADA_ALLOWED_USERS to lock down";
264
+ if (enterpriseMode() && clientKeys()) console.warn("\x1b[33m[warn] ADA_CLIENT_KEYS is set but ignored in enterprise mode (seats supersede it) — unset it to avoid confusion.\x1b[0m");
265
+ const seats = listSeats().filter((s) => !s.disabled).length;
266
+ const auth = enterpriseMode()
267
+ ? `ENTERPRISE (${seats} seat${seats === 1 ? "" : "s"}${process.env.ADA_ADMIN_KEY ? " + admin key" : ""})`
268
+ : locked()
269
+ ? `auth ON (client keys + GitHub/Google login${allowedUsers() ? `, allowlist: ${allowedUsers()!.length}` : ""})`
270
+ : "AUTH DISABLED (dev) — set ADA_CLIENT_KEYS or ADA_ADMIN_KEY to lock down";
133
271
  const provs = configuredProviders();
134
272
  console.log(`ada backend → http://localhost:${PORT} [${auth}]`);
135
273
  console.log(`providers: ${provs.length ? provs.join(", ") : "(none configured — set provider API keys)"}`);
@@ -114,6 +114,8 @@ export const anthropicAdapter: Adapter = {
114
114
 
115
115
  let stop = "stop";
116
116
  let toolIndex = -1;
117
+ let inTokens = 0; // Anthropic reports input on message_start, cumulative output on message_delta
118
+ let outTokens = 0;
117
119
 
118
120
  try {
119
121
  const client = await getClient();
@@ -148,7 +150,9 @@ export const anthropicAdapter: Adapter = {
148
150
  );
149
151
 
150
152
  for await (const event of stream) {
151
- if (event.type === "content_block_start") {
153
+ if (event.type === "message_start") {
154
+ inTokens = (event.message as { usage?: { input_tokens?: number } }).usage?.input_tokens ?? 0;
155
+ } else if (event.type === "content_block_start") {
152
156
  const cb = event.content_block as { type: string; id?: string; name?: string };
153
157
  if (cb.type === "tool_use") {
154
158
  toolIndex++;
@@ -161,10 +165,15 @@ export const anthropicAdapter: Adapter = {
161
165
  } else if (event.type === "message_delta") {
162
166
  const reason = (event.delta as { stop_reason?: string | null }).stop_reason;
163
167
  if (reason) stop = mapStop(reason);
168
+ const ot = (event as { usage?: { output_tokens?: number } }).usage?.output_tokens;
169
+ if (typeof ot === "number") outTokens = ot; // cumulative — take the latest
164
170
  }
165
171
  }
166
172
 
167
173
  chunk({}, stop);
174
+ // Emit an OpenAI-shaped usage chunk so the backend's metering (and the client's own token
175
+ // counters) work for Claude too — Anthropic doesn't send one in this wire format.
176
+ writeChunk(res, { id, object: "chat.completion.chunk", created, model, choices: [], usage: { prompt_tokens: inTokens, completion_tokens: outTokens, total_tokens: inTokens + outTokens } });
168
177
  endStream(res);
169
178
  } catch (err) {
170
179
  chunk({ content: `\n[backend: anthropic error: ${err instanceof Error ? err.message : String(err)}]` }, "stop");
@@ -42,14 +42,25 @@ export const openAICompatAdapter: Adapter = {
42
42
  // endpoint wants the bare id. (Cloudflare's "@cf/…" ids aren't "cloudflare/…", so they pass through.)
43
43
  const prefix = `${provider}/`;
44
44
  const outBody = typeof body.model === "string" && body.model.startsWith(prefix) ? { ...body, model: body.model.slice(prefix.length) } : body;
45
+ // If the client goes away, abort the upstream too — else the full completion is generated,
46
+ // billed, and (for enterprise) metered against a request nobody is reading.
47
+ const ac = new AbortController();
48
+ res.on("close", () => {
49
+ if (!res.writableEnded) ac.abort();
50
+ });
45
51
  let upstream: Awaited<ReturnType<typeof fetch>>;
46
52
  try {
47
53
  upstream = await fetch(`${def.baseURL}/chat/completions`, {
48
54
  method: "POST",
49
55
  headers: { "content-type": "application/json", ...(await authHeaders(provider)) },
50
56
  body: JSON.stringify(outBody),
57
+ signal: ac.signal,
51
58
  });
52
59
  } catch (e) {
60
+ if (ac.signal.aborted) {
61
+ res.end();
62
+ return;
63
+ }
53
64
  res.writeHead(502, { "content-type": "application/json" });
54
65
  res.end(
55
66
  JSON.stringify({
@@ -71,10 +82,18 @@ export const openAICompatAdapter: Adapter = {
71
82
  if (body.stream) {
72
83
  res.writeHead(200, SSE_HEADERS);
73
84
  const reader = upstream.body.getReader();
74
- for (;;) {
75
- const { done, value } = await reader.read();
76
- if (done) break;
77
- if (value) res.write(Buffer.from(value));
85
+ try {
86
+ for (;;) {
87
+ const { done, value } = await reader.read();
88
+ if (done) break;
89
+ if (res.destroyed) {
90
+ await reader.cancel(); // client gone → stop pulling tokens from upstream
91
+ break;
92
+ }
93
+ if (value) res.write(Buffer.from(value));
94
+ }
95
+ } catch {
96
+ /* aborted mid-stream (client closed) — nothing more to do */
78
97
  }
79
98
  res.end();
80
99
  } else {